Compare commits

...

51 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
120 changed files with 17599 additions and 1936 deletions

View File

@ -1,4 +1,4 @@
<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" />

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,7 +55,7 @@
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"settings": "Settings", "settings": "Settings",
"settingsNotificationBgService": "Background Notification Service", "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.", "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",
@ -67,11 +68,20 @@
"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",
@ -103,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!",
@ -147,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",
@ -215,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",
@ -240,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",
@ -334,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",
@ -397,5 +417,41 @@
"userLevel13": "Immortal", "userLevel13": "Immortal",
"postBrowsingIn": "Browsing in @region", "postBrowsingIn": "Browsing in @region",
"needRestartToApply": "Restart the application to take effect", "needRestartToApply": "Restart the application to take effect",
"holdToSeeDetail": "Long press / Mouse hover to see detail" "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": "确认",
@ -31,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 条未读的消息",
@ -75,8 +76,12 @@
"forgotPassword": "忘记密码", "forgotPassword": "忘记密码",
"email": "邮件地址", "email": "邮件地址",
"username": "用户名", "username": "用户名",
"usernameInputHint": "同时支持邮箱 / 电话号码",
"nickname": "显示名", "nickname": "显示名",
"password": "密码", "password": "密码",
"passwordOneTime": "一次性验证码",
"passwordInputHint": "忘记密码了?回到第一步以重置密码",
"passwordOneTimeInputHint": "检查你的收件箱或是授权器获得以验证码",
"title": "标题", "title": "标题",
"description": "简介", "description": "简介",
"birthday": "生日", "birthday": "生日",
@ -108,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 的存取权!",
@ -158,6 +168,9 @@
"postListNews": "新鲜事", "postListNews": "新鲜事",
"postListFriends": "好友圈", "postListFriends": "好友圈",
"postListShuffle": "打乱看", "postListShuffle": "打乱看",
"attachmentThumbnail": "附件缩略图",
"attachmentThumbnailAttachmentNew": "上传附件作为缩略图",
"attachmentThumbnailAttachment": "附件序列号",
"postNew": "创建新帖子", "postNew": "创建新帖子",
"postNewInRealmHint": "在领域 @realm 里发表新帖子", "postNewInRealmHint": "在领域 @realm 里发表新帖子",
"postAction": "发表", "postAction": "发表",
@ -216,6 +229,8 @@
"realmDescription": "领域简介", "realmDescription": "领域简介",
"realmPublic": "公开领域", "realmPublic": "公开领域",
"realmCommunity": "社区领域", "realmCommunity": "社区领域",
"realmAvatar": "领域头像",
"realmBanner": "领域横幅",
"realmDetail": "领域详情", "realmDetail": "领域详情",
"realmMember": "领域成员", "realmMember": "领域成员",
"realmMembers": "领域成员", "realmMembers": "领域成员",
@ -241,7 +256,8 @@
"channelName": "显示名称", "channelName": "显示名称",
"channelDescription": "频道简介", "channelDescription": "频道简介",
"channelDirectDescription": "与 @username 的私聊", "channelDirectDescription": "与 @username 的私聊",
"channelEncrypted": "加密频道", "channelPublic": "公开频道",
"channelCommunity": "社区频道",
"channelMember": "频道成员", "channelMember": "频道成员",
"channelMembers": "频道成员", "channelMembers": "频道成员",
"channelMembersAdd": "添加频道成员", "channelMembersAdd": "添加频道成员",
@ -335,8 +351,7 @@
"bsCheckForUpdate": "正在检查更新", "bsCheckForUpdate": "正在检查更新",
"bsCheckForUpdateFailed": "无法检查更新", "bsCheckForUpdateFailed": "无法检查更新",
"bsCheckForUpdateNew": "发现新版本", "bsCheckForUpdateNew": "发现新版本",
"bsCheckForUpdateDescApple": "请前往 TestFlight 并将您的应用程序更新到最新版本,以防止出现错误并获取最新功能。", "bsCheckForUpdateDesc": "请前往应用商店并将您的应用程序更新到最新版本,以防止出现错误并获取最新功能。",
"bsCheckForUpdateDescCommon": "请前往我们的网站下载并安装最新版本的应用程序,以防止出现错误并获取最新功能。",
"bsCheckingServer": "检查服务器状态中", "bsCheckingServer": "检查服务器状态中",
"bsCheckingServerFail": "无法连接至服务器,请检查你的网络连接状态", "bsCheckingServerFail": "无法连接至服务器,请检查你的网络连接状态",
"bsCheckingServerDown": "当前服务器不可用,请稍后重试", "bsCheckingServerDown": "当前服务器不可用,请稍后重试",
@ -398,5 +413,41 @@
"userLevel13": "万古流芳", "userLevel13": "万古流芳",
"postBrowsingIn": "浏览 @region 内的帖子中", "postBrowsingIn": "浏览 @region 内的帖子中",
"needRestartToApply": "需要重启应用来生效", "needRestartToApply": "需要重启应用来生效",
"holdToSeeDetail": "长按 / 鼠标悬浮来查看详情" "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

@ -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.1): - 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.1): - firebase_core (3.5.0):
- Firebase/CoreOnly (= 11.0.0) - Firebase/CoreOnly (= 11.0.0)
- Flutter - Flutter
- firebase_crashlytics (4.1.1): - 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.1): - 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-6): - 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,8 +152,10 @@ 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_background_service_ios (0.0.3):
- Flutter - Flutter
- flutter_keyboard_visibility (0.0.1): - flutter_keyboard_visibility (0.0.1):
@ -225,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.5): - 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):
@ -306,6 +308,7 @@ 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_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_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
@ -383,6 +386,8 @@ 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: flutter_background_service_ios:
:path: ".symlinks/plugins/flutter_background_service_ios/ios" :path: ".symlinks/plugins/flutter_background_service_ios/ios"
flutter_keyboard_visibility: flutter_keyboard_visibility:
@ -445,25 +450,26 @@ SPEC CHECKSUMS:
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Firebase: 9f574c08c2396885b5e7e100ed4293d956218af9 Firebase: 9f574c08c2396885b5e7e100ed4293d956218af9
firebase_analytics: b8ce6c2c4b245d3c3bb3a147965d09da0f455959 firebase_analytics: 4fd10182fd08bb8358f26ac8aca8dad7b6d0f592
firebase_core: ba84e940cf5cbbc601095f86556560937419195c firebase_core: 2ec6b789859c7c24766344ec71fdf78639402d56
firebase_crashlytics: 4111f8198b78c99471c955af488cecd8224967e6 firebase_crashlytics: 60630a0f91ee432275fa1660fd8593079761448a
firebase_messaging: c40f84e7a98da956d5262fada373b5c458edcf13 firebase_messaging: a18e1e02b2e8e69097c8173e0c851be223b21c50
firebase_performance: 8b7b9ca5adf3a9b3afa12b4eb96b9cabefc2c248 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_background_service_ios: e30e0d3ee69e4cee66272d0c78eacd48c2e94aac
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
@ -476,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: 9c8080879256a0fb16da13c9be4845248209d896 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

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)";

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,7 +1,8 @@
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/database/database.dart';
import 'package:solian/providers/database/services/messages.dart'; import 'package:solian/providers/database/services/messages.dart';
@ -31,80 +32,56 @@ class ChatEventController {
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 src.fetchRemoteEvents(
channel,
scope,
depth: 1,
offset: 0,
);
totalEvents.value = result?.$2 ?? 0;
if (result != null) {
for (final x in result.$1.reversed) {
final entry = LocalMessageEventTableData(
id: x.id,
channelId: x.channelId,
createdAt: x.createdAt,
data: x,
);
insertEvent(entry);
applyEvent(entry);
}
}
} else {
final result = await src.pullRemoteEvents(
channel,
scope: scope,
depth: 1,
);
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 src.fetchRemoteEvents( src
channel, .pullRemoteEvents(channel, scope: scope, take: take, offset: offset)
scope, .then((result) {
depth: 3,
offset: currentEvents.length,
);
if (result != null) {
totalEvents.value = result.$2;
for (final x in result.$1.reversed) {
final entry = LocalMessageEventTableData(
id: x.id,
channelId: x.channelId,
createdAt: x.createdAt,
data: x,
);
currentEvents.add(entry);
applyEvent(entry);
}
}
} else {
final result = await src.pullRemoteEvents(
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 src.listEvents(channel); 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);
} }
@ -113,16 +90,7 @@ class ChatEventController {
receiveEvent(Event remote) async { receiveEvent(Event remote) async {
LocalMessageEventTableData entry; LocalMessageEventTableData entry;
if (PlatformInfo.isWeb) { entry = await src.receiveEvent(remote);
entry = LocalMessageEventTableData(
id: remote.id,
channelId: remote.channelId,
createdAt: remote.createdAt,
data: remote,
);
} else {
entry = await src.receiveEvent(remote);
}
totalEvents.value++; totalEvents.value++;
insertEvent(entry); insertEvent(entry);

View File

@ -1,6 +1,8 @@
import 'dart:math' as math; 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';
@ -51,6 +53,69 @@ extension AppExtensions 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) {

View File

@ -9,7 +9,6 @@ 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/background.dart'; import 'package:solian/background.dart';
import 'package:solian/bootstrapper.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';
@ -20,6 +19,7 @@ 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';
@ -122,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(),
),
), ),
); );
}, },
@ -151,6 +149,7 @@ class SolianApp extends StatelessWidget {
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(); 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

@ -9,6 +9,7 @@ import 'package:get/get_connect/http/src/request/request.dart';
import 'package:solian/background.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/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';
@ -114,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);
@ -148,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) {
@ -217,7 +204,7 @@ class AuthProvider extends GetConnect {
Future<void> refreshUserProfile() async { Future<void> refreshUserProfile() async {
if (!isAuthorized.value) return; if (!isAuthorized.value) return;
final client = configureClient('auth'); 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

@ -20,7 +20,13 @@ class AppDatabase extends _$AppDatabase {
int get schemaVersion => 1; int get schemaVersion => 1;
static QueryExecutor _openConnection() { static QueryExecutor _openConnection() {
return driftDatabase(name: 'solar_network_local_db'); 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 { static Future<int> getDatabaseSize() async {

View File

@ -12,7 +12,7 @@ class MessagesFetchingProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null; if (auth.isAuthorized.isFalse) return null;
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.get( final resp = await client.get(
'/whats-new?pivot=$pivot&take=$take', '/whats-new?pivot=$pivot&take=$take',
@ -33,7 +33,7 @@ class MessagesFetchingProvider extends GetxController {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null; if (auth.isAuthorized.isFalse) return null;
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.get( final resp = await client.get(
'/channels/$scope/${channel.alias}/events/$id', '/channels/$scope/${channel.alias}/events/$id',
@ -51,19 +51,13 @@ class MessagesFetchingProvider extends GetxController {
Future<(List<Event>, int)?> fetchRemoteEvents( Future<(List<Event>, int)?> fetchRemoteEvents(
Channel channel, Channel channel,
String scope, { String scope, {
required int depth,
bool Function(List<Event> items)? onBrake,
take = 10, take = 10,
offset = 0, offset = 0,
}) async { }) async {
if (depth <= 0) {
return null;
}
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null; if (auth.isAuthorized.isFalse) return null;
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.get( final resp = await client.get(
'/channels/$scope/${channel.alias}/events?take=$take&offset=$offset', '/channels/$scope/${channel.alias}/events?take=$take&offset=$offset',
@ -77,21 +71,7 @@ class MessagesFetchingProvider extends GetxController {
final result = final result =
response.data?.map((e) => Event.fromJson(e)).toList() ?? List.empty(); response.data?.map((e) => Event.fromJson(e)).toList() ?? List.empty();
if (onBrake != null && onBrake(result)) { return (result, response.count);
return (result, response.count);
}
final expandResult = (await fetchRemoteEvents(
channel,
scope,
depth: depth - 1,
take: take,
offset: offset + result.length,
))
?.$1 ??
List.empty();
return ([...result, ...expandResult], response.count);
} }
Future<LocalMessageEventTableData> receiveEvent(Event remote) async { Future<LocalMessageEventTableData> receiveEvent(Event remote) async {
@ -149,24 +129,24 @@ class MessagesFetchingProvider extends GetxController {
return await receiveEvent(remoteRecord); 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 /// Pull the remote events to local database
Future<(List<Event>, int)?> pullRemoteEvents(Channel channel, Future<(List<Event>, int)?> pullRemoteEvents(Channel channel,
{String scope = 'global', depth = 10, offset = 0}) async { {String scope = 'global', take = 10, offset = 0}) async {
final database = Get.find<DatabaseProvider>().database; final database = Get.find<DatabaseProvider>().database;
final lastOne = await (database.select(database.localMessageEventTable)
..where((x) => x.channelId.equals(channel.id))
..orderBy([(t) => OrderingTerm.desc(t.id)])
..limit(1))
.getSingleOrNull();
final data = await fetchRemoteEvents( final data = await fetchRemoteEvents(
channel, channel,
scope, scope,
depth: depth,
offset: offset, offset: offset,
onBrake: (items) { take: take,
return items.any((x) => x.id == lastOne?.id);
},
); );
if (data != null) { if (data != null) {
await database.batch((batch) { await database.batch((batch) {
@ -185,11 +165,13 @@ class MessagesFetchingProvider extends GetxController {
return data; return data;
} }
Future<List<LocalMessageEventTableData>> listEvents(Channel channel) async { Future<List<LocalMessageEventTableData>> listEvents(Channel channel,
{required int take, int offset = 0}) async {
final database = Get.find<DatabaseProvider>().database; final database = Get.find<DatabaseProvider>().database;
return await (database.select(database.localMessageEventTable) return await (database.select(database.localMessageEventTable)
..where((x) => x.channelId.equals(channel.id)) ..where((x) => x.channelId.equals(channel.id))
..orderBy([(t) => OrderingTerm.desc(t.id)])) ..orderBy([(t) => OrderingTerm.desc(t.id)])
..limit(take, offset: offset))
.get(); .get();
} }

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

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

@ -138,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) {
@ -152,6 +152,8 @@ class WebSocketProvider extends GetxController {
} }
Future<void> registerPushNotifications() async { Future<void> registerPushNotifications() async {
if (PlatformInfo.isWeb) return;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('service_background_notification') == true) { if (prefs.getBool('service_background_notification') == true) {
log('Background notification service has been enabled, skip register push notifications'); log('Background notification service has been enabled, skip register push notifications');
@ -180,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,14 +242,6 @@ 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: 'accountProfile', name: 'accountProfile',
@ -252,6 +250,14 @@ abstract class AppRouter {
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

@ -6,8 +6,6 @@ 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;
@ -47,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();
@ -73,13 +66,7 @@ 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();
} }
@ -94,13 +81,7 @@ 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(() {});
}); });
}, },
@ -150,6 +131,15 @@ class _AccountScreenState extends State<AccountScreen> {
AppRouter.instance.pushNamed('settings'); 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) const Divider(thickness: 0.3, height: 1)
.paddingSymmetric(vertical: 4), .paddingSymmetric(vertical: 4),
ListTile( ListTile(
@ -219,7 +209,6 @@ class _ActionCard extends StatelessWidget {
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',
@ -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(

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,49 @@
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/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();
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 { void _requestResetPassword() async {
final username = _usernameController.value.text; final username = _usernameController.value.text;
if (username.isEmpty) { if (username.isEmpty) {
@ -32,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);
@ -53,158 +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();
autoConfigureBackgroundNotificationService();
autoStartBackgroundNotificationService();
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

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

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

@ -88,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) {
@ -103,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) {
@ -379,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,
),
), ),
], ],
), ),

View File

@ -69,7 +69,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
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('realmSettings'.tr.capitalize!), title: Text('realmSettings'.tr),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () async { onTap: () async {
AppRouter.instance AppRouter.instance
.pushNamed( .pushNamed(
@ -120,14 +121,16 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
child: ListView( child: ListView(
children: [ children: [
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('realmMembers'.tr.capitalize!), title: Text('realmMembers'.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

@ -1,9 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
@ -29,26 +35,30 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
bool _isBusy = false; bool _isBusy = false;
final _aliasController = TextEditingController(); final _aliasController = TextEditingController();
final _avatarController = TextEditingController();
final _bannerController = TextEditingController();
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
bool _isCommunity = false; bool _isCommunity = false;
bool _isPublic = false; bool _isPublic = false;
void applyRealm() async { void _applyRealm() 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);
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final payload = { final payload = {
'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,
'avatar': _avatarController.value.text,
'banner': _bannerController.value.text,
'is_public': _isPublic, 'is_public': _isPublic,
'is_community': _isCommunity, 'is_community': _isCommunity,
}; };
@ -68,31 +78,110 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
void randomizeAlias() { final _imagePicker = ImagePicker();
Future<void> _editImage(String position) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return;
CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: image.path,
uiSettings: [
AndroidUiSettings(
toolbarTitle: 'cropImage'.tr,
toolbarColor: Theme.of(context).colorScheme.primary,
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
aspectRatioPresets: [
if (position == 'avatar') CropAspectRatioPreset.square,
if (position == 'banner') _BannerCropAspectRatioPreset(),
],
),
IOSUiSettings(
title: 'cropImage'.tr,
aspectRatioPresets: [
if (position == 'avatar') CropAspectRatioPreset.square,
if (position == 'banner') _BannerCropAspectRatioPreset(),
],
),
WebUiSettings(
context: context,
),
],
);
if (croppedFile == null) return;
final file = File(croppedFile.path);
setState(() => _isBusy = true);
final AttachmentProvider attach = Get.find();
Attachment? attachResult;
try {
attachResult = await attach.createAttachmentDirectly(
await file.readAsBytes(),
file.path,
'avatar',
null,
);
} catch (e) {
setState(() => _isBusy = false);
context.showErrorDialog(e);
return;
}
switch (position) {
case 'avatar':
_avatarController.text = attachResult.rid;
break;
case 'banner':
_bannerController.text = attachResult.rid;
break;
}
setState(() => _isBusy = false);
}
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;
_avatarController.text = widget.edit!.avatar ?? '';
_bannerController.text = widget.edit!.banner ?? '';
_isPublic = widget.edit!.isPublic; _isPublic = widget.edit!.isPublic;
_isCommunity = widget.edit!.isCommunity; _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();
_avatarController.dispose();
_bannerController.dispose();
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
@ -105,7 +194,7 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
toolbarHeight: AppTheme.toolbarHeight(context), toolbarHeight: AppTheme.toolbarHeight(context),
actions: [ actions: [
TextButton( TextButton(
onPressed: _isBusy ? null : () => applyRealm(), onPressed: _isBusy ? null : () => _applyRealm(),
child: Text('apply'.tr.toUpperCase()), child: Text('apply'.tr.toUpperCase()),
) )
], ],
@ -126,7 +215,7 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: cancelAction, onPressed: _cancelAction,
child: Text('cancel'.tr), child: Text('cancel'.tr),
), ),
], ],
@ -150,7 +239,7 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
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),
) )
], ],
@ -166,6 +255,55 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
).paddingSymmetric(horizontal: 16, vertical: 8), ).paddingSymmetric(horizontal: 16, vertical: 8),
const Divider(thickness: 0.3), const Divider(thickness: 0.3),
Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _avatarController,
decoration: InputDecoration.collapsed(
hintText: 'realmAvatar'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: _isBusy ? null : () => _editImage('avatar'),
child: const Icon(Icons.upload),
)
],
).paddingSymmetric(horizontal: 16, vertical: 2),
Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _bannerController,
decoration: InputDecoration.collapsed(
hintText: 'realmBanner'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: _isBusy ? null : () => _editImage('banner'),
child: const Icon(Icons.upload),
)
],
).paddingSymmetric(horizontal: 16, vertical: 2),
const Divider(thickness: 0.3),
Expanded( Expanded(
child: TextField( child: TextField(
minLines: 5, minLines: 5,
@ -202,3 +340,11 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
); );
} }
} }
class _BannerCropAspectRatioPreset extends CropAspectRatioPresetData {
@override
(int, int)? get data => (16, 7);
@override
String get name => '16x7';
}

View File

@ -34,7 +34,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
final List<Channel> _channels = List.empty(growable: true); final List<Channel> _channels = List.empty(growable: true);
getRealm({String? overrideAlias}) async { getRealm({String? overrideAlias}) async {
final RealmProvider provider = Get.find(); final RealmProvider realm = Get.find();
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -43,7 +43,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
} }
try { try {
final resp = await provider.getRealm(_overrideAlias ?? widget.alias); final resp = await realm.getRealm(_overrideAlias ?? widget.alias);
setState(() => _realm = Realm.fromJson(resp.body)); setState(() => _realm = Realm.fromJson(resp.body));
} catch (e) { } catch (e) {
context.showErrorDialog(e); context.showErrorDialog(e);
@ -55,14 +55,26 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
getChannels() async { getChannels() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final ChannelProvider provider = Get.find(); final ChannelProvider channel = Get.find();
final resp = await provider.listChannel(scope: _realm!.alias); final resp = await channel.listChannel(scope: _realm!.alias);
final availableResp = await channel.listAvailableChannel(
scope: _realm!.alias,
);
final Set<int> channelIdx = {};
setState(() { setState(() {
_channels.clear(); _channels.clear();
_channels.addAll( _channels.addAll(
resp.body.map((e) => Channel.fromJson(e)).toList().cast<Channel>(), resp.body.map((e) => Channel.fromJson(e)).toList().cast<Channel>(),
); );
_channels.addAll(
availableResp.body
.map((e) => Channel.fromJson(e))
.toList()
.cast<Channel>(),
);
_channels.retainWhere((x) => channelIdx.add(x.id));
}); });
setState(() => _isBusy = false); setState(() => _isBusy = false);

View File

@ -2,12 +2,15 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.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/database/database.dart'; import 'package:solian/providers/database/database.dart';
import 'package:solian/providers/theme_switcher.dart'; import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/reports/abuse_report.dart';
class SettingScreen extends StatefulWidget { class SettingScreen extends StatefulWidget {
const SettingScreen({super.key}); const SettingScreen({super.key});
@ -114,6 +117,69 @@ class _SettingScreenState extends State<SettingScreen> {
}, },
), ),
), ),
_buildCaptionHeader('update'.tr),
CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
secondary: const Icon(Icons.sync_alt),
title: Text('updateCheckStrictly'.tr),
subtitle: Text('updateCheckStrictlyDesc'.tr),
value: _prefs?.getBool('check_update_strictly') ?? false,
onChanged: (value) {
_prefs
?.setBool('check_update_strictly', value ?? false)
.then((_) {
setState(() {});
});
},
),
Obx(() {
final AuthProvider auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) return const SizedBox.shrink();
return Column(
children: [
_buildCaptionHeader('account'.tr),
ListTile(
leading: const Icon(Icons.flag),
trailing: const Icon(Icons.chevron_right),
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
title: Text('reportAbuse'.tr),
subtitle: Text('reportAbuseDesc'.tr),
onTap: () {
showDialog(
context: context,
builder: (context) => const AbuseReportDialog(),
);
},
),
ListTile(
leading: const Icon(Icons.person_remove),
trailing: const Icon(Icons.chevron_right),
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
title: Text('accountDeletion'.tr),
subtitle: Text('accountDeletionDesc'.tr),
onTap: () {
context
.showSlideToConfirmDialog(
'accountDeletionConfirm'.tr,
'accountDeletionConfirmDesc'.trParams({
'account': '@${auth.userProfile.value!['name']}',
}),
)
.then((value) async {
if (value != true) return;
final client = await auth.configureClient('id');
final resp = await client.post('/users/me/deletion', {});
if (resp.statusCode != 200) {
context.showErrorDialog(RequestException(resp));
} else {
context.showSnackbar('accountDeletionRequested'.tr);
}
});
},
),
],
);
}),
_buildCaptionHeader('more'.tr), _buildCaptionHeader('more'.tr),
ListTile( ListTile(
leading: const Icon(Icons.delete_sweep), leading: const Icon(Icons.delete_sweep),

View File

@ -1,28 +1,58 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:solian/platform.dart';
abstract class ServiceFinder { abstract class ServiceFinder {
static const bool devFlag = false; static const bool devFlag = false;
static const String dealerUrl = static const String dealerUrl =
devFlag ? 'http://localhost:8442' : 'https://api.sn.solsynth.dev'; devFlag ? 'http://localhost:8442' : 'https://api.sn.solsynth.dev';
static const String capitalUrl =
devFlag ? 'http://localhost:8444' : 'https://solsynth.dev';
static String buildUrl(String serviceName, String? append) { static String buildUrl(String serviceName, String? append) {
append ??= ''; append ??= '';
if (serviceName == 'dealer') { if (serviceName == 'dealer') {
return '$dealerUrl$append'; return '$dealerUrl$append';
} else if (serviceName == 'capital') {
return '$capitalUrl$append';
} }
return '$dealerUrl/cgi/$serviceName$append'; return '$dealerUrl/cgi/$serviceName$append';
} }
static GetConnect configureClient(String serviceName, static Future<String> getUserAgent() async {
{timeout = const Duration(seconds: 5)}) { final String platformInfo;
if (PlatformInfo.isAndroid) {
final deviceInfo = await DeviceInfoPlugin().androidInfo;
platformInfo =
'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}';
} else if (PlatformInfo.isIOS) {
final deviceInfo = await DeviceInfoPlugin().iosInfo;
platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}';
} else if (PlatformInfo.isMacOS) {
final deviceInfo = await DeviceInfoPlugin().macOsInfo;
platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}';
} else if (PlatformInfo.isWindows) {
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
platformInfo =
'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
} else if (PlatformInfo.isLinux) {
final deviceInfo = await DeviceInfoPlugin().linuxInfo;
platformInfo = 'Linux; ${deviceInfo.prettyName}';
} else if (PlatformInfo.isWeb) {
final deviceInfo = await DeviceInfoPlugin().webBrowserInfo;
platformInfo = 'Web; ${deviceInfo.vendor}';
} else {
platformInfo = 'Unknown';
}
final packageInfo = await PackageInfo.fromPlatform();
return 'Solian/${packageInfo.version}+${packageInfo.buildNumber} ($platformInfo)';
}
static Future<GetConnect> configureClient(String serviceName,
{timeout = const Duration(seconds: 5)}) async {
final client = GetConnect( final client = GetConnect(
timeout: timeout, timeout: timeout,
userAgent: 'Solian/1.1', userAgent: await getUserAgent(),
sendUserAgent: true, sendUserAgent: true,
); );
client.httpClient.baseUrl = buildUrl(serviceName, null); client.httpClient.baseUrl = buildUrl(serviceName, null);

View File

@ -24,6 +24,8 @@ class TitleShell extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(state != null || title != null);
return Scaffold( return Scaffold(
appBar: showAppBar appBar: showAppBar
? AppBar( ? AppBar(

View File

@ -35,6 +35,9 @@ abstract class AppTheme {
brightness: brightness, brightness: brightness,
seedColor: seedColor ?? const Color.fromRGBO(154, 98, 91, 1), seedColor: seedColor ?? const Color.fromRGBO(154, 98, 91, 1),
), ),
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
fontFamily: 'Comfortaa', fontFamily: 'Comfortaa',
fontFamilyFallback: [ fontFamilyFallback: [
'NotoSansSC', 'NotoSansSC',

View File

@ -26,7 +26,7 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final client = ServiceFinder.configureClient('auth'); final client = await ServiceFinder.configureClient('auth');
final resp = await client.get('/users/${widget.name}'); final resp = await client.get('/users/${widget.name}');
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
setState(() { setState(() {

View File

@ -36,16 +36,13 @@ class _AccountSelectorState extends State<AccountSelector> {
_revertSelectedUsers() async { _revertSelectedUsers() async {
if (widget.initialSelection?.isEmpty ?? true) return; if (widget.initialSelection?.isEmpty ?? true) return;
final client = ServiceFinder.configureClient('auth'); final client = await ServiceFinder.configureClient('auth');
final idQuery = widget.initialSelection!.join(','); final idQuery = widget.initialSelection!.join(',');
final resp = await client.get('/users?id=$idQuery'); final resp = await client.get('/users?id=$idQuery');
setState(() { setState(() {
_selectedUsers.addAll( _selectedUsers.addAll(
resp.body resp.body.map((e) => Account.fromJson(e)).toList().cast<Account>(),
.map((e) => Account.fromJson(e))
.toList()
.cast<Account>(),
); );
}); });
} }
@ -73,7 +70,7 @@ class _AccountSelectorState extends State<AccountSelector> {
if (_probeController.text.isEmpty) return; if (_probeController.text.isEmpty) return;
final client = auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.get( final resp = await client.get(
'/users/search?probe=${_probeController.text}', '/users/search?probe=${_probeController.text}',
); );
@ -156,7 +153,8 @@ class _AccountSelectorState extends State<AccountSelector> {
} }
setState(() { setState(() {
final idx = _selectedUsers.indexWhere((x) => x.id == element.id); final idx = _selectedUsers
.indexWhere((x) => x.id == element.id);
if (idx != -1) { if (idx != -1) {
_selectedUsers.removeAt(idx); _selectedUsers.removeAt(idx);
} else { } else {

View File

@ -1,49 +1,43 @@
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:solian/screens/auth/signin.dart'; import 'package:solian/router.dart';
import 'package:solian/widgets/sized_container.dart';
class SigninRequiredOverlay extends StatelessWidget { class SigninRequiredOverlay extends StatelessWidget {
final Function onSignedIn; final Function onDone;
const SigninRequiredOverlay({super.key, required this.onSignedIn}); const SigninRequiredOverlay({super.key, required this.onDone});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
child: Center( child: CenteredContainer(
child: Container( maxWidth: 280,
constraints: const BoxConstraints(maxWidth: 280), child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ const Icon(
const Icon( Icons.login,
Icons.login, size: 48,
size: 48, ),
), const Gap(8),
const Gap(8), Text(
Text( 'signinRequired'.tr,
'signinRequired'.tr, style: Theme.of(context).textTheme.titleLarge,
style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center,
textAlign: TextAlign.center, ),
), Text(
Text( 'signinRequiredHint'.tr,
'signinRequiredHint'.tr, style: Theme.of(context).textTheme.bodyMedium,
style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center,
textAlign: TextAlign.center, ),
), ],
],
),
), ),
), ),
onTap: () { onTap: () {
showModalBottomSheet( AppRouter.instance.pushNamed('signin').then((value) {
useRootNavigator: true, if (value != null) onDone();
isScrollControlled: true,
context: context,
builder: (context) => const SignInPopup(),
).then((value) {
if (value != null) onSignedIn();
}); });
}, },
); );

View File

@ -312,7 +312,9 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
} }
Widget _buildQueueEntry(AttachmentUploadTask element, int index) { Widget _buildQueueEntry(AttachmentUploadTask element, int index) {
final extName = extension(element.file.path).substring(1); final extName = element.file.name.contains('.')
? extension(element.file.name).substring(1)
: '';
final canBeCrop = ['png', 'jpg', 'jpeg', 'gif'].contains(extName); final canBeCrop = ['png', 'jpg', 'jpeg', 'gif'].contains(extName);
return Container( return Container(
@ -482,7 +484,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
), ),
), ),
Text( Text(
'${fileType[0].toUpperCase()}${fileType.substring(1)} · ${element.size.formatBytes()}', '${fileType.isNotEmpty ? fileType.capitalize : 'unknown'.tr} · ${element.size.formatBytes()}',
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
), ),
], ],

View File

@ -93,14 +93,14 @@ class _AttachmentEditorThumbnailDialogState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
title: Text('postThumbnail'.tr), title: Text('attachmentThumbnail'.tr),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Card( Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: ListTile( child: ListTile(
title: Text('postThumbnailAttachmentNew'.tr), title: Text('attachmentThumbnailAttachmentNew'.tr),
contentPadding: const EdgeInsets.only(left: 12, right: 9), contentPadding: const EdgeInsets.only(left: 12, right: 9),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@ -122,7 +122,7 @@ class _AttachmentEditorThumbnailDialogState
isDense: true, isDense: true,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
prefixText: '#', prefixText: '#',
labelText: 'postThumbnailAttachment'.tr, labelText: 'attachmentThumbnailAttachment'.tr,
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),

View File

@ -21,6 +21,7 @@ class AttachmentItem extends StatefulWidget {
final bool showBadge; final bool showBadge;
final bool showHideButton; final bool showHideButton;
final bool autoload; final bool autoload;
final bool isDense;
final BoxFit fit; final BoxFit fit;
final String? badge; final String? badge;
final Function? onHide; final Function? onHide;
@ -34,6 +35,7 @@ class AttachmentItem extends StatefulWidget {
this.showBadge = true, this.showBadge = true,
this.showHideButton = true, this.showHideButton = true,
this.autoload = false, this.autoload = false,
this.isDense = false,
this.onHide, this.onHide,
}); });
@ -53,6 +55,7 @@ class _AttachmentItemState extends State<AttachmentItem> {
fit: widget.fit, fit: widget.fit,
showBadge: widget.showBadge, showBadge: widget.showBadge,
showHideButton: widget.showHideButton, showHideButton: widget.showHideButton,
isDense: widget.isDense,
onHide: widget.onHide, onHide: widget.onHide,
); );
case 'video': case 'video':
@ -120,6 +123,7 @@ class _AttachmentItemImage extends StatelessWidget {
final bool showBadge; final bool showBadge;
final bool showHideButton; final bool showHideButton;
final BoxFit fit; final BoxFit fit;
final bool isDense;
final String? badge; final String? badge;
final Function? onHide; final Function? onHide;
@ -128,6 +132,7 @@ class _AttachmentItemImage extends StatelessWidget {
required this.item, required this.item,
required this.showBadge, required this.showBadge,
required this.showHideButton, required this.showHideButton,
required this.isDense,
required this.fit, required this.fit,
this.badge, this.badge,
this.onHide, this.onHide,
@ -146,6 +151,7 @@ class _AttachmentItemImage extends StatelessWidget {
'/attachments/${item.rid}', '/attachments/${item.rid}',
), ),
fit: fit, fit: fit,
isDense: isDense,
), ),
if (showBadge && badge != null) if (showBadge && badge != null)
Positioned( Positioned(
@ -233,6 +239,7 @@ class _AttachmentItemVideoState extends State<_AttachmentItemVideo> {
final ratio = widget.item.metadata?['ratio'] ?? 16 / 9; final ratio = widget.item.metadata?['ratio'] ?? 16 / 9;
if (!_showContent) { if (!_showContent) {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque,
child: Stack( child: Stack(
children: [ children: [
if (widget.item.metadata?['thumbnail'] != null) if (widget.item.metadata?['thumbnail'] != null)
@ -282,6 +289,8 @@ class _AttachmentItemVideoState extends State<_AttachmentItemVideo> {
children: [ children: [
Text( Text(
widget.item.alt, widget.item.alt,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
shadows: labelShadows, shadows: labelShadows,
color: Colors.white, color: Colors.white,
@ -398,6 +407,7 @@ class _AttachmentItemAudioState extends State<_AttachmentItemAudio> {
const ratio = 16 / 9; const ratio = 16 / 9;
if (!_showContent) { if (!_showContent) {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque,
child: Stack( child: Stack(
children: [ children: [
if (widget.item.metadata?['thumbnail'] != null) if (widget.item.metadata?['thumbnail'] != null)
@ -447,6 +457,8 @@ class _AttachmentItemAudioState extends State<_AttachmentItemAudio> {
children: [ children: [
Text( Text(
widget.item.alt, widget.item.alt,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
shadows: labelShadows, shadows: labelShadows,
color: Colors.white, color: Colors.white,

View File

@ -338,6 +338,7 @@ class AttachmentListEntry extends StatelessWidget {
badge: showBadge ? badgeContent : null, badge: showBadge ? badgeContent : null,
showHideButton: !item!.isMature || showMature, showHideButton: !item!.isMature || showMature,
autoload: autoload, autoload: autoload,
isDense: isDense,
onHide: () { onHide: () {
onReveal(false); onReveal(false);
}, },

View File

@ -10,6 +10,7 @@ class AutoCacheImage extends StatelessWidget {
final BoxFit? fit; final BoxFit? fit;
final bool noProgressIndicator; final bool noProgressIndicator;
final bool noErrorWidget; final bool noErrorWidget;
final bool isDense;
const AutoCacheImage( const AutoCacheImage(
this.url, { this.url, {
@ -19,6 +20,7 @@ class AutoCacheImage extends StatelessWidget {
this.fit, this.fit,
this.noProgressIndicator = false, this.noProgressIndicator = false,
this.noErrorWidget = false, this.noErrorWidget = false,
this.isDense = false,
}); });
@override @override
@ -32,8 +34,17 @@ class AutoCacheImage extends StatelessWidget {
progressIndicatorBuilder: noProgressIndicator progressIndicatorBuilder: noProgressIndicator
? null ? null
: (context, url, downloadProgress) => Center( : (context, url, downloadProgress) => Center(
child: CircularProgressIndicator( child: TweenAnimationBuilder(
value: downloadProgress.progress, tween: Tween(
begin: 0,
end: downloadProgress.progress ?? 0,
),
duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator(
value: downloadProgress.progress != null
? value.toDouble()
: null,
),
), ),
), ),
errorWidget: noErrorWidget errorWidget: noErrorWidget
@ -46,13 +57,14 @@ class AutoCacheImage extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.close, size: 32) Icon(Icons.close, size: isDense ? 24 : 32)
.animate(onPlay: (e) => e.repeat(reverse: true)) .animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms), .fade(duration: 500.ms),
Text( if (!isDense)
error.toString(), Text(
textAlign: TextAlign.center, error.toString(),
), textAlign: TextAlign.center,
),
], ],
), ),
), ),
@ -71,11 +83,20 @@ class AutoCacheImage extends StatelessWidget {
ImageChunkEvent? loadingProgress) { ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child; if (loadingProgress == null) return child;
return Center( return Center(
child: CircularProgressIndicator( child: TweenAnimationBuilder(
value: loadingProgress.expectedTotalBytes != null tween: Tween(
? loadingProgress.cumulativeBytesLoaded / begin: 0,
loadingProgress.expectedTotalBytes! end: loadingProgress.expectedTotalBytes != null
: null, ? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: 0,
),
duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? value.toDouble()
: null,
),
), ),
); );
}, },
@ -89,13 +110,14 @@ class AutoCacheImage extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.close, size: 32) Icon(Icons.close, size: isDense ? 24 : 32)
.animate(onPlay: (e) => e.repeat(reverse: true)) .animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms), .fade(duration: 500.ms),
Text( if (!isDense)
error.toString(), Text(
textAlign: TextAlign.center, error.toString(),
), textAlign: TextAlign.center,
),
], ],
), ),
), ),

View File

@ -29,10 +29,10 @@ class _ChannelDeletionDialogState extends State<ChannelDeletionDialog> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client final resp =
.delete('/channels/${widget.realm}/${widget.channel.id}'); await client.delete('/channels/${widget.realm}/${widget.channel.id}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString); context.showErrorDialog(resp.bodyString);
} else if (Navigator.canPop(context)) { } else if (Navigator.canPop(context)) {
@ -48,7 +48,7 @@ class _ChannelDeletionDialogState extends State<ChannelDeletionDialog> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.delete( final resp = await client.delete(
'/channels/${widget.realm}/${widget.channel.alias}/members/me', '/channels/${widget.realm}/${widget.channel.alias}/members/me',
@ -69,11 +69,11 @@ class _ChannelDeletionDialogState extends State<ChannelDeletionDialog> {
? 'channelDeletionConfirm'.tr ? 'channelDeletionConfirm'.tr
: 'channelLeaveConfirm'.tr), : 'channelLeaveConfirm'.tr),
content: Text( content: Text(
widget.isOwned ? widget.isOwned
'channelDeletionConfirmCaption' ? 'channelDeletionConfirmCaption'
.trParams({'channel': '#${widget.channel.alias}'}) : .trParams({'channel': '#${widget.channel.alias}'})
'channelLeaveConfirmCaption' : 'channelLeaveConfirmCaption'
.trParams({'channel': '#${widget.channel.alias}'}), .trParams({'channel': '#${widget.channel.alias}'}),
), ),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(

View File

@ -1,5 +1,8 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:gap/gap.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/models/channel.dart'; import 'package:solian/models/channel.dart';
@ -213,6 +216,7 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
return _buildEntry(element); return _buildEntry(element);
}, },
), ),
SliverGap(max(16, MediaQuery.of(context).padding.bottom)),
], ],
); );
} }

View File

@ -39,7 +39,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
void getMembers() async { void getMembers() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = ServiceFinder.configureClient('messaging'); final client = await ServiceFinder.configureClient('messaging');
final resp = await client final resp = await client
.get('/channels/${widget.realm}/${widget.channel.alias}/members'); .get('/channels/${widget.realm}/${widget.channel.alias}/members');
@ -75,7 +75,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.post( final resp = await client.post(
'/channels/${widget.realm}/${widget.channel.alias}/members', '/channels/${widget.realm}/${widget.channel.alias}/members',
@ -96,7 +96,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.request( final resp = await client.request(
'/channels/${widget.realm}/${widget.channel.alias}/members', '/channels/${widget.realm}/${widget.channel.alias}/members',

View File

@ -33,7 +33,7 @@ class _ChatCallButtonState extends State<ChatCallButton> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -57,7 +57,7 @@ class _ChatCallButtonState extends State<ChatCallButton> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
setState(() => _isBusy = true); setState(() => _isBusy = true);

View File

@ -30,7 +30,7 @@ class _ChatEventDeletionDialogState extends State<ChatEventDeletionDialog> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
final client = auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
setState(() => _isBusy = true); setState(() => _isBusy = true);

View File

@ -92,10 +92,13 @@ class ChatEventList extends StatelessWidget {
); );
}, },
onFetchData: () { onFetchData: () {
chatController.loadEvents( if (chatController.currentEvents.length <
chatController.channel!, chatController.totalEvents.value) {
chatController.scope!, chatController.loadEvents(
); chatController.channel!,
chatController.scope!,
);
}
}, },
); );
}), }),

View File

@ -49,6 +49,7 @@ class ChatEventMessage extends StatelessWidget {
return MarkdownTextContent( return MarkdownTextContent(
parentId: 'm${item.id}', parentId: 'm${item.id}',
isSelectable: true, isSelectable: true,
isAutoWarp: true,
content: body.text, content: body.text,
); );
} }

View File

@ -118,7 +118,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
final mentionedUserNames = _findMentionedUsers(_textController.text); final mentionedUserNames = _findMentionedUsers(_textController.text);
final mentionedUserIds = List<int>.empty(growable: true); final mentionedUserIds = List<int>.empty(growable: true);
var client = auth.configureClient('auth'); var client = await auth.configureClient('auth');
if (mentionedUserNames.isNotEmpty) { if (mentionedUserNames.isNotEmpty) {
resp = await client.get('/users?name=${mentionedUserNames.join(',')}'); resp = await client.get('/users?name=${mentionedUserNames.join(',')}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
@ -131,7 +131,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
} }
} }
client = auth.configureClient('messaging'); client = await auth.configureClient('messaging');
if (_textController.text.trim().isEmpty && _attachments.isEmpty) return; if (_textController.text.trim().isEmpty && _attachments.isEmpty) return;
@ -405,12 +405,9 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
if (emojiMatch != null) { if (emojiMatch != null) {
final StickerProvider stickers = Get.find(); final StickerProvider stickers = Get.find();
final emoteSearch = emojiMatch[2]!; final emoteSearch = emojiMatch[2]!;
return stickers.availableStickers final result = await stickers
.where( .searchStickerByAlias(emoteSearch.substring(1));
(x) => x.textWarpedPlaceholder return result
.toUpperCase()
.contains(emoteSearch.toUpperCase()),
)
.map( .map(
(x) => ChatMessageSuggestion( (x) => ChatMessageSuggestion(
type: 'emotes', type: 'emotes',
@ -418,6 +415,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
x.imageUrl, x.imageUrl,
width: 28, width: 28,
height: 28, height: 28,
isDense: true,
), ),
display: x.name, display: x.name,
content: x.textWarpedPlaceholder, content: x.textWarpedPlaceholder,
@ -432,7 +430,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
final userSearch = userMatch[1]!.toLowerCase(); final userSearch = userMatch[1]!.toLowerCase();
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.get( final resp = await client.get(
'/users/search?probe=$userSearch', '/users/search?probe=$userSearch',
); );

View File

@ -12,7 +12,7 @@ class DailySignHistoryChartDialog extends StatelessWidget {
const DailySignHistoryChartDialog({super.key, required this.data}); const DailySignHistoryChartDialog({super.key, required this.data});
static List<String> signSymbols = ['大凶', '', '中平', '', '大吉']; static final List<String> signSymbols = ['大凶', '', '中平', '', '大吉'];
DateTime? get _firstRecordDate => data?.map((x) => x.createdAt).reduce( DateTime? get _firstRecordDate => data?.map((x) => x.createdAt).reduce(
(a, b) => DateTime.fromMillisecondsSinceEpoch( (a, b) => DateTime.fromMillisecondsSinceEpoch(
@ -42,215 +42,222 @@ class DailySignHistoryChartDialog extends StatelessWidget {
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
) )
: Column( : SizedBox(
mainAxisSize: MainAxisSize.min, width: double.maxFinite,
crossAxisAlignment: CrossAxisAlignment.start, child: ListView(
children: [ shrinkWrap: true,
Text( children: [
'dailySignHistoryRecent'.tr, Text(
style: Theme.of(context).textTheme.titleMedium, 'dailySignHistoryRecent'.tr,
).paddingOnly(bottom: 18), style: Theme.of(context).textTheme.titleMedium,
SizedBox( ).paddingOnly(bottom: 18),
height: 180, SizedBox(
width: max(640, MediaQuery.of(context).size.width), height: 180,
child: LineChart( width: max(640, MediaQuery.of(context).size.width),
LineChartData( child: LineChart(
lineBarsData: [ LineChartData(
LineChartBarData( lineBarsData: [
isCurved: true, LineChartBarData(
isStrokeCapRound: true, isCurved: true,
isStrokeJoinRound: true, isStrokeCapRound: true,
color: Theme.of(context).colorScheme.primary, isStrokeJoinRound: true,
belowBarData: BarAreaData( color: Theme.of(context).colorScheme.primary,
show: true, belowBarData: BarAreaData(
gradient: LinearGradient( show: true,
colors: List.filled( gradient: LinearGradient(
data!.length, colors: List.filled(
Theme.of(context) data!.length,
.colorScheme Theme.of(context)
.primary .colorScheme
.withOpacity(0.3), .primary
).toList(), .withOpacity(0.3),
), ).toList(),
),
spots: data!
.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(
'${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(
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, bottom: 8, top: 8),
const Gap(16),
Text(
'dailySignHistoryReward'.tr,
style: Theme.of(context).textTheme.titleMedium,
).paddingOnly(bottom: 18),
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(
data!.length,
Theme.of(context)
.colorScheme
.primary
.withOpacity(0.3),
).toList(),
), ),
), spots: data!
spots: data! .map(
.map( (x) => FlSpot(
(x) => FlSpot( x.createdAt
x.createdAt .copyWith(
.copyWith( hour: 0,
hour: 0, minute: 0,
minute: 0, second: 0,
second: 0, millisecond: 0,
millisecond: 0, microsecond: 0,
microsecond: 0, )
) .millisecondsSinceEpoch
.millisecondsSinceEpoch .toDouble(),
.toDouble(), x.resultTier.toDouble(),
x.resultExperience.toDouble(),
),
)
.toList(),
)
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots
.map((spot) => LineTooltipItem(
'+${spot.y.toStringAsFixed(0)} EXP\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
TextStyle(
color:
Theme.of(context).colorScheme.onSurface,
), ),
)) )
.toList(), .toList(),
getTooltipColor: (_) => )
Theme.of(context).colorScheme.surfaceContainerHigh, ],
)), lineTouchData: LineTouchData(
titlesData: FlTitlesData( touchTooltipData: LineTouchTooltipData(
topTitles: const AxisTitles( getTooltipItems: (spots) => spots
sideTitles: SideTitles(showTitles: false), .map((spot) => LineTooltipItem(
'${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,
),
), ),
rightTitles: const AxisTitles( titlesData: FlTitlesData(
sideTitles: SideTitles(showTitles: false), topTitles: const AxisTitles(
), sideTitles: SideTitles(showTitles: false),
leftTitles: AxisTitles( ),
sideTitles: SideTitles( rightTitles: const AxisTitles(
showTitles: true, sideTitles: SideTitles(showTitles: false),
reservedSize: 40, ),
getTitlesWidget: (value, _) => Align( leftTitles: AxisTitles(
alignment: Alignment.centerRight, sideTitles: SideTitles(
child: Text( showTitles: true,
value.toStringAsFixed(0), reservedSize: 40,
textAlign: TextAlign.right, interval: 1,
).paddingOnly(right: 8), getTitlesWidget: (value, _) => Align(
alignment: Alignment.centerRight,
child: Text(
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),
), ),
), ),
), ),
bottomTitles: AxisTitles( gridData: const FlGridData(show: false),
sideTitles: SideTitles( borderData: FlBorderData(show: false),
showTitles: true, ),
reservedSize: 28, ),
interval: 86400000, ).marginOnly(right: 24, bottom: 8, top: 8),
getTitlesWidget: (value, _) => Text( const Gap(16),
DateFormat('dd').format( Text(
DateTime.fromMillisecondsSinceEpoch( 'dailySignHistoryReward'.tr,
value.toInt(), style: Theme.of(context).textTheme.titleMedium,
), ).paddingOnly(bottom: 18),
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(
data!.length,
Theme.of(context)
.colorScheme
.primary
.withOpacity(0.3),
).toList(),
), ),
textAlign: TextAlign.center, ),
).paddingOnly(top: 8), spots: data!
.map(
(x) => FlSpot(
x.createdAt
.copyWith(
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
microsecond: 0,
)
.millisecondsSinceEpoch
.toDouble(),
x.resultExperience.toDouble(),
),
)
.toList(),
)
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots
.map((spot) => LineTooltipItem(
'+${spot.y.toStringAsFixed(0)} EXP\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,
getTitlesWidget: (value, _) => Align(
alignment: Alignment.centerRight,
child: Text(
value.toStringAsFixed(0),
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),
), ),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
), ),
), ).marginOnly(right: 24, bottom: 8, top: 8),
).marginOnly(right: 24, bottom: 8, top: 8), ],
], ),
), ),
); );
} }

View File

@ -74,9 +74,13 @@ class LinkExpansion extends StatelessWidget {
), ),
).paddingOnly(right: 8), ).paddingOnly(right: 8),
if (snapshot.data!.siteName != null) if (snapshot.data!.siteName != null)
Text( Expanded(
snapshot.data!.siteName!, child: Text(
style: Theme.of(context).textTheme.labelLarge, snapshot.data!.siteName!,
style: Theme.of(context).textTheme.labelLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
], ],
).paddingOnly( ).paddingOnly(

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown_selectionarea/flutter_markdown.dart'; import 'package:flutter_markdown_selectionarea/flutter_markdown.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:markdown/markdown.dart' as markdown; import 'package:markdown/markdown.dart' as markdown;
import 'package:markdown/markdown.dart'; import 'package:markdown/markdown.dart';
@ -14,129 +15,184 @@ class MarkdownTextContent extends StatelessWidget {
final String content; final String content;
final String parentId; final String parentId;
final bool isSelectable; final bool isSelectable;
final bool isLargeText;
final bool isAutoWarp;
const MarkdownTextContent({ const MarkdownTextContent({
super.key, super.key,
required this.content, required this.content,
required this.parentId, required this.parentId,
this.isSelectable = false, this.isSelectable = false,
this.isLargeText = false,
this.isAutoWarp = false,
}); });
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
final emojiRegex = RegExp(r':([-\w]+):'); final stickerRegex = RegExp(r':([-\w]+):');
final emojiMatch = emojiRegex.allMatches(content);
final isOnlyEmoji = content.replaceAll(emojiRegex, '').trim().isEmpty;
return Markdown( // Split the content into paragraphs
shrinkWrap: true, final paragraphs = content.split(RegExp(r'\n\s*\n'));
physics: const NeverScrollableScrollPhysics(),
data: content, // Iterate over each paragraph to process stickers individually
padding: EdgeInsets.zero, List<Widget> contentWidgets = [];
styleSheet: MarkdownStyleSheet.fromTheme( for (var idx = 0; idx < paragraphs.length; idx++) {
Theme.of(context), // Getting paragraph
).copyWith( var paragraph = paragraphs[idx];
horizontalRuleDecoration: BoxDecoration(
border: Border( // Auto adding new-lines
top: BorderSide( if (isAutoWarp) {
width: 1.0, paragraph = paragraph.replaceAll('\n', '\\\n');
color: Theme.of(context).dividerColor, }
// Matching stickers
final stickerMatch = stickerRegex.allMatches(paragraph);
final isOnlySticker =
paragraph.replaceAll(stickerRegex, '').trim().isEmpty;
contentWidgets.add(
Markdown(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: paragraph,
padding: EdgeInsets.zero,
styleSheet: MarkdownStyleSheet.fromTheme(
Theme.of(context),
).copyWith(
textScaleFactor: isLargeText ? 1.1 : 1,
blockquote: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
blockquoteDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(
width: 1.0,
color: Theme.of(context).dividerColor,
),
),
), ),
), ),
), extensionSet: markdown.ExtensionSet(
), markdown.ExtensionSet.gitHubFlavored.blockSyntaxes,
extensionSet: markdown.ExtensionSet( <markdown.InlineSyntax>[
markdown.ExtensionSet.gitHubFlavored.blockSyntaxes, _UserNameCardInlineSyntax(),
<markdown.InlineSyntax>[ _CustomEmoteInlineSyntax(),
_UserNameCardInlineSyntax(), markdown.EmojiSyntax(),
_CustomEmoteInlineSyntax(), markdown.AutolinkSyntax(),
markdown.EmojiSyntax(), markdown.AutolinkExtensionSyntax(),
markdown.AutolinkSyntax(), ...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes
markdown.AutolinkExtensionSyntax(), ],
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes ),
], onTapLink: (text, href, title) async {
), if (href == null) return;
onTapLink: (text, href, title) async { if (href.startsWith('solink://')) {
if (href == null) return; final segments = href.replaceFirst('solink://', '').split('/');
if (href.startsWith('solink://')) { switch (segments[0]) {
final segments = href.replaceFirst('solink://', '').split('/'); case 'users':
switch (segments[0]) { showModalBottomSheet(
case 'users': useRootNavigator: true,
showModalBottomSheet( isScrollControlled: true,
useRootNavigator: true, backgroundColor: Theme.of(context).colorScheme.surface,
isScrollControlled: true, context: context,
backgroundColor: Theme.of(context).colorScheme.surface, builder: (context) => AccountProfilePopup(
context: context, name: segments[1],
builder: (context) => AccountProfilePopup( ),
name: segments[1], );
),
);
}
return;
}
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
imageBuilder: (uri, title, alt) {
var url = uri.toString();
double? width, height;
BoxFit? fit;
if (url.startsWith('solink://')) {
final segments = url.replaceFirst('solink://', '').split('/');
switch (segments[0]) {
case 'stickers':
double radius = 8;
final StickerProvider sticker = Get.find();
url = sticker.aliasImageMapping[segments[1].toUpperCase()]!;
if (emojiMatch.length <= 1 && isOnlyEmoji) {
width = 128;
height = 128;
} else if (emojiMatch.length <= 3 && isOnlyEmoji) {
width = 32;
height = 32;
} else {
radius = 4;
width = 16;
height = 16;
} }
fit = BoxFit.contain; return;
return ClipRRect( }
borderRadius: BorderRadius.all(Radius.circular(radius)),
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: AutoCacheImage(
url,
width: width,
height: height,
fit: fit,
),
),
).paddingSymmetric(vertical: 4);
case 'attachments':
const radius = BorderRadius.all(Radius.circular(8));
return LimitedBox(
maxHeight: MediaQuery.of(context).size.width,
child: ClipRRect(
borderRadius: radius,
child: AttachmentSelfContainedEntry(
isDense: true,
parentId: parentId,
rid: segments[1],
),
),
).paddingSymmetric(vertical: 4);
}
}
return AutoCacheImage( await launchUrlString(
url, href,
width: width, mode: LaunchMode.externalApplication,
height: height, );
fit: fit, },
); imageBuilder: (uri, title, alt) {
}, var url = uri.toString();
double? width, height;
BoxFit? fit;
if (url.startsWith('solink://')) {
final segments = url.replaceFirst('solink://', '').split('/');
switch (segments[0]) {
case 'stickers':
double radius = 8;
final StickerProvider sticker = Get.find();
// Adjust sticker size based on the sticker count in this paragraph
if (stickerMatch.length <= 1 && isOnlySticker) {
width = 128;
height = 128;
} else if (stickerMatch.length <= 3 && isOnlySticker) {
width = 32;
height = 32;
} else {
radius = 4;
width = 16;
height = 16;
}
fit = BoxFit.contain;
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(radius)),
child: Container(
width: width,
height: height,
color: Theme.of(context).colorScheme.surfaceContainer,
child: FutureBuilder(
future: sticker.getStickerByAlias(segments[1]),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator());
}
return AutoCacheImage(
snapshot.data!.imageUrl,
width: width,
height: height,
fit: fit,
noErrorWidget: true,
);
},
),
),
).paddingSymmetric(vertical: 4);
case 'attachments':
const radius = BorderRadius.all(Radius.circular(8));
return LimitedBox(
maxHeight: MediaQuery.of(context).size.width,
child: ClipRRect(
borderRadius: radius,
child: AttachmentSelfContainedEntry(
isDense: true,
parentId: parentId,
rid: segments[1],
),
),
).paddingSymmetric(vertical: 4);
}
}
return AutoCacheImage(
url,
width: width,
height: height,
fit: fit,
);
},
),
);
if (idx < paragraphs.length - 1) {
contentWidgets.add(const Gap(4));
}
}
// Return the list of widgets for the paragraphs
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: contentWidgets,
); );
} }
@ -172,7 +228,8 @@ class _CustomEmoteInlineSyntax extends InlineSyntax {
bool onMatch(markdown.InlineParser parser, Match match) { bool onMatch(markdown.InlineParser parser, Match match) {
final StickerProvider sticker = Get.find(); final StickerProvider sticker = Get.find();
final alias = match[1]!.toUpperCase(); final alias = match[1]!.toUpperCase();
if (sticker.aliasImageMapping[alias] == null) { if (sticker.stickerCache.containsKey(alias) &&
sticker.stickerCache[alias] == null) {
parser.advanceBy(1); parser.advanceBy(1);
return false; return false;
} }

View File

@ -9,9 +9,9 @@ abstract class AppNavigation {
page: 'dashboard', page: 'dashboard',
), ),
AppNavigationDestination( AppNavigationDestination(
icon: Icons.newspaper, icon: Icons.explore,
label: 'feed'.tr, label: 'explore'.tr,
page: 'feed', page: 'explore',
), ),
AppNavigationDestination( AppNavigationDestination(
icon: Icons.workspaces, icon: Icons.workspaces,

View File

@ -138,7 +138,14 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer>
Builder( Builder(
builder: (context) { builder: (context) {
if (_accountStatus == null) { if (_accountStatus == null) {
return Text('loading'.tr).paddingOnly(left: 16); return Text(
'loading'.tr,
maxLines: 1,
overflow: TextOverflow.fade,
style: TextStyle(
color: _unFocusColor,
),
).paddingOnly(left: 16);
} }
final info = StatusProvider.determineStatus( final info = StatusProvider.determineStatus(
_accountStatus!, _accountStatus!,
@ -274,6 +281,9 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer>
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: AppNavigationRegion( child: AppNavigationRegion(
isCollapsed: _isCollapsed, isCollapsed: _isCollapsed,
onSelected: () {
_closeDrawer();
},
), ),
), ),
), ),

View File

@ -1,3 +1,4 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
@ -5,15 +6,19 @@ 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/navigation.dart'; import 'package:solian/providers/navigation.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/auto_cache_image.dart';
import 'package:solian/widgets/channel/channel_list.dart'; import 'package:solian/widgets/channel/channel_list.dart';
class AppNavigationRegion extends StatefulWidget { class AppNavigationRegion extends StatefulWidget {
final bool isCollapsed; final bool isCollapsed;
final Function onSelected;
const AppNavigationRegion({ const AppNavigationRegion({
super.key, super.key,
this.isCollapsed = false, this.isCollapsed = false,
required this.onSelected,
}); });
@override @override
@ -124,16 +129,12 @@ class _AppNavigationRegionState extends State<AppNavigationRegion> {
final NavigationStateProvider navState = Get.find(); final NavigationStateProvider navState = Get.find();
return Obx( return Obx(
() => AnimatedSwitcher( () => PageTransitionSwitcher(
switchInCurve: Curves.fastOutSlowIn, transitionBuilder: (child, animation, secondaryAnimation) {
switchOutCurve: Curves.fastOutSlowIn, return SharedAxisTransition(
duration: const Duration(milliseconds: 300), animation: animation,
transitionBuilder: (child, animation) { secondaryAnimation: secondaryAnimation,
return SlideTransition( transitionType: SharedAxisTransitionType.horizontal,
position: Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
).animate(animation),
child: Material( child: Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: child, child: child,
@ -170,6 +171,19 @@ class _AppNavigationRegionState extends State<AppNavigationRegion> {
) )
: Column( : Column(
children: [ children: [
if (!widget.isCollapsed &&
(navState.focusedRealm.value!.banner?.isNotEmpty ??
false))
AspectRatio(
aspectRatio: 16 / 7,
child: AutoCacheImage(
ServiceFinder.buildUrl(
'uc',
'/attachments/${navState.focusedRealm.value!.banner}',
),
fit: BoxFit.cover,
),
),
if (widget.isCollapsed) if (widget.isCollapsed)
Tooltip( Tooltip(
message: navState.focusedRealm.value!.name, message: navState.focusedRealm.value!.name,
@ -204,6 +218,7 @@ class _AppNavigationRegionState extends State<AppNavigationRegion> {
isCollapsed: widget.isCollapsed, isCollapsed: widget.isCollapsed,
selfId: auth.userProfile.value!['id'], selfId: auth.userProfile.value!['id'],
noCategory: true, noCategory: true,
onSelected: (_) => widget.onSelected(),
), ),
), ),
), ),

View File

@ -1,5 +1,4 @@
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/controllers/post_editor_controller.dart'; import 'package:solian/controllers/post_editor_controller.dart';
import 'package:solian/widgets/attachments/attachment_editor.dart'; import 'package:solian/widgets/attachments/attachment_editor.dart';
@ -58,18 +57,25 @@ class _PostEditorThumbnailDialogState extends State<PostEditorThumbnailDialog> {
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( Card(
title: Text('postThumbnailAttachmentNew'.tr), margin: EdgeInsets.zero,
contentPadding: const EdgeInsets.only(left: 16, right: 13), child: ListTile(
trailing: const Icon(Icons.chevron_right), title: Text('postThumbnailAttachmentNew'.tr),
shape: const RoundedRectangleBorder( contentPadding: const EdgeInsets.only(left: 12, right: 9),
borderRadius: BorderRadius.all(Radius.circular(8)), trailing: const Icon(Icons.chevron_right),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
onTap: () {
_promptUploadNewAttachment();
},
), ),
onTap: () {
_promptUploadNewAttachment();
},
), ),
const Gap(8), const Row(children: <Widget>[
Expanded(child: Divider()),
Text('OR'),
Expanded(child: Divider()),
]).paddingOnly(top: 12, bottom: 16, left: 16, right: 16),
TextField( TextField(
controller: _attachmentController, controller: _attachmentController,
decoration: InputDecoration( decoration: InputDecoration(

View File

@ -35,7 +35,7 @@ class _TagsFieldState extends State<TagsField> {
Future<List<String>?> _searchTags(String probe) async { Future<List<String>?> _searchTags(String probe) async {
_currentSearchProbe = probe; _currentSearchProbe = probe;
final client = ServiceFinder.configureClient('interactive'); final client = await ServiceFinder.configureClient('interactive');
final resp = await client.get( final resp = await client.get(
'/tags?take=10&probe=$_currentSearchProbe', '/tags?take=10&probe=$_currentSearchProbe',
); );

View File

@ -12,6 +12,7 @@ import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/posts/post_editor.dart'; import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/widgets/reports/abuse_report.dart';
class PostAction extends StatefulWidget { class PostAction extends StatefulWidget {
final Post item; final Post item;
@ -149,6 +150,23 @@ class _PostActionState extends State<PostAction> {
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.flag),
title: Text('report'.tr),
onTap: () {
showDialog(
context: context,
builder: (context) => AbuseReportDialog(
resourceId: 'post:${widget.item.id}',
),
).then((status) {
if (status == true) {
Navigator.pop(context);
}
});
},
),
if (!widget.noReact) if (!widget.noReact)
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
@ -192,7 +210,7 @@ class _PostActionState extends State<PostAction> {
: 'unpinPost'.tr, : 'unpinPost'.tr,
), ),
onTap: () async { onTap: () async {
final client = Get.find<AuthProvider>() final client = await Get.find<AuthProvider>()
.configureClient('interactive'); .configureClient('interactive');
await client.post('/posts/${widget.item.id}/pin', {}); await client.post('/posts/${widget.item.id}/pin', {});
Navigator.pop(context, true); Navigator.pop(context, true);
@ -254,7 +272,7 @@ class _PostDeletionDialogState extends State<PostDeletionDialog> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
final client = auth.configureClient('interactive'); final client = await auth.configureClient('interactive');
setState(() => _isBusy = true); setState(() => _isBusy = true);
final resp = await client.delete('/posts/${widget.item.id}'); final resp = await client.delete('/posts/${widget.item.id}');

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