Compare commits
147 Commits
2.3.2+75
...
a1c4e5eca0
| Author | SHA1 | Date | |
|---|---|---|---|
| a1c4e5eca0 | |||
| 595050f89f | |||
| 0722c99f21 | |||
| 12d03836f9 | |||
|
|
f78d3f4fd5 | ||
|
|
e798a8ba76 | ||
| c28a664373 | |||
| 4589722c3b | |||
| 38e1c51b45 | |||
| 610ddec05c | |||
| d0276f9ac6 | |||
| c1e89a2ee6 | |||
| ecc79368a1 | |||
| e6d732c86a | |||
| dd055fb077 | |||
| 280840c6d8 | |||
| bde62a7b2c | |||
| 5445c570a2 | |||
| b2302f5b3c | |||
| d7359cfd0d | |||
| 9cc577adbe | |||
| dd196b7754 | |||
| 16c07c2133 | |||
| 6bcb658d44 | |||
| 9311bfc3b5 | |||
| 8dd6435a30 | |||
| 21a1d4a2ad | |||
| 603875b1af | |||
| 4209a13c84 | |||
| 55b79bfd8f | |||
| 6e6c3f42f6 | |||
| dc38b46b2c | |||
| b4990308e9 | |||
| 237abe564d | |||
| 71b41d470a | |||
| 7052b5b635 | |||
| f356e08f79 | |||
| 152872db65 | |||
| dfe117d04f | |||
| caf63f0cbe | |||
| b8f5cc82f9 | |||
| 360bc50f21 | |||
| 2de93a0486 | |||
| 02227852f8 | |||
| ad16de595b | |||
| 9f8c8923d9 | |||
| 060bfa4887 | |||
| e68ada2d04 | |||
| d6013078bd | |||
| 5976d61997 | |||
| b492db90ca | |||
| c9f69fed2c | |||
| d2f4e7a969 | |||
| aecd04e0b9 | |||
| e5212419ae | |||
| ec7650a920 | |||
| 7b96013406 | |||
| fc5a79b29b | |||
| 4146820be5 | |||
| 9ec0f1ff19 | |||
| ac2aec48aa | |||
| 58421e5d5e | |||
| 172d0d24fb | |||
| 71899dd4f2 | |||
| 02ffe9866d | |||
| 1b7e668b3f | |||
| f03d80ba88 | |||
| 14ee6845ed | |||
| 8fe6c2be46 | |||
| 78e765f69d | |||
| ddd6ff7eee | |||
| b8f379796f | |||
| 3a10e9280c | |||
| 65fe06de22 | |||
| e44320e0fe | |||
| f2d913ffec | |||
| e88dea8858 | |||
| 813679b161 | |||
| 9d4ce6ca8c | |||
| 88396647f3 | |||
| 335318ae3f | |||
| da25fb9c29 | |||
| c1aef89b84 | |||
| 0241c5f804 | |||
| f6939d7c23 | |||
| d654c162e3 | |||
| 25550ba197 | |||
| 3defd3a593 | |||
| d62ed4c375 | |||
| 857f3cc832 | |||
| e16bc80eea | |||
| a4f6e8af56 | |||
| 060a97f5ec | |||
| 92f7e92018 | |||
| 5c483bd3b8 | |||
| 1c510d63fe | |||
| 115cb4adc1 | |||
| 54c098c274 | |||
| 29731728cd | |||
| 9e8882c580 | |||
| 6042e57e7a | |||
| 6235e736b9 | |||
| e075804782 | |||
| d40a6ca1c4 | |||
| 5ac657e526 | |||
| 97ddc18b8e | |||
| b835c8edea | |||
| 288c0399f9 | |||
| 1478933cf1 | |||
| 93c6fa6e53 | |||
| ce6e9c185a | |||
| cdaa8cfe58 | |||
| 76d8cd943d | |||
| d6f3ffc655 | |||
| 5a6b841253 | |||
| cb2de52bee | |||
| 64e2644745 | |||
| 56711889ab | |||
| 4f47cd2c0c | |||
| 2b61c372f5 | |||
| 73777fe74e | |||
| 33a4bd7e71 | |||
| 17e6b81f76 | |||
| 22fde6b400 | |||
| 6e03a00280 | |||
| 72e6a6a1f6 | |||
| 66aef44281 | |||
| 7bb73c80b0 | |||
| d043ef2410 | |||
| 1d0e2f7591 | |||
| e9ef28d764 | |||
| 289aa17a7a | |||
| 93f41bb523 | |||
| 09ec9d4a0c | |||
| 1153fbdeee | |||
| e933058338 | |||
| ae9743c84f | |||
| 32bf834108 | |||
| 1b41c847a6 | |||
| b1af6c2c97 | |||
| 8e76ff3f84 | |||
| bd26602299 | |||
| 52ab1d0d10 | |||
| f746e06f65 | |||
| d11069a2be | |||
| d6dc487d9e | |||
| a07c7cdede |
10
.github/workflows/nightly.yml
vendored
10
.github/workflows/nightly.yml
vendored
@@ -52,10 +52,12 @@ jobs:
|
|||||||
- run: |
|
- run: |
|
||||||
sudo apt-get update -y
|
sudo apt-get update -y
|
||||||
sudo apt-get install -y ninja-build libgtk-3-dev
|
sudo apt-get install -y ninja-build libgtk-3-dev
|
||||||
sudo apt-get install libmpv-dev mpv
|
sudo apt-get install -y libmpv-dev mpv
|
||||||
sudo apt-get install libayatana-appindicator3-dev
|
sudo apt-get install -y libayatana-appindicator3-dev
|
||||||
sudo apt-get install keybinder-3.0
|
sudo apt-get install -y keybinder-3.0
|
||||||
sudo apt-get install libnotify-dev
|
sudo apt-get install -y libnotify-dev
|
||||||
|
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||||
|
sudo apt-get install -y gstreamer-1.0
|
||||||
- run: flutter pub get
|
- run: flutter pub get
|
||||||
- run: flutter build linux
|
- run: flutter build linux
|
||||||
- name: Archive production artifacts
|
- name: Archive production artifacts
|
||||||
|
|||||||
11
api/Interactive/Trigger Fediverse Scan.bru
Normal file
11
api/Interactive/Trigger Fediverse Scan.bru
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Trigger Fediverse Scan
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{endpoint}}/cgi/co/admin/fediverse
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
11
api/Nexus/Check Status.bru
Normal file
11
api/Nexus/Check Status.bru
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Check Status
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{endpoint}}/directory/status
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
11
api/Nexus/List Services.bru
Normal file
11
api/Nexus/List Services.bru
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: List Services
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{endpoint}}/directory/services
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
18
api/Passport/Deal Abuse Report.bru
Normal file
18
api/Passport/Deal Abuse Report.bru
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
meta {
|
||||||
|
name: Deal Abuse Report
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: {{endpoint}}/cgi/id/reports/abuse/6/status
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"status": "rejected",
|
||||||
|
"message": "Not a good reason"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,12 +15,10 @@ body:json {
|
|||||||
"client_id": "{{third_client_id}}",
|
"client_id": "{{third_client_id}}",
|
||||||
"client_secret":"{{third_client_tk}}",
|
"client_secret":"{{third_client_tk}}",
|
||||||
"type": "general",
|
"type": "general",
|
||||||
"subject": "新年快乐!",
|
"subject": "关于迁移服务器完成的提示",
|
||||||
"subtitle": "一条来自 Solar Network 团队的信息",
|
"subtitle": "一条来自 Solar Network 团队的运营信息",
|
||||||
"content": "今天是农历正月初一,小羊祝您新年快乐 🎉",
|
"content": "我们已经将所有用户数据迁移到新版服务器,刚刚发布新的 DNS,因为部分 DNS 缓存的影响。可能更改不会生效,可以使用 nslookup / ping 检查解析地址是否未 8. 开头,您可以主动刷新 DNS。谢谢!",
|
||||||
"metadata": {
|
"metadata": {},
|
||||||
"image": "D2EDbcrsTugs3xk5"
|
|
||||||
},
|
|
||||||
"priority": 10
|
"priority": 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ meta {
|
|||||||
get {
|
get {
|
||||||
url: {{endpoint}}/cgi/re/well-known/sources
|
url: {{endpoint}}/cgi/re/well-known/sources
|
||||||
body: none
|
body: none
|
||||||
auth: none
|
auth: inherit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ post {
|
|||||||
|
|
||||||
body:json {
|
body:json {
|
||||||
{
|
{
|
||||||
"sources": ["taiwan-ltn"],
|
"sources": ["taiwan-pts"],
|
||||||
"eager": true
|
"eager": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
assets/audio/notify/metal-pipe.mp3
Normal file
BIN
assets/audio/notify/metal-pipe.mp3
Normal file
Binary file not shown.
BIN
assets/audio/sfx/launch-intro.mp3
Normal file
BIN
assets/audio/sfx/launch-intro.mp3
Normal file
Binary file not shown.
BIN
assets/fonts/Nunito-Bold.ttf
Executable file
BIN
assets/fonts/Nunito-Bold.ttf
Executable file
Binary file not shown.
BIN
assets/fonts/Nunito-Italic.ttf
Executable file
BIN
assets/fonts/Nunito-Italic.ttf
Executable file
Binary file not shown.
BIN
assets/fonts/Nunito-Regular.ttf
Executable file
BIN
assets/fonts/Nunito-Regular.ttf
Executable file
Binary file not shown.
BIN
assets/icon/kanban-1st.jpg
Executable file
BIN
assets/icon/kanban-1st.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 509 KiB |
@@ -130,7 +130,7 @@
|
|||||||
"accountPublishersSubtitle": "Manage your publish identities.",
|
"accountPublishersSubtitle": "Manage your publish identities.",
|
||||||
"accountSettings": "Account Settings",
|
"accountSettings": "Account Settings",
|
||||||
"accountSettingsSubtitle": "Manage your account and make it yours.",
|
"accountSettingsSubtitle": "Manage your account and make it yours.",
|
||||||
"accountProfileEdit": "Edit your profile",
|
"accountProfileEdit": "Edit Profile",
|
||||||
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
|
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
|
||||||
"accountWallet": "Wallet",
|
"accountWallet": "Wallet",
|
||||||
"accountWalletSubtitle": "View your balance and transactions.",
|
"accountWalletSubtitle": "View your balance and transactions.",
|
||||||
@@ -153,6 +153,11 @@
|
|||||||
"publisherRunBy": "Run by {}",
|
"publisherRunBy": "Run by {}",
|
||||||
"fieldPublisherBelongToRealm": "Belongs to",
|
"fieldPublisherBelongToRealm": "Belongs to",
|
||||||
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
|
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
|
||||||
|
"writePost": "Compose",
|
||||||
|
"postTypeStory": "Story",
|
||||||
|
"postTypeArticle": "Article",
|
||||||
|
"postTypeQuestion": "Question",
|
||||||
|
"postTypeVideo": "Video",
|
||||||
"writePostTypeStory": "Post a story",
|
"writePostTypeStory": "Post a story",
|
||||||
"writePostTypeArticle": "Write an article",
|
"writePostTypeArticle": "Write an article",
|
||||||
"writePostTypeQuestion": "Ask a question",
|
"writePostTypeQuestion": "Ask a question",
|
||||||
@@ -202,7 +207,13 @@
|
|||||||
"one": "{} comment",
|
"one": "{} comment",
|
||||||
"other": "{} comments"
|
"other": "{} comments"
|
||||||
},
|
},
|
||||||
|
"postCommentExpand": "Show comments",
|
||||||
"settingsAppearance": "Appearance",
|
"settingsAppearance": "Appearance",
|
||||||
|
"settingsCustomFonts": "Custom Fonts",
|
||||||
|
"settingsCustomFontsDescription": "Set custom fonts for the application.",
|
||||||
|
"settingsCustomFontFamily": "Custom Font Family",
|
||||||
|
"settingsCustomFontFamilyHint": "Use comma to separate fonts, higher priority comes first",
|
||||||
|
"settingsCustomFontApplied": "Custom font has been applied.",
|
||||||
"settingsDisplayLanguage": "Display Language",
|
"settingsDisplayLanguage": "Display Language",
|
||||||
"settingsDisplayLanguageDescription": "Set the application language.",
|
"settingsDisplayLanguageDescription": "Set the application language.",
|
||||||
"settingsDisplayLanguageSystem": "Follow System",
|
"settingsDisplayLanguageSystem": "Follow System",
|
||||||
@@ -327,6 +338,7 @@
|
|||||||
"fieldAttachmentRandomId": "Random ID",
|
"fieldAttachmentRandomId": "Random ID",
|
||||||
"fieldAttachmentAlt": "Alternative text",
|
"fieldAttachmentAlt": "Alternative text",
|
||||||
"addAttachmentFromAlbum": "Add from album",
|
"addAttachmentFromAlbum": "Add from album",
|
||||||
|
"addAttachmentFromFiles": "Add from files",
|
||||||
"addAttachmentFromClipboard": "Paste file",
|
"addAttachmentFromClipboard": "Paste file",
|
||||||
"addAttachmentFromCameraPhoto": "Take photo",
|
"addAttachmentFromCameraPhoto": "Take photo",
|
||||||
"addAttachmentFromCameraVideo": "Take video",
|
"addAttachmentFromCameraVideo": "Take video",
|
||||||
@@ -512,8 +524,13 @@
|
|||||||
"accountBirthday": "Born on {}",
|
"accountBirthday": "Born on {}",
|
||||||
"accountBadge": "Badge",
|
"accountBadge": "Badge",
|
||||||
"accountCheckInNoRecords": "No check-in records",
|
"accountCheckInNoRecords": "No check-in records",
|
||||||
"badgeCompanyStaff": "Solsynth Staff",
|
"badgeCompanyStaff": "Staff",
|
||||||
"badgeSiteMigration": "Solar Network Native",
|
"badgeSiteMigration": "Solar Network Native",
|
||||||
|
"badgeCommunitySurvey": "Survey Participant",
|
||||||
|
"badgeCommunityVerified": "Verified User",
|
||||||
|
"badgeCommunityContributor": "Great Contributor",
|
||||||
|
"badgeSiteAnniversary": "Anniversary",
|
||||||
|
"badgeUserBirthday": "Birthday",
|
||||||
"accountStatus": "Status",
|
"accountStatus": "Status",
|
||||||
"accountStatusOnline": "Online",
|
"accountStatusOnline": "Online",
|
||||||
"accountStatusOffline": "Offline",
|
"accountStatusOffline": "Offline",
|
||||||
@@ -622,6 +639,7 @@
|
|||||||
"postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}",
|
"postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}",
|
||||||
"postQuestionAnswered": "Answered Question",
|
"postQuestionAnswered": "Answered Question",
|
||||||
"postQuestionAnswerSelect": "Select as Answer",
|
"postQuestionAnswerSelect": "Select as Answer",
|
||||||
|
"postQuestionAnswerTitle": "Selected Question",
|
||||||
"postQuestionAnswerSelected": "Answer has been selected, reward has been applied.",
|
"postQuestionAnswerSelected": "Answer has been selected, reward has been applied.",
|
||||||
"postVideoUpload": "Upload Video",
|
"postVideoUpload": "Upload Video",
|
||||||
"realmJoin": "Join Realm",
|
"realmJoin": "Join Realm",
|
||||||
@@ -722,5 +740,204 @@
|
|||||||
"trayMenuMuteNotification": "Do Not Disturb",
|
"trayMenuMuteNotification": "Do Not Disturb",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"forceUpdate": "Force Update",
|
"forceUpdate": "Force Update",
|
||||||
"forceUpdateDescription": "Force to show the application update popup, even the new version is not available."
|
"forceUpdateDescription": "Force to show the application update popup, even the new version is not available.",
|
||||||
|
"debugLogging": "Runtime Logs",
|
||||||
|
"runtimeLogsOpen": "Open Logs",
|
||||||
|
"runtimeLogsDescription": "Show the runtime logs to help debugging.",
|
||||||
|
"signinResetPasswordHint": "Please enter the username / email address to help us to find your account and reset your password.",
|
||||||
|
"cacheSize": "Cache Size",
|
||||||
|
"cacheDelete": "Clean Cache",
|
||||||
|
"cacheDeleteDescription": "Remove the cached images and other resources from your disk, the content will be downloaded from server again.",
|
||||||
|
"cacheDeleted": "All cache has been cleaned up.",
|
||||||
|
"userNoDescription": "No description.",
|
||||||
|
"fieldTimeZone": "Time Zone",
|
||||||
|
"fieldGender": "Gender",
|
||||||
|
"fieldPronouns": "Pronouns",
|
||||||
|
"fieldLocation": "Location",
|
||||||
|
"fieldLinks": "Links",
|
||||||
|
"fieldLinkName": "Name",
|
||||||
|
"fieldLinkUrl": "URL",
|
||||||
|
"screenAccountBadges": "Badges",
|
||||||
|
"accountBadges": "Badges",
|
||||||
|
"accountBadgesDescription": "View and manage your badges.",
|
||||||
|
"badgeActivated": "Activated badge {}.",
|
||||||
|
"viewDetailedAttachment": "Details",
|
||||||
|
"screenKeyPairs": "Key Pairs",
|
||||||
|
"accountKeyPairs": "Key Pairs",
|
||||||
|
"accountKeyPairsDescription": "Manage the key pairs which used to encrypt messages.",
|
||||||
|
"enrollNewKeyPair": "Enroll New One",
|
||||||
|
"enrollNewKeyPairDescription": "Generate a new key pair.",
|
||||||
|
"keyPairHasPrivateKey": "With private key",
|
||||||
|
"decrypting": "Decrypting……",
|
||||||
|
"decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online",
|
||||||
|
"messageUnablePreview": "Unable preview",
|
||||||
|
"messageUnablePreviewEncrypted": "Unable preview encrypted message",
|
||||||
|
"postViewInGlobalDescription": "Do not view the post in the specific realm.",
|
||||||
|
"postDraftSaved": "The draft has been saved.",
|
||||||
|
"postDraftBox": "Draft Box",
|
||||||
|
"postShuffle": "Read Randomly",
|
||||||
|
"checkInStreak": {
|
||||||
|
"zero": "No streak",
|
||||||
|
"one": "{} day streak",
|
||||||
|
"other": "{} days streak"
|
||||||
|
},
|
||||||
|
"accountChangeStatus": "Change Status",
|
||||||
|
"accountStatusSilent": "Do not Disturb",
|
||||||
|
"accountStatusSilentDesc": "The notification will stop popping up",
|
||||||
|
"accountStatusInvisible": "Invisible",
|
||||||
|
"accountStatusInvisibleDesc": "Will show as offline, but all features still remain normal",
|
||||||
|
"accountCustomStatus": "Custom Status",
|
||||||
|
"accountCustomStatusDescription": "Customize your status.",
|
||||||
|
"accountClearStatus": "Clear Status",
|
||||||
|
"accountClearStatusDescription": "Clear your status, and let server decide which status you are for you.",
|
||||||
|
"fieldAccountStatusLabel": "Status Text",
|
||||||
|
"fieldAccountStatusClearAt": "Clear At",
|
||||||
|
"accountStatusNegative": "Negative",
|
||||||
|
"accountStatusNeutral": "Neutral",
|
||||||
|
"accountStatusPositive": "Positive",
|
||||||
|
"mixedFeed": "Mixed Feed",
|
||||||
|
"mixedFeedDescription": "The Explore screen may not only display the user's posts, but may also contain other content. However, this mode does not apply to classification and filtering.",
|
||||||
|
"filterFeed": "Exploring Adjust",
|
||||||
|
"feedUnknownItem": "Unable to display this content, the current version of the client does not support the type of content, please try to update the application afterwards.",
|
||||||
|
"serviceStatusOperational": "All services operational",
|
||||||
|
"serviceStatusDowngraded": "Some services downgraded",
|
||||||
|
"serviceStatusFailed": "All services unavailable",
|
||||||
|
"serviceStatusFailedDescription": "The server is down or the maintenance is just finished.",
|
||||||
|
"serviceNameInsights": "Summarize and Insights",
|
||||||
|
"serviceNameInteractive": "Posts, Reactions and Explore",
|
||||||
|
"serviceNameReader": "News and Link Previews",
|
||||||
|
"serviceNameMessaging": "Chat",
|
||||||
|
"serviceNameMatrix": "Matrix Software and Game Marketplace",
|
||||||
|
"serviceNamePaperclip": "Attachments, Images and Files",
|
||||||
|
"serviceNameWallet": "Source Points Wallet",
|
||||||
|
"serviceNamePassport": "Authorization and Authentication",
|
||||||
|
"accountActionEvent": "Action Events",
|
||||||
|
"accountActionEventDescription": "View your action event logs.",
|
||||||
|
"eventMetadata": "Metadata",
|
||||||
|
"accountAuthTickets": "Auth Sessions",
|
||||||
|
"accountAuthTicketsDescription": "View and manage your auth sessions.",
|
||||||
|
"authTicketCreatedAt": "Issued at {}",
|
||||||
|
"authTicketExpiredAt": "Expired at {}",
|
||||||
|
"authTicketLastGrantAt": "Last granted at {}",
|
||||||
|
"authTicketCurrent": "Current",
|
||||||
|
"accountUnconfirmedTitle": "Unconfirmed Account",
|
||||||
|
"accountUnconfirmedSubtitle": "Your account is unconfirmed, which will make most features unavailable and your account will be destroyed in 24 hours. You should receive an email in your inbox with a confirmation link.",
|
||||||
|
"accountUnconfirmedUnreceived": "Didn't receive the email?",
|
||||||
|
"accountUnconfirmedResend": "Resend one",
|
||||||
|
"accountUnconfirmedResendSuccessful": "Email has been resent, you can resend it again in 60 minutes.",
|
||||||
|
"stickerPickerEmpty": "Sticker list is empty",
|
||||||
|
"stickerPickerEmptyHint": "To start using stickers, you need to add a sticker pack first.",
|
||||||
|
"goto": "Go to {}",
|
||||||
|
"accountContactMethods": "Contact Methods",
|
||||||
|
"accountContactMethodsDescription": "Manage your contact methods.",
|
||||||
|
"accountContactMethodsNameEmail": "Email address",
|
||||||
|
"accountContactMethodsNamePhone": "Phone number",
|
||||||
|
"accountContactMethodsNameAddress": "Address",
|
||||||
|
"accountContactMethodsPrimary": "Primary",
|
||||||
|
"accountContactMethodsVerified": "Verified",
|
||||||
|
"accountContactMethodsPublic": "Public",
|
||||||
|
"accountContactMethodsAdd": "Add Contact Method",
|
||||||
|
"accountContactMethodsEdit": "Edit Contact Method",
|
||||||
|
"accountContactMethodsAddDescription": "Add a new contact method.",
|
||||||
|
"fieldContactContent": "Contact method",
|
||||||
|
"accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.",
|
||||||
|
"accountContactMethodsDelete": "Delete Contact Method",
|
||||||
|
"accountContactMethodsDeleteDescription": "Are you sure you want to delete contact method {}? This operation is irreversible.",
|
||||||
|
"postCommentAdd": "Write a comment",
|
||||||
|
"translate": "Translate",
|
||||||
|
"translating": "Translating…",
|
||||||
|
"translated": "Translated",
|
||||||
|
"settingsAutoTranslate": "Auto Translate",
|
||||||
|
"settingsAutoTranslateDescription": "Automatically translate text when viewing posts and messages.",
|
||||||
|
"trayMenuHide": "Hide",
|
||||||
|
"accountSettingsNotify": "Notify Settings",
|
||||||
|
"accountSettingsNotifyDescription": "Adjust the types of notifications you receive.",
|
||||||
|
"accountSettingsSecurity": "Security Settings",
|
||||||
|
"accountSettingsSecurityDescription": "Adjust your account security settings.",
|
||||||
|
"save": "Save",
|
||||||
|
"notificationTopicPostFeedback": "Post Feedback",
|
||||||
|
"notificationTopicPostReply": "Post Replies",
|
||||||
|
"notificationTopicPostSubscription": "Post Subscriptions",
|
||||||
|
"notificationTopicMessaging": "New Messages",
|
||||||
|
"notificationTopicMessagingCall": "Incoming Calls",
|
||||||
|
"notificationTopicGeneral": "General",
|
||||||
|
"authMaximumAuthSteps": "Maximum Authenticate Steps",
|
||||||
|
"authMaximumAuthStepsDescription": {
|
||||||
|
"one": "Maximum ask for {} step authenticate",
|
||||||
|
"other": "Maximum ask for {} steps authenticate"
|
||||||
|
},
|
||||||
|
"authAlwaysRisky": "Always Risky",
|
||||||
|
"authAlwaysRiskyDescription": "Always ask for the highest steps count of authentication when logging in.",
|
||||||
|
"chatUnjoined": "Unjoined Channel",
|
||||||
|
"chatUnjoinedDescription": "You haven't joined this channel, so you can't send messages either view messages in it.",
|
||||||
|
"chatUnjoinedPublicDescription": "Fortunately, this is a public channel, so you can join it as you want.",
|
||||||
|
"chatJoin": "Join the Channel",
|
||||||
|
"appInitStarting": "Starting",
|
||||||
|
"appInitNetwork": "Initializing Network",
|
||||||
|
"appInitUserdata": "Initializing User Data",
|
||||||
|
"appInitWebsocket": "Establishing Solar Link",
|
||||||
|
"appInitNotification": "Initializing Push Notifications",
|
||||||
|
"appInitKeyPair": "Initializing Key Pairs",
|
||||||
|
"appInitStickers": "Initializing Stickers",
|
||||||
|
"appInitUserDirectory": "Initializing User Directory",
|
||||||
|
"appInitRealm": "Initializing Realms",
|
||||||
|
"appInitChat": "Initializing Chat",
|
||||||
|
"appInitDone": "Completed",
|
||||||
|
"community": "Community",
|
||||||
|
"realmCommunity": "{}'s Community",
|
||||||
|
"postTotalCount": {
|
||||||
|
"one": "Total {} post",
|
||||||
|
"other": "Total {} posts"
|
||||||
|
},
|
||||||
|
"settingsHideBottomNav": "Hide Bottom Navigation",
|
||||||
|
"settingsHideBottomNavDescription": "Hide the bottom navigation bar, and show the navigation buttons in the drawer.",
|
||||||
|
"reCaptcha": "reCaptcha",
|
||||||
|
"friends": "Friends",
|
||||||
|
"friendsDescription": "Manage your friendships.",
|
||||||
|
"album": "Album",
|
||||||
|
"albumDescription": "View albums and manage attachments.",
|
||||||
|
"stickers": "Stickers",
|
||||||
|
"stickersDescription": "View sticker packs and manage stickers.",
|
||||||
|
"navBottomUnauthorizedCaption": "Or create an account",
|
||||||
|
"walletCurrencyGoldenShort": "GDP",
|
||||||
|
"walletCurrencyGolden": {
|
||||||
|
"one": "{} Golden Point",
|
||||||
|
"other": "{} Golden Points"
|
||||||
|
},
|
||||||
|
"walletTransactionTypeNormal": "Source Point",
|
||||||
|
"walletTransactionTypeGolden": "Golden Point",
|
||||||
|
"accountProgram": "Programs",
|
||||||
|
"accountProgramDescription": "Explore the available member programs.",
|
||||||
|
"accountProgramJoin": "Join Program",
|
||||||
|
"accountProgramJoinRequirements": "Requirements",
|
||||||
|
"accountProgramJoinPricing": "Pricing",
|
||||||
|
"accountProgramJoinPricingHint": "Billed every (30 days) month.",
|
||||||
|
"accountProgramLeaveHint": "After leaving the program, the source points will not be refunded.",
|
||||||
|
"accountProgramJoined": "Joined Program.",
|
||||||
|
"accountProgramAlreadyJoined": "Joined",
|
||||||
|
"accountProgramLeft": "Left Program.",
|
||||||
|
"leave": "Leave",
|
||||||
|
"attachmentFailedToLoadMedia": "Unable to load media file, please try again later. If this error occurs repeatedly, the source file may not exist or the network connection may be abnormal.",
|
||||||
|
"accountPunishments": "Punishments",
|
||||||
|
"accountPunishmentsDescription": "View your account's reputation status.",
|
||||||
|
"punishmentType0": "Strike",
|
||||||
|
"punishmentType1": "Limited",
|
||||||
|
"punishmentType2": "Banned",
|
||||||
|
"punishmentOverall": "Overall Status",
|
||||||
|
"punishmentStatusNormal": "All abilities normal",
|
||||||
|
"punishmentStatusWarned": "All abilities normal, but at least one strike is in effect",
|
||||||
|
"punishmentStatusLimited": "Some abilities limited, at least one limited punishment is in effect",
|
||||||
|
"punishmentStatusLimitedFully": "All abilities limited, at least one completely limited punishment is in effect",
|
||||||
|
"punishmentStatusBanned": "All services are terminated, banned",
|
||||||
|
"punishmentCreatedAt": "Applied since {}",
|
||||||
|
"punishmentExpiredAt": "Expired at {}",
|
||||||
|
"punishmentExpiredNever": "Never expired",
|
||||||
|
"punishmentModerator": "Moderator who made this punishment",
|
||||||
|
"punishmentMadeBySystem": "Made by auto-mod system",
|
||||||
|
"settingsAprilFoolFeatures": "April Fool Features",
|
||||||
|
"settingsAprilFoolFeaturesDescription": "Enable April Fool features during April Fool, this option will only be visible during April Fool.",
|
||||||
|
"settingsSoundEffects": "Sound Effects",
|
||||||
|
"settingsSoundEffectsDescription": "Enable the sound effects around the app.",
|
||||||
|
"settingsResetMemorizedWindowSize": "Reset Window Size",
|
||||||
|
"settingsResetMemorizedWindowSizeDescription": "Reset the memorized window size, and set it to the default size."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,11 @@
|
|||||||
"publisherRunBy": "由 {} 管理",
|
"publisherRunBy": "由 {} 管理",
|
||||||
"fieldPublisherBelongToRealm": "所属领域",
|
"fieldPublisherBelongToRealm": "所属领域",
|
||||||
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
|
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
|
||||||
|
"writePost": "撰写",
|
||||||
|
"postTypeStory": "动态",
|
||||||
|
"postTypeArticle": "文章",
|
||||||
|
"postTypeQuestion": "问题",
|
||||||
|
"postTypeVideo": "视频",
|
||||||
"writePostTypeStory": "发动态",
|
"writePostTypeStory": "发动态",
|
||||||
"writePostTypeArticle": "写文章",
|
"writePostTypeArticle": "写文章",
|
||||||
"writePostTypeQuestion": "提问题",
|
"writePostTypeQuestion": "提问题",
|
||||||
@@ -200,7 +205,13 @@
|
|||||||
"one": "{} 条评论",
|
"one": "{} 条评论",
|
||||||
"other": "{} 条评论"
|
"other": "{} 条评论"
|
||||||
},
|
},
|
||||||
|
"postCommentExpand": "展开评论",
|
||||||
"settingsAppearance": "外观",
|
"settingsAppearance": "外观",
|
||||||
|
"settingsCustomFonts": "自定义字体",
|
||||||
|
"settingsCustomFontsDescription": "设置应用程序使用的字体。",
|
||||||
|
"settingsCustomFontFamily": "应用字体",
|
||||||
|
"settingsCustomFontFamilyHint": "使用英文逗号分割每一种字体,越前优先级越高",
|
||||||
|
"settingsCustomFontApplied": "自定义字体已经应用。",
|
||||||
"settingsDisplayLanguage": "显示语言",
|
"settingsDisplayLanguage": "显示语言",
|
||||||
"settingsDisplayLanguageDescription": "设置应用程序使用的语言",
|
"settingsDisplayLanguageDescription": "设置应用程序使用的语言",
|
||||||
"settingsDisplayLanguageSystem": "跟随系统",
|
"settingsDisplayLanguageSystem": "跟随系统",
|
||||||
@@ -325,6 +336,7 @@
|
|||||||
"fieldAttachmentRandomId": "访问 ID",
|
"fieldAttachmentRandomId": "访问 ID",
|
||||||
"fieldAttachmentAlt": "概述文字",
|
"fieldAttachmentAlt": "概述文字",
|
||||||
"addAttachmentFromAlbum": "从相册中添加附件",
|
"addAttachmentFromAlbum": "从相册中添加附件",
|
||||||
|
"addAttachmentFromFiles": "从文件中添加附件",
|
||||||
"addAttachmentFromClipboard": "粘贴附件",
|
"addAttachmentFromClipboard": "粘贴附件",
|
||||||
"addAttachmentFromCameraPhoto": "拍摄照片",
|
"addAttachmentFromCameraPhoto": "拍摄照片",
|
||||||
"addAttachmentFromCameraVideo": "拍摄视频",
|
"addAttachmentFromCameraVideo": "拍摄视频",
|
||||||
@@ -510,8 +522,13 @@
|
|||||||
"accountBirthday": "出生于 {}",
|
"accountBirthday": "出生于 {}",
|
||||||
"accountBadge": "徽章",
|
"accountBadge": "徽章",
|
||||||
"accountCheckInNoRecords": "暂无运势记录",
|
"accountCheckInNoRecords": "暂无运势记录",
|
||||||
"badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
|
"badgeCompanyStaff": "工作人员",
|
||||||
"badgeSiteMigration": "Solar Network 原住民",
|
"badgeSiteMigration": "Solar Network 原住民",
|
||||||
|
"badgeCommunitySurvey": "调研参与者",
|
||||||
|
"badgeCommunityVerified": "认证用户",
|
||||||
|
"badgeCommunityContributor": "优秀社区贡献者",
|
||||||
|
"badgeSiteAnniversary": "周年纪念",
|
||||||
|
"badgeUserBirthday": "生日纪念",
|
||||||
"accountStatus": "状态",
|
"accountStatus": "状态",
|
||||||
"accountStatusOnline": "在线",
|
"accountStatusOnline": "在线",
|
||||||
"accountStatusOffline": "离线",
|
"accountStatusOffline": "离线",
|
||||||
@@ -720,5 +737,204 @@
|
|||||||
"trayMenuMuteNotification": "静音通知",
|
"trayMenuMuteNotification": "静音通知",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"forceUpdate": "强制更新",
|
"forceUpdate": "强制更新",
|
||||||
"forceUpdateDescription": "强制更新应用程序,即使有更新的版本可能不可用。"
|
"forceUpdateDescription": "强制更新应用程序,即使有更新的版本可能不可用。",
|
||||||
|
"runtimeLogs": "运行时日志",
|
||||||
|
"runtimeLogsOpen": "打开日志文件",
|
||||||
|
"runtimeLogsDescription": "显示运行时的日志记录。",
|
||||||
|
"signinResetPasswordHint": "请输入用户名/电子邮箱地址以帮助我们找到您的帐户并重置密码。",
|
||||||
|
"cacheSize": "缓存资源大小",
|
||||||
|
"cacheDelete": "清除缓存",
|
||||||
|
"cacheDeleteDescription": "从磁盘中移除缓存的图片和其他资源,内容将从服务器重新下载。",
|
||||||
|
"cacheDeleted": "所有缓存已被清除。",
|
||||||
|
"userNoDescription": "这个人很懒,没有留下什么……",
|
||||||
|
"fieldTimeZone": "时区",
|
||||||
|
"fieldGender": "性别",
|
||||||
|
"fieldPronouns": "人称代词",
|
||||||
|
"fieldLocation": "位置",
|
||||||
|
"fieldLinks": "链接",
|
||||||
|
"fieldLinkName": "名称",
|
||||||
|
"fieldLinkUrl": "链接",
|
||||||
|
"screenAccountBadges": "徽章",
|
||||||
|
"accountBadges": "徽章",
|
||||||
|
"accountBadgesDescription": "查看并管理你的徽章。",
|
||||||
|
"badgeActivated": "已佩戴徽章 {}。",
|
||||||
|
"viewDetailedAttachment": "查看附件详情",
|
||||||
|
"screenKeyPairs": "密钥对",
|
||||||
|
"accountKeyPairs": "密钥对",
|
||||||
|
"accountKeyPairsDescription": "管理用于加密信息的密钥对。",
|
||||||
|
"enrollNewKeyPair": "新建密钥对",
|
||||||
|
"enrollNewKeyPairDescription": "生成一对新密钥对。",
|
||||||
|
"keyPairHasPrivateKey": "有私钥",
|
||||||
|
"decrypting": "解密中……",
|
||||||
|
"decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线",
|
||||||
|
"messageUnablePreview": "无法预览消息",
|
||||||
|
"messageUnablePreviewEncrypted": "无法预览加密消息",
|
||||||
|
"postViewInGlobalDescription": "不查看特定领域的帖子。",
|
||||||
|
"postDraftSaved": "已保存为草稿。",
|
||||||
|
"postDraftBox": "草稿箱",
|
||||||
|
"postShuffle": "随便看看",
|
||||||
|
"checkInStreak": {
|
||||||
|
"zero": "无连击",
|
||||||
|
"one": "连续签到 {} 天",
|
||||||
|
"other": "连续签到 {} 天"
|
||||||
|
},
|
||||||
|
"accountChangeStatus": "修改状态",
|
||||||
|
"accountStatusSilent": "请勿打扰",
|
||||||
|
"accountStatusSilentDesc": "将会暂停所有通知推送",
|
||||||
|
"accountStatusInvisible": "隐身",
|
||||||
|
"accountStatusInvisibleDesc": "将会在他人界面显示离线,但不影响功能使用",
|
||||||
|
"accountCustomStatus": "自定义状态",
|
||||||
|
"accountCustomStatusDescription": "客制化你的状态。",
|
||||||
|
"accountClearStatus": "清除状态",
|
||||||
|
"accountClearStatusDescription": "清除你的状态,并让服务器决定你的状态。",
|
||||||
|
"fieldAccountStatusLabel": "状态文字",
|
||||||
|
"fieldAccountStatusClearAt": "清除时间",
|
||||||
|
"accountStatusNegative": "负面",
|
||||||
|
"accountStatusNeutral": "中性",
|
||||||
|
"accountStatusPositive": "正面",
|
||||||
|
"mixedFeed": "混合推荐流",
|
||||||
|
"mixedFeedDescription": "探索页面可能不只会展示用户的帖子,更可能包含其他的内容。但该模式不适用分类和过滤。",
|
||||||
|
"filterFeed": "探索队列调整",
|
||||||
|
"feedUnknownItem": "无法显示该内容,当前版本客户端不支持该类型的内容,请尝试更新应用程序后再试。",
|
||||||
|
"serviceStatusOperational": "所有服务正常",
|
||||||
|
"serviceStatusDowngraded": "部分服务异常",
|
||||||
|
"serviceStatusFailed": "服务状态异常",
|
||||||
|
"serviceStatusFailedDescription": "服务器炸了或者刚刚执行完维护程序。",
|
||||||
|
"serviceNameInsights": "总结、见解与洞察",
|
||||||
|
"serviceNameInteractive": "帖子与互动",
|
||||||
|
"serviceNameReader": "新闻与链接展开",
|
||||||
|
"serviceNameMessaging": "即使聊天",
|
||||||
|
"serviceNameMatrix": "矩阵市场",
|
||||||
|
"serviceNamePaperclip": "附件",
|
||||||
|
"serviceNameWallet": "源点钱包",
|
||||||
|
"serviceNamePassport": "身份验证与授权",
|
||||||
|
"accountActionEvent": "操作日志",
|
||||||
|
"accountActionEventDescription": "查看你的操作日志。",
|
||||||
|
"eventMetadata": "元数据",
|
||||||
|
"accountAuthTickets": "授权会话",
|
||||||
|
"accountAuthTicketsDescription": "查看和管理你的授权会话。",
|
||||||
|
"authTicketCreatedAt": "签发于 {}",
|
||||||
|
"authTicketExpiredAt": "到期于 {}",
|
||||||
|
"authTicketLastGrantAt": "上次刷新于 {}",
|
||||||
|
"authTicketCurrent": "当前会话",
|
||||||
|
"accountUnconfirmedTitle": "尚未未确认账户",
|
||||||
|
"accountUnconfirmedSubtitle": "您的账户尚未确认,这会导致大部分功能不可用,并且您的帐户将在 24 小时后自毁。您绑定的邮箱的收件箱应该会有一封邮件指引您确认您的帐户。",
|
||||||
|
"accountUnconfirmedUnreceived": "未收到邮件?",
|
||||||
|
"accountUnconfirmedResend": "重新发送一封",
|
||||||
|
"accountUnconfirmedResendSuccessful": "邮件已重新发送,你可以在 60 分钟后再重发一封。",
|
||||||
|
"stickerPickerEmpty": "贴图列表为空",
|
||||||
|
"stickerPickerEmptyHint": "想要开始使用贴图,请先添加贴图包。",
|
||||||
|
"goto": "跳转到 {}",
|
||||||
|
"accountContactMethods": "联系方式",
|
||||||
|
"accountContactMethodsDescription": "管理你的联系方式。",
|
||||||
|
"accountContactMethodsNameEmail": "电子邮箱",
|
||||||
|
"accountContactMethodsNamePhone": "电话",
|
||||||
|
"accountContactMethodsNameAddress": "地址",
|
||||||
|
"accountContactMethodsPrimary": "主要的",
|
||||||
|
"accountContactMethodsVerified": "已验证",
|
||||||
|
"accountContactMethodsPublic": "公开的",
|
||||||
|
"accountContactMethodsAdd": "添加联系方式",
|
||||||
|
"accountContactMethodsEdit": "编辑联系方式",
|
||||||
|
"accountContactMethodsAddDescription": "添加新的联系方式。",
|
||||||
|
"fieldContactContent": "联系方式",
|
||||||
|
"accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。",
|
||||||
|
"accountContactMethodsDelete": "删除联系方式",
|
||||||
|
"accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。",
|
||||||
|
"postCommentAdd": "撰写一条评论",
|
||||||
|
"translate": "翻译",
|
||||||
|
"translating": "正在翻译……",
|
||||||
|
"translated": "已翻译",
|
||||||
|
"settingsAutoTranslate": "自动翻译",
|
||||||
|
"settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。",
|
||||||
|
"trayMenuHide": "隐藏",
|
||||||
|
"accountSettingsNotify": "通知设置",
|
||||||
|
"accountSettingsNotifyDescription": "调整你所收到的通知种类。",
|
||||||
|
"accountSettingsSecurity": "安全设置",
|
||||||
|
"accountSettingsSecurityDescription": "调整你的帐户安全设置。",
|
||||||
|
"save": "保存",
|
||||||
|
"notificationTopicPostFeedback": "帖子数据反馈",
|
||||||
|
"notificationTopicPostReply": "帖子回复",
|
||||||
|
"notificationTopicPostSubscription": "帖子订阅",
|
||||||
|
"notificationTopicMessaging": "消息",
|
||||||
|
"notificationTopicMessagingCall": "通话",
|
||||||
|
"notificationTopicGeneral": "杂项",
|
||||||
|
"authMaximumAuthSteps": "最大验证步骤",
|
||||||
|
"authMaximumAuthStepsDescription": {
|
||||||
|
"one": "登入时最多要求 {} 步验证",
|
||||||
|
"other": "登入时最多要求 {} 步验证"
|
||||||
|
},
|
||||||
|
"authAlwaysRisky": "总是风险",
|
||||||
|
"authAlwaysRiskyDescription": "在登入时始终按最高标准要求验证。",
|
||||||
|
"chatUnjoined": "未加入频道",
|
||||||
|
"chatUnjoinedDescription": "你没有加入这个频道,所以你也无法发送消息或者查看这个频道中的消息。",
|
||||||
|
"chatUnjoinedPublicDescription": "但幸运的是,这是一个公开频道,所以你可以主动加入。",
|
||||||
|
"chatJoin": "加入频道",
|
||||||
|
"appInitStarting": "启动中",
|
||||||
|
"appInitNetwork": "正在初始化网络",
|
||||||
|
"appInitUserdata": "正在初始化用户数据",
|
||||||
|
"appInitWebsocket": "正在建立 Solar Link",
|
||||||
|
"appInitNotification": "正在初始化推送通知",
|
||||||
|
"appInitKeyPair": "正在初始化密钥对",
|
||||||
|
"appInitStickers": "正在初始化贴图包",
|
||||||
|
"appInitUserDirectory": "正在初始化用户目录",
|
||||||
|
"appInitRealm": "正在初始化领域信息",
|
||||||
|
"appInitChat": "正在初始化聊天",
|
||||||
|
"appInitDone": "完成",
|
||||||
|
"community": "社区",
|
||||||
|
"realmCommunity": "{}的社区",
|
||||||
|
"postTotalCount": {
|
||||||
|
"zero": "没有帖子",
|
||||||
|
"one": "共 {} 条帖子"
|
||||||
|
},
|
||||||
|
"settingsHideBottomNav": "隐藏底部导航栏",
|
||||||
|
"settingsHideBottomNavDescription": "隐藏底部导航栏,在侧边栏抽屉显示导航按钮。",
|
||||||
|
"reCaptcha": "人机验证",
|
||||||
|
"friends": "好友",
|
||||||
|
"friendsDescription": "管理好友关系。",
|
||||||
|
"album": "相册",
|
||||||
|
"albumDescription": "查看相册与管理上传附件。",
|
||||||
|
"stickers": "贴图",
|
||||||
|
"stickersDescription": "查看贴图包与管理贴图。",
|
||||||
|
"navBottomUnauthorizedCaption": "或者注册一个账号",
|
||||||
|
"walletCurrencyGoldenShort": "金点",
|
||||||
|
"walletCurrencyGolden": {
|
||||||
|
"one": "{} 金点",
|
||||||
|
"other": "{} 金点"
|
||||||
|
},
|
||||||
|
"walletTransactionTypeNormal": "源点",
|
||||||
|
"walletTransactionTypeGolden": "金点",
|
||||||
|
"accountProgram": "计划",
|
||||||
|
"accountProgramDescription": "了解可用的成员计划。",
|
||||||
|
"accountProgramJoin": "加入计划",
|
||||||
|
"accountProgramJoinRequirements": "要求",
|
||||||
|
"accountProgramJoinPricing": "价格",
|
||||||
|
"accountProgramJoinPricingHint": "按月(30 天)收费",
|
||||||
|
"accountProgramLeaveHint": "离开计划后,之前花费的源点不会退款。",
|
||||||
|
"accountProgramJoined": "已加入计划。",
|
||||||
|
"accountProgramLeft": "已离开计划。",
|
||||||
|
"accountProgramAlreadyJoined": "已加入",
|
||||||
|
"leave": "离开",
|
||||||
|
"attachmentFailedToLoadMedia": "无法加载媒体文件,请稍后重试。若此错误重复出现,可能源文件不存在或者网络连接异常。",
|
||||||
|
"accountPunishments": "处分",
|
||||||
|
"accountPunishmentsDescription": "查看你帐号的信誉状态。",
|
||||||
|
"punishmentType0": "警告",
|
||||||
|
"punishmentType1": "停权",
|
||||||
|
"punishmentType2": "封禁",
|
||||||
|
"punishmentOverall": "总体状态",
|
||||||
|
"punishmentStatusNormal": "所有功能正常",
|
||||||
|
"punishmentStatusWarned": "所有功能正常,但有警告生效",
|
||||||
|
"punishmentStatusLimited": "部份功能暂时受限,有至少一个停权生效",
|
||||||
|
"punishmentStatusLimitedFully": "所有功能暂时受限,有至少一个完全停权生效",
|
||||||
|
"punishmentStatusBanned": "所有服务终止,已被封禁",
|
||||||
|
"punishmentCreatedAt": "宣布于 {}",
|
||||||
|
"punishmentExpiredAt": "到期于 {}",
|
||||||
|
"punishmentExpiredNever": "永久生效",
|
||||||
|
"punishmentModerator": "责任管理员",
|
||||||
|
"punishmentMadeBySystem": "由系统自动裁决",
|
||||||
|
"settingsAprilFoolFeatures": "愚人节特性",
|
||||||
|
"settingsAprilFoolFeaturesDescription": "在愚人节期间启用愚人节特性,该选项只会在愚人节期间显示。",
|
||||||
|
"settingsSoundEffects": "声音效果",
|
||||||
|
"settingsSoundEffectsDescription": "在一些场合下启用声音特效。",
|
||||||
|
"settingsResetMemorizedWindowSize": "重置窗口大小",
|
||||||
|
"settingsResetMemorizedWindowSizeDescription": "重置记忆的窗口大小,以重新设置为默认大小。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,11 @@
|
|||||||
"publisherRunBy": "由 {} 管理",
|
"publisherRunBy": "由 {} 管理",
|
||||||
"fieldPublisherBelongToRealm": "所屬領域",
|
"fieldPublisherBelongToRealm": "所屬領域",
|
||||||
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
|
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
|
||||||
|
"writePost": "撰寫",
|
||||||
|
"postTypeStory": "動態",
|
||||||
|
"postTypeArticle": "文章",
|
||||||
|
"postTypeQuestion": "問題",
|
||||||
|
"postTypeVideo": "視頻",
|
||||||
"writePostTypeStory": "發動態",
|
"writePostTypeStory": "發動態",
|
||||||
"writePostTypeArticle": "寫文章",
|
"writePostTypeArticle": "寫文章",
|
||||||
"writePostTypeQuestion": "提問題",
|
"writePostTypeQuestion": "提問題",
|
||||||
@@ -200,7 +205,13 @@
|
|||||||
"one": "{} 條評論",
|
"one": "{} 條評論",
|
||||||
"other": "{} 條評論"
|
"other": "{} 條評論"
|
||||||
},
|
},
|
||||||
|
"postCommentExpand": "展開評論",
|
||||||
"settingsAppearance": "外觀",
|
"settingsAppearance": "外觀",
|
||||||
|
"settingsCustomFonts": "自定義字體",
|
||||||
|
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
|
||||||
|
"settingsCustomFontFamily": "應用字體",
|
||||||
|
"settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
|
||||||
|
"settingsCustomFontApplied": "自定義字體已經應用。",
|
||||||
"settingsDisplayLanguage": "顯示語言",
|
"settingsDisplayLanguage": "顯示語言",
|
||||||
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
|
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
|
||||||
"settingsDisplayLanguageSystem": "跟隨系統",
|
"settingsDisplayLanguageSystem": "跟隨系統",
|
||||||
@@ -325,6 +336,7 @@
|
|||||||
"fieldAttachmentRandomId": "訪問 ID",
|
"fieldAttachmentRandomId": "訪問 ID",
|
||||||
"fieldAttachmentAlt": "概述文字",
|
"fieldAttachmentAlt": "概述文字",
|
||||||
"addAttachmentFromAlbum": "從相冊中添加附件",
|
"addAttachmentFromAlbum": "從相冊中添加附件",
|
||||||
|
"addAttachmentFromFiles": "從文件中添加附件",
|
||||||
"addAttachmentFromClipboard": "粘貼附件",
|
"addAttachmentFromClipboard": "粘貼附件",
|
||||||
"addAttachmentFromCameraPhoto": "拍攝照片",
|
"addAttachmentFromCameraPhoto": "拍攝照片",
|
||||||
"addAttachmentFromCameraVideo": "拍攝視頻",
|
"addAttachmentFromCameraVideo": "拍攝視頻",
|
||||||
@@ -510,8 +522,13 @@
|
|||||||
"accountBirthday": "出生於 {}",
|
"accountBirthday": "出生於 {}",
|
||||||
"accountBadge": "徽章",
|
"accountBadge": "徽章",
|
||||||
"accountCheckInNoRecords": "暫無運勢記錄",
|
"accountCheckInNoRecords": "暫無運勢記錄",
|
||||||
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
|
"badgeCompanyStaff": "工作人員",
|
||||||
"badgeSiteMigration": "Solar Network 原住民",
|
"badgeSiteMigration": "Solar Network 原住民",
|
||||||
|
"badgeCommunitySurvey": "調研參與者",
|
||||||
|
"badgeCommunityVerified": "認證用户",
|
||||||
|
"badgeCommunityContributor": "優秀社區貢獻者",
|
||||||
|
"badgeSiteAnniversary": "週年紀念",
|
||||||
|
"badgeUserBirthday": "生日紀念",
|
||||||
"accountStatus": "狀態",
|
"accountStatus": "狀態",
|
||||||
"accountStatusOnline": "在線",
|
"accountStatusOnline": "在線",
|
||||||
"accountStatusOffline": "離線",
|
"accountStatusOffline": "離線",
|
||||||
@@ -720,5 +737,163 @@
|
|||||||
"trayMenuMuteNotification": "靜音通知",
|
"trayMenuMuteNotification": "靜音通知",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"forceUpdate": "強制更新",
|
"forceUpdate": "強制更新",
|
||||||
"forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。"
|
"forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。",
|
||||||
|
"runtimeLogs": "運行時日誌",
|
||||||
|
"runtimeLogsOpen": "打開日誌文件",
|
||||||
|
"runtimeLogsDescription": "顯示運行時的日誌記錄。",
|
||||||
|
"signinResetPasswordHint": "請輸入用户名/電子郵箱地址以幫助我們找到您的帳户並重置密碼。",
|
||||||
|
"cacheSize": "緩存資源大小",
|
||||||
|
"cacheDelete": "清除緩存",
|
||||||
|
"cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。",
|
||||||
|
"cacheDeleted": "所有緩存已被清除。",
|
||||||
|
"userNoDescription": "這個人很懶,沒有留下什麼……",
|
||||||
|
"fieldTimeZone": "時區",
|
||||||
|
"fieldGender": "性別",
|
||||||
|
"fieldPronouns": "人稱代詞",
|
||||||
|
"fieldLocation": "位置",
|
||||||
|
"fieldLinks": "鏈接",
|
||||||
|
"fieldLinkName": "名稱",
|
||||||
|
"fieldLinkUrl": "鏈接",
|
||||||
|
"screenAccountBadges": "徽章",
|
||||||
|
"accountBadges": "徽章",
|
||||||
|
"accountBadgesDescription": "查看並管理你的徽章。",
|
||||||
|
"badgeActivated": "已佩戴徽章 {}。",
|
||||||
|
"viewDetailedAttachment": "查看附件詳情",
|
||||||
|
"screenKeyPairs": "密鑰對",
|
||||||
|
"accountKeyPairs": "密鑰對",
|
||||||
|
"accountKeyPairsDescription": "管理用於加密信息的密鑰對。",
|
||||||
|
"enrollNewKeyPair": "新建密鑰對",
|
||||||
|
"enrollNewKeyPairDescription": "生成一對新密鑰對。",
|
||||||
|
"keyPairHasPrivateKey": "有私鑰",
|
||||||
|
"decrypting": "解密中……",
|
||||||
|
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
|
||||||
|
"messageUnablePreview": "無法預覽消息",
|
||||||
|
"messageUnablePreviewEncrypted": "無法預覽加密消息",
|
||||||
|
"postViewInGlobalDescription": "不查看特定領域的帖子。",
|
||||||
|
"postDraftSaved": "已保存為草稿。",
|
||||||
|
"postDraftBox": "草稿箱",
|
||||||
|
"postShuffle": "隨便看看",
|
||||||
|
"checkInStreak": {
|
||||||
|
"zero": "無連擊",
|
||||||
|
"one": "連續簽到 {} 天",
|
||||||
|
"other": "連續簽到 {} 天"
|
||||||
|
},
|
||||||
|
"accountChangeStatus": "修改狀態",
|
||||||
|
"accountStatusSilent": "請勿打擾",
|
||||||
|
"accountStatusSilentDesc": "將會暫停所有通知推送",
|
||||||
|
"accountStatusInvisible": "隱身",
|
||||||
|
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
|
||||||
|
"accountCustomStatus": "自定義狀態",
|
||||||
|
"accountCustomStatusDescription": "客製化你的狀態。",
|
||||||
|
"accountClearStatus": "清除狀態",
|
||||||
|
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
|
||||||
|
"fieldAccountStatusLabel": "狀態文字",
|
||||||
|
"fieldAccountStatusClearAt": "清除時間",
|
||||||
|
"accountStatusNegative": "負面",
|
||||||
|
"accountStatusNeutral": "中性",
|
||||||
|
"accountStatusPositive": "正面",
|
||||||
|
"mixedFeed": "混合推薦流",
|
||||||
|
"mixedFeedDescription": "探索頁面可能不只會展示用户的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
|
||||||
|
"filterFeed": "探索隊列調整",
|
||||||
|
"feedUnknownItem": "無法顯示該內容,當前版本客户端不支持該類型的內容,請嘗試更新應用程序後再試。",
|
||||||
|
"serviceStatusOperational": "所有服務正常",
|
||||||
|
"serviceStatusDowngraded": "部分服務異常",
|
||||||
|
"serviceStatusFailed": "服務狀態異常",
|
||||||
|
"serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
|
||||||
|
"serviceNameInsights": "總結、見解與洞察",
|
||||||
|
"serviceNameInteractive": "帖子與互動",
|
||||||
|
"serviceNameReader": "新聞與鏈接展開",
|
||||||
|
"serviceNameMessaging": "即使聊天",
|
||||||
|
"serviceNameMatrix": "矩陣市場",
|
||||||
|
"serviceNamePaperclip": "附件",
|
||||||
|
"serviceNameWallet": "源點錢包",
|
||||||
|
"serviceNamePassport": "身份驗證與授權",
|
||||||
|
"accountActionEvent": "操作日誌",
|
||||||
|
"accountActionEventDescription": "查看你的操作日誌。",
|
||||||
|
"eventMetadata": "元數據",
|
||||||
|
"accountAuthTickets": "授權會話",
|
||||||
|
"accountAuthTicketsDescription": "查看和管理你的授權會話。",
|
||||||
|
"authTicketCreatedAt": "簽發於 {}",
|
||||||
|
"authTicketExpiredAt": "到期於 {}",
|
||||||
|
"authTicketLastGrantAt": "上次刷新於 {}",
|
||||||
|
"authTicketCurrent": "當前會話",
|
||||||
|
"accountUnconfirmedTitle": "尚未未確認賬户",
|
||||||
|
"accountUnconfirmedSubtitle": "您的賬户尚未確認,這會導致大部分功能不可用,並且您的帳户將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳户。",
|
||||||
|
"accountUnconfirmedUnreceived": "未收到郵件?",
|
||||||
|
"accountUnconfirmedResend": "重新發送一封",
|
||||||
|
"accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。",
|
||||||
|
"stickerPickerEmpty": "貼圖列表為空",
|
||||||
|
"stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。",
|
||||||
|
"goto": "跳轉到 {}",
|
||||||
|
"accountContactMethods": "聯繫方式",
|
||||||
|
"accountContactMethodsDescription": "管理你的聯繫方式。",
|
||||||
|
"accountContactMethodsNameEmail": "電子郵箱",
|
||||||
|
"accountContactMethodsNamePhone": "電話",
|
||||||
|
"accountContactMethodsNameAddress": "地址",
|
||||||
|
"accountContactMethodsPrimary": "主要的",
|
||||||
|
"accountContactMethodsVerified": "已驗證",
|
||||||
|
"accountContactMethodsPublic": "公開的",
|
||||||
|
"accountContactMethodsAdd": "添加聯繫方式",
|
||||||
|
"accountContactMethodsEdit": "編輯聯繫方式",
|
||||||
|
"accountContactMethodsAddDescription": "添加新的聯繫方式。",
|
||||||
|
"fieldContactContent": "聯繫方式",
|
||||||
|
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
|
||||||
|
"accountContactMethodsDelete": "刪除聯繫方式",
|
||||||
|
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
|
||||||
|
"postCommentAdd": "撰寫一條評論",
|
||||||
|
"translate": "翻譯",
|
||||||
|
"translating": "正在翻譯……",
|
||||||
|
"translated": "已翻譯",
|
||||||
|
"settingsAutoTranslate": "自動翻譯",
|
||||||
|
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
|
||||||
|
"trayMenuHide": "隱藏",
|
||||||
|
"accountSettingsNotify": "通知設置",
|
||||||
|
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
|
||||||
|
"accountSettingsSecurity": "安全設置",
|
||||||
|
"accountSettingsSecurityDescription": "調整你的帳户安全設置。",
|
||||||
|
"save": "保存",
|
||||||
|
"notificationTopicPostFeedback": "帖子數據反饋",
|
||||||
|
"notificationTopicPostReply": "帖子回覆",
|
||||||
|
"notificationTopicPostSubscription": "帖子訂閲",
|
||||||
|
"notificationTopicMessaging": "消息",
|
||||||
|
"notificationTopicMessagingCall": "通話",
|
||||||
|
"notificationTopicGeneral": "雜項",
|
||||||
|
"authMaximumAuthSteps": "最大驗證步驟",
|
||||||
|
"authMaximumAuthStepsDescription": {
|
||||||
|
"one": "登入時最多要求 {} 步驗證",
|
||||||
|
"other": "登入時最多要求 {} 步驗證"
|
||||||
|
},
|
||||||
|
"authAlwaysRisky": "總是風險",
|
||||||
|
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
|
||||||
|
"chatUnjoined": "未加入頻道",
|
||||||
|
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
|
||||||
|
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
|
||||||
|
"chatJoin": "加入頻道",
|
||||||
|
"appInitStarting": "啓動中",
|
||||||
|
"appInitNetwork": "正在初始化網絡",
|
||||||
|
"appInitUserdata": "正在初始化用户數據",
|
||||||
|
"appInitWebsocket": "正在建立 Solar Link",
|
||||||
|
"appInitNotification": "正在初始化推送通知",
|
||||||
|
"appInitKeyPair": "正在初始化密鑰對",
|
||||||
|
"appInitStickers": "正在初始化貼圖包",
|
||||||
|
"appInitUserDirectory": "正在初始化用户目錄",
|
||||||
|
"appInitRealm": "正在初始化領域信息",
|
||||||
|
"appInitChat": "正在初始化聊天",
|
||||||
|
"appInitDone": "完成",
|
||||||
|
"community": "社區",
|
||||||
|
"realmCommunity": "{}的社區",
|
||||||
|
"postTotalCount": {
|
||||||
|
"zero": "沒有帖子",
|
||||||
|
"one": "共 {} 條帖子"
|
||||||
|
},
|
||||||
|
"settingsHideBottomNav": "隱藏底部導航欄",
|
||||||
|
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
|
||||||
|
"reCaptcha": "人機驗證",
|
||||||
|
"friends": "好友",
|
||||||
|
"friendsDescription": "管理好友關係。",
|
||||||
|
"album": "相冊",
|
||||||
|
"albumDescription": "查看相冊與管理上傳附件。",
|
||||||
|
"stickers": "貼圖",
|
||||||
|
"stickersDescription": "查看貼圖包與管理貼圖。",
|
||||||
|
"navBottomUnauthorizedCaption": "或者註冊一個賬號"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,11 @@
|
|||||||
"publisherRunBy": "由 {} 管理",
|
"publisherRunBy": "由 {} 管理",
|
||||||
"fieldPublisherBelongToRealm": "所屬領域",
|
"fieldPublisherBelongToRealm": "所屬領域",
|
||||||
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
|
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
|
||||||
|
"writePost": "撰寫",
|
||||||
|
"postTypeStory": "動態",
|
||||||
|
"postTypeArticle": "文章",
|
||||||
|
"postTypeQuestion": "問題",
|
||||||
|
"postTypeVideo": "視頻",
|
||||||
"writePostTypeStory": "發動態",
|
"writePostTypeStory": "發動態",
|
||||||
"writePostTypeArticle": "寫文章",
|
"writePostTypeArticle": "寫文章",
|
||||||
"writePostTypeQuestion": "提問題",
|
"writePostTypeQuestion": "提問題",
|
||||||
@@ -200,7 +205,13 @@
|
|||||||
"one": "{} 條評論",
|
"one": "{} 條評論",
|
||||||
"other": "{} 條評論"
|
"other": "{} 條評論"
|
||||||
},
|
},
|
||||||
|
"postCommentExpand": "展開評論",
|
||||||
"settingsAppearance": "外觀",
|
"settingsAppearance": "外觀",
|
||||||
|
"settingsCustomFonts": "自定義字體",
|
||||||
|
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
|
||||||
|
"settingsCustomFontFamily": "應用字體",
|
||||||
|
"settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
|
||||||
|
"settingsCustomFontApplied": "自定義字體已經應用。",
|
||||||
"settingsDisplayLanguage": "顯示語言",
|
"settingsDisplayLanguage": "顯示語言",
|
||||||
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
|
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
|
||||||
"settingsDisplayLanguageSystem": "跟隨系統",
|
"settingsDisplayLanguageSystem": "跟隨系統",
|
||||||
@@ -325,6 +336,7 @@
|
|||||||
"fieldAttachmentRandomId": "訪問 ID",
|
"fieldAttachmentRandomId": "訪問 ID",
|
||||||
"fieldAttachmentAlt": "概述文字",
|
"fieldAttachmentAlt": "概述文字",
|
||||||
"addAttachmentFromAlbum": "從相冊中添加附件",
|
"addAttachmentFromAlbum": "從相冊中添加附件",
|
||||||
|
"addAttachmentFromFiles": "從文件中添加附件",
|
||||||
"addAttachmentFromClipboard": "粘貼附件",
|
"addAttachmentFromClipboard": "粘貼附件",
|
||||||
"addAttachmentFromCameraPhoto": "拍攝照片",
|
"addAttachmentFromCameraPhoto": "拍攝照片",
|
||||||
"addAttachmentFromCameraVideo": "拍攝視頻",
|
"addAttachmentFromCameraVideo": "拍攝視頻",
|
||||||
@@ -510,8 +522,13 @@
|
|||||||
"accountBirthday": "出生於 {}",
|
"accountBirthday": "出生於 {}",
|
||||||
"accountBadge": "徽章",
|
"accountBadge": "徽章",
|
||||||
"accountCheckInNoRecords": "暫無運勢記錄",
|
"accountCheckInNoRecords": "暫無運勢記錄",
|
||||||
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
|
"badgeCompanyStaff": "工作人員",
|
||||||
"badgeSiteMigration": "Solar Network 原住民",
|
"badgeSiteMigration": "Solar Network 原住民",
|
||||||
|
"badgeCommunitySurvey": "調研參與者",
|
||||||
|
"badgeCommunityVerified": "認證用戶",
|
||||||
|
"badgeCommunityContributor": "優秀社區貢獻者",
|
||||||
|
"badgeSiteAnniversary": "週年紀念",
|
||||||
|
"badgeUserBirthday": "生日紀念",
|
||||||
"accountStatus": "狀態",
|
"accountStatus": "狀態",
|
||||||
"accountStatusOnline": "在線",
|
"accountStatusOnline": "在線",
|
||||||
"accountStatusOffline": "離線",
|
"accountStatusOffline": "離線",
|
||||||
@@ -720,5 +737,163 @@
|
|||||||
"trayMenuMuteNotification": "靜音通知",
|
"trayMenuMuteNotification": "靜音通知",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"forceUpdate": "強制更新",
|
"forceUpdate": "強制更新",
|
||||||
"forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。"
|
"forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。",
|
||||||
|
"runtimeLogs": "運行時日誌",
|
||||||
|
"runtimeLogsOpen": "打開日誌文件",
|
||||||
|
"runtimeLogsDescription": "顯示運行時的日誌記錄。",
|
||||||
|
"signinResetPasswordHint": "請輸入用戶名/電子郵箱地址以幫助我們找到您的帳戶並重置密碼。",
|
||||||
|
"cacheSize": "緩存資源大小",
|
||||||
|
"cacheDelete": "清除緩存",
|
||||||
|
"cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。",
|
||||||
|
"cacheDeleted": "所有緩存已被清除。",
|
||||||
|
"userNoDescription": "這個人很懶,沒有留下什麼……",
|
||||||
|
"fieldTimeZone": "時區",
|
||||||
|
"fieldGender": "性別",
|
||||||
|
"fieldPronouns": "人稱代詞",
|
||||||
|
"fieldLocation": "位置",
|
||||||
|
"fieldLinks": "鏈接",
|
||||||
|
"fieldLinkName": "名稱",
|
||||||
|
"fieldLinkUrl": "鏈接",
|
||||||
|
"screenAccountBadges": "徽章",
|
||||||
|
"accountBadges": "徽章",
|
||||||
|
"accountBadgesDescription": "查看並管理你的徽章。",
|
||||||
|
"badgeActivated": "已佩戴徽章 {}。",
|
||||||
|
"viewDetailedAttachment": "查看附件詳情",
|
||||||
|
"screenKeyPairs": "密鑰對",
|
||||||
|
"accountKeyPairs": "密鑰對",
|
||||||
|
"accountKeyPairsDescription": "管理用於加密信息的密鑰對。",
|
||||||
|
"enrollNewKeyPair": "新建密鑰對",
|
||||||
|
"enrollNewKeyPairDescription": "生成一對新密鑰對。",
|
||||||
|
"keyPairHasPrivateKey": "有私鑰",
|
||||||
|
"decrypting": "解密中……",
|
||||||
|
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
|
||||||
|
"messageUnablePreview": "無法預覽消息",
|
||||||
|
"messageUnablePreviewEncrypted": "無法預覽加密消息",
|
||||||
|
"postViewInGlobalDescription": "不查看特定領域的帖子。",
|
||||||
|
"postDraftSaved": "已保存為草稿。",
|
||||||
|
"postDraftBox": "草稿箱",
|
||||||
|
"postShuffle": "隨便看看",
|
||||||
|
"checkInStreak": {
|
||||||
|
"zero": "無連擊",
|
||||||
|
"one": "連續簽到 {} 天",
|
||||||
|
"other": "連續簽到 {} 天"
|
||||||
|
},
|
||||||
|
"accountChangeStatus": "修改狀態",
|
||||||
|
"accountStatusSilent": "請勿打擾",
|
||||||
|
"accountStatusSilentDesc": "將會暫停所有通知推送",
|
||||||
|
"accountStatusInvisible": "隱身",
|
||||||
|
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
|
||||||
|
"accountCustomStatus": "自定義狀態",
|
||||||
|
"accountCustomStatusDescription": "客製化你的狀態。",
|
||||||
|
"accountClearStatus": "清除狀態",
|
||||||
|
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
|
||||||
|
"fieldAccountStatusLabel": "狀態文字",
|
||||||
|
"fieldAccountStatusClearAt": "清除時間",
|
||||||
|
"accountStatusNegative": "負面",
|
||||||
|
"accountStatusNeutral": "中性",
|
||||||
|
"accountStatusPositive": "正面",
|
||||||
|
"mixedFeed": "混合推薦流",
|
||||||
|
"mixedFeedDescription": "探索頁面可能不只會展示用戶的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
|
||||||
|
"filterFeed": "探索隊列調整",
|
||||||
|
"feedUnknownItem": "無法顯示該內容,當前版本客戶端不支持該類型的內容,請嘗試更新應用程序後再試。",
|
||||||
|
"serviceStatusOperational": "所有服務正常",
|
||||||
|
"serviceStatusDowngraded": "部分服務異常",
|
||||||
|
"serviceStatusFailed": "服務狀態異常",
|
||||||
|
"serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
|
||||||
|
"serviceNameInsights": "總結、見解與洞察",
|
||||||
|
"serviceNameInteractive": "帖子與互動",
|
||||||
|
"serviceNameReader": "新聞與鏈接展開",
|
||||||
|
"serviceNameMessaging": "即使聊天",
|
||||||
|
"serviceNameMatrix": "矩陣市場",
|
||||||
|
"serviceNamePaperclip": "附件",
|
||||||
|
"serviceNameWallet": "源點錢包",
|
||||||
|
"serviceNamePassport": "身份驗證與授權",
|
||||||
|
"accountActionEvent": "操作日誌",
|
||||||
|
"accountActionEventDescription": "查看你的操作日誌。",
|
||||||
|
"eventMetadata": "元數據",
|
||||||
|
"accountAuthTickets": "授權會話",
|
||||||
|
"accountAuthTicketsDescription": "查看和管理你的授權會話。",
|
||||||
|
"authTicketCreatedAt": "簽發於 {}",
|
||||||
|
"authTicketExpiredAt": "到期於 {}",
|
||||||
|
"authTicketLastGrantAt": "上次刷新於 {}",
|
||||||
|
"authTicketCurrent": "當前會話",
|
||||||
|
"accountUnconfirmedTitle": "尚未未確認賬戶",
|
||||||
|
"accountUnconfirmedSubtitle": "您的賬戶尚未確認,這會導致大部分功能不可用,並且您的帳戶將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳戶。",
|
||||||
|
"accountUnconfirmedUnreceived": "未收到郵件?",
|
||||||
|
"accountUnconfirmedResend": "重新發送一封",
|
||||||
|
"accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。",
|
||||||
|
"stickerPickerEmpty": "貼圖列表為空",
|
||||||
|
"stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。",
|
||||||
|
"goto": "跳轉到 {}",
|
||||||
|
"accountContactMethods": "聯繫方式",
|
||||||
|
"accountContactMethodsDescription": "管理你的聯繫方式。",
|
||||||
|
"accountContactMethodsNameEmail": "電子郵箱",
|
||||||
|
"accountContactMethodsNamePhone": "電話",
|
||||||
|
"accountContactMethodsNameAddress": "地址",
|
||||||
|
"accountContactMethodsPrimary": "主要的",
|
||||||
|
"accountContactMethodsVerified": "已驗證",
|
||||||
|
"accountContactMethodsPublic": "公開的",
|
||||||
|
"accountContactMethodsAdd": "添加聯繫方式",
|
||||||
|
"accountContactMethodsEdit": "編輯聯繫方式",
|
||||||
|
"accountContactMethodsAddDescription": "添加新的聯繫方式。",
|
||||||
|
"fieldContactContent": "聯繫方式",
|
||||||
|
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
|
||||||
|
"accountContactMethodsDelete": "刪除聯繫方式",
|
||||||
|
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
|
||||||
|
"postCommentAdd": "撰寫一條評論",
|
||||||
|
"translate": "翻譯",
|
||||||
|
"translating": "正在翻譯……",
|
||||||
|
"translated": "已翻譯",
|
||||||
|
"settingsAutoTranslate": "自動翻譯",
|
||||||
|
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
|
||||||
|
"trayMenuHide": "隱藏",
|
||||||
|
"accountSettingsNotify": "通知設置",
|
||||||
|
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
|
||||||
|
"accountSettingsSecurity": "安全設置",
|
||||||
|
"accountSettingsSecurityDescription": "調整你的帳戶安全設置。",
|
||||||
|
"save": "保存",
|
||||||
|
"notificationTopicPostFeedback": "帖子數據反饋",
|
||||||
|
"notificationTopicPostReply": "帖子回覆",
|
||||||
|
"notificationTopicPostSubscription": "帖子訂閱",
|
||||||
|
"notificationTopicMessaging": "消息",
|
||||||
|
"notificationTopicMessagingCall": "通話",
|
||||||
|
"notificationTopicGeneral": "雜項",
|
||||||
|
"authMaximumAuthSteps": "最大驗證步驟",
|
||||||
|
"authMaximumAuthStepsDescription": {
|
||||||
|
"one": "登入時最多要求 {} 步驗證",
|
||||||
|
"other": "登入時最多要求 {} 步驗證"
|
||||||
|
},
|
||||||
|
"authAlwaysRisky": "總是風險",
|
||||||
|
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
|
||||||
|
"chatUnjoined": "未加入頻道",
|
||||||
|
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
|
||||||
|
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
|
||||||
|
"chatJoin": "加入頻道",
|
||||||
|
"appInitStarting": "啟動中",
|
||||||
|
"appInitNetwork": "正在初始化網絡",
|
||||||
|
"appInitUserdata": "正在初始化用戶數據",
|
||||||
|
"appInitWebsocket": "正在建立 Solar Link",
|
||||||
|
"appInitNotification": "正在初始化推送通知",
|
||||||
|
"appInitKeyPair": "正在初始化密鑰對",
|
||||||
|
"appInitStickers": "正在初始化貼圖包",
|
||||||
|
"appInitUserDirectory": "正在初始化用戶目錄",
|
||||||
|
"appInitRealm": "正在初始化領域信息",
|
||||||
|
"appInitChat": "正在初始化聊天",
|
||||||
|
"appInitDone": "完成",
|
||||||
|
"community": "社區",
|
||||||
|
"realmCommunity": "{}的社區",
|
||||||
|
"postTotalCount": {
|
||||||
|
"zero": "沒有帖子",
|
||||||
|
"one": "共 {} 條帖子"
|
||||||
|
},
|
||||||
|
"settingsHideBottomNav": "隱藏底部導航欄",
|
||||||
|
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
|
||||||
|
"reCaptcha": "人機驗證",
|
||||||
|
"friends": "好友",
|
||||||
|
"friendsDescription": "管理好友關係。",
|
||||||
|
"album": "相冊",
|
||||||
|
"albumDescription": "查看相冊與管理上傳附件。",
|
||||||
|
"stickers": "貼圖",
|
||||||
|
"stickersDescription": "查看貼圖包與管理貼圖。",
|
||||||
|
"navBottomUnauthorizedCaption": "或者註冊一個賬號"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,7 @@ targets:
|
|||||||
options:
|
options:
|
||||||
explicit_to_json: true
|
explicit_to_json: true
|
||||||
field_rename: snake
|
field_rename: snake
|
||||||
|
drift_dev:
|
||||||
|
options:
|
||||||
|
databases:
|
||||||
|
my_database: lib/database/database.dart
|
||||||
1
drift_schemas/my_database/drift_schema_v1.json
Normal file
1
drift_schemas/my_database/drift_schema_v1.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}}]}
|
||||||
1
drift_schemas/my_database/drift_schema_v2.json
Normal file
1
drift_schemas/my_database/drift_schema_v2.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"sn_local_key_pair","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"account_id","getter_name":"accountId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"public_key","getter_name":"publicKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"private_key","getter_name":"privateKey","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_active","getter_name":"isActive","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_active\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_active\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}}]}
|
||||||
1
drift_schemas/my_database/drift_schema_v3.json
Normal file
1
drift_schemas/my_database/drift_schema_v3.json
Normal file
File diff suppressed because one or more lines are too long
1
drift_schemas/my_database/drift_schema_v4.json
Normal file
1
drift_schemas/my_database/drift_schema_v4.json
Normal file
File diff suppressed because one or more lines are too long
111
ios/Podfile.lock
111
ios/Podfile.lock
@@ -1,5 +1,7 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Alamofire (5.10.2)
|
- Alamofire (5.10.2)
|
||||||
|
- audioplayers_darwin (0.0.1):
|
||||||
|
- Flutter
|
||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- croppy (0.0.1):
|
- croppy (0.0.1):
|
||||||
@@ -37,6 +39,8 @@ PODS:
|
|||||||
- DKPhotoGallery/Resource (0.0.19):
|
- DKPhotoGallery/Resource (0.0.19):
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SwiftyGif
|
- SwiftyGif
|
||||||
|
- fast_rsa (0.6.0):
|
||||||
|
- Flutter
|
||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- DKImagePickerController/PhotoGallery
|
- DKImagePickerController/PhotoGallery
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -52,14 +56,14 @@ PODS:
|
|||||||
- Firebase/Messaging (11.8.0):
|
- Firebase/Messaging (11.8.0):
|
||||||
- Firebase/CoreOnly
|
- Firebase/CoreOnly
|
||||||
- FirebaseMessaging (~> 11.8.0)
|
- FirebaseMessaging (~> 11.8.0)
|
||||||
- firebase_analytics (11.4.3):
|
- firebase_analytics (11.4.4):
|
||||||
- Firebase/Analytics (= 11.8.0)
|
- Firebase/Analytics (= 11.8.0)
|
||||||
- firebase_core
|
- firebase_core
|
||||||
- Flutter
|
- Flutter
|
||||||
- firebase_core (3.12.0):
|
- firebase_core (3.12.1):
|
||||||
- Firebase/CoreOnly (= 11.8.0)
|
- Firebase/CoreOnly (= 11.8.0)
|
||||||
- Flutter
|
- Flutter
|
||||||
- firebase_messaging (15.2.3):
|
- firebase_messaging (15.2.4):
|
||||||
- Firebase/Messaging (= 11.8.0)
|
- Firebase/Messaging (= 11.8.0)
|
||||||
- firebase_core
|
- firebase_core
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -113,6 +117,8 @@ PODS:
|
|||||||
- OrderedSet (~> 6.0.3)
|
- OrderedSet (~> 6.0.3)
|
||||||
- flutter_native_splash (2.4.3):
|
- flutter_native_splash (2.4.3):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- flutter_timezone (0.0.1):
|
||||||
|
- Flutter
|
||||||
- flutter_udid (0.0.1):
|
- flutter_udid (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- SAMKeychain
|
- SAMKeychain
|
||||||
@@ -179,14 +185,12 @@ PODS:
|
|||||||
- in_app_review (2.0.0):
|
- in_app_review (2.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Kingfisher (8.2.0)
|
- Kingfisher (8.2.0)
|
||||||
- livekit_client (2.4.0):
|
- livekit_client (2.4.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- flutter_webrtc
|
- flutter_webrtc
|
||||||
- WebRTC-SDK (= 125.6422.06)
|
- WebRTC-SDK (= 125.6422.06)
|
||||||
- media_kit_libs_ios_video (1.0.4):
|
- media_kit_libs_ios_video (1.0.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
- media_kit_native_event_loop (1.0.0):
|
|
||||||
- Flutter
|
|
||||||
- media_kit_video (0.0.1):
|
- media_kit_video (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- nanopb (3.30910.0):
|
- nanopb (3.30910.0):
|
||||||
@@ -208,8 +212,6 @@ PODS:
|
|||||||
- receive_sharing_intent (1.8.1):
|
- receive_sharing_intent (1.8.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- SAMKeychain (1.5.3)
|
- SAMKeychain (1.5.3)
|
||||||
- screen_brightness_ios (0.1.0):
|
|
||||||
- Flutter
|
|
||||||
- SDWebImage (5.20.1):
|
- SDWebImage (5.20.1):
|
||||||
- SDWebImage/Core (= 5.20.1)
|
- SDWebImage/Core (= 5.20.1)
|
||||||
- SDWebImage/Core (5.20.1)
|
- SDWebImage/Core (5.20.1)
|
||||||
@@ -228,6 +230,8 @@ PODS:
|
|||||||
- sqlite3/common
|
- sqlite3/common
|
||||||
- sqlite3/fts5 (3.49.1):
|
- sqlite3/fts5 (3.49.1):
|
||||||
- sqlite3/common
|
- sqlite3/common
|
||||||
|
- sqlite3/math (3.49.1):
|
||||||
|
- sqlite3/common
|
||||||
- sqlite3/perf-threadsafe (3.49.1):
|
- sqlite3/perf-threadsafe (3.49.1):
|
||||||
- sqlite3/common
|
- sqlite3/common
|
||||||
- sqlite3/rtree (3.49.1):
|
- sqlite3/rtree (3.49.1):
|
||||||
@@ -235,9 +239,10 @@ PODS:
|
|||||||
- sqlite3_flutter_libs (0.0.1):
|
- sqlite3_flutter_libs (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- sqlite3 (~> 3.49.0)
|
- sqlite3 (~> 3.49.1)
|
||||||
- sqlite3/dbstatvtab
|
- sqlite3/dbstatvtab
|
||||||
- sqlite3/fts5
|
- sqlite3/fts5
|
||||||
|
- sqlite3/math
|
||||||
- sqlite3/perf-threadsafe
|
- sqlite3/perf-threadsafe
|
||||||
- sqlite3/rtree
|
- sqlite3/rtree
|
||||||
- SwiftyGif (5.4.5)
|
- SwiftyGif (5.4.5)
|
||||||
@@ -255,9 +260,11 @@ PODS:
|
|||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- Alamofire
|
- Alamofire
|
||||||
|
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`)
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- croppy (from `.symlinks/plugins/croppy/ios`)
|
- croppy (from `.symlinks/plugins/croppy/ios`)
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
|
- fast_rsa (from `.symlinks/plugins/fast_rsa/ios`)
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- file_saver (from `.symlinks/plugins/file_saver/ios`)
|
- file_saver (from `.symlinks/plugins/file_saver/ios`)
|
||||||
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
|
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
|
||||||
@@ -267,6 +274,7 @@ DEPENDENCIES:
|
|||||||
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
|
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
|
||||||
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
||||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||||
|
- flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`)
|
||||||
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
||||||
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
|
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
|
||||||
- gal (from `.symlinks/plugins/gal/darwin`)
|
- gal (from `.symlinks/plugins/gal/darwin`)
|
||||||
@@ -276,14 +284,12 @@ DEPENDENCIES:
|
|||||||
- Kingfisher (~> 8.0)
|
- Kingfisher (~> 8.0)
|
||||||
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
|
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
|
||||||
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
||||||
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
|
|
||||||
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
|
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||||
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
|
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
@@ -319,12 +325,16 @@ SPEC REPOS:
|
|||||||
- WebRTC-SDK
|
- WebRTC-SDK
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
audioplayers_darwin:
|
||||||
|
:path: ".symlinks/plugins/audioplayers_darwin/ios"
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
croppy:
|
croppy:
|
||||||
:path: ".symlinks/plugins/croppy/ios"
|
:path: ".symlinks/plugins/croppy/ios"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||||
|
fast_rsa:
|
||||||
|
:path: ".symlinks/plugins/fast_rsa/ios"
|
||||||
file_picker:
|
file_picker:
|
||||||
:path: ".symlinks/plugins/file_picker/ios"
|
:path: ".symlinks/plugins/file_picker/ios"
|
||||||
file_saver:
|
file_saver:
|
||||||
@@ -343,6 +353,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
|
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
|
||||||
flutter_native_splash:
|
flutter_native_splash:
|
||||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||||
|
flutter_timezone:
|
||||||
|
:path: ".symlinks/plugins/flutter_timezone/ios"
|
||||||
flutter_udid:
|
flutter_udid:
|
||||||
:path: ".symlinks/plugins/flutter_udid/ios"
|
:path: ".symlinks/plugins/flutter_udid/ios"
|
||||||
flutter_webrtc:
|
flutter_webrtc:
|
||||||
@@ -359,8 +371,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/livekit_client/ios"
|
:path: ".symlinks/plugins/livekit_client/ios"
|
||||||
media_kit_libs_ios_video:
|
media_kit_libs_ios_video:
|
||||||
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
|
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
|
||||||
media_kit_native_event_loop:
|
|
||||||
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
|
|
||||||
media_kit_video:
|
media_kit_video:
|
||||||
:path: ".symlinks/plugins/media_kit_video/ios"
|
:path: ".symlinks/plugins/media_kit_video/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
@@ -373,8 +383,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
receive_sharing_intent:
|
receive_sharing_intent:
|
||||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||||
screen_brightness_ios:
|
|
||||||
:path: ".symlinks/plugins/screen_brightness_ios/ios"
|
|
||||||
share_plus:
|
share_plus:
|
||||||
:path: ".symlinks/plugins/share_plus/ios"
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
@@ -396,63 +404,64 @@ EXTERNAL SOURCES:
|
|||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||||
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
audioplayers_darwin: ccf9c770ee768abb07e26d90af093f7bab1c12ab
|
||||||
croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
|
||||||
|
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
fast_rsa: d99f8e1809a4a312fa9216d830186869b2e9eb65
|
||||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||||
|
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||||
Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf
|
Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf
|
||||||
firebase_analytics: 7ec1166af61987fa968766eb11587c562a5650ee
|
firebase_analytics: 4e93dbe66872104d28ae9750fec1800e1fd66858
|
||||||
firebase_core: 6e223dfa350b2edceb729cea505eaaef59330682
|
firebase_core: 8d552814f6c01ccde5d88939fced4ec26f2f5510
|
||||||
firebase_messaging: 07fde77ae28c08616a1d4d870450efc2b38cf40d
|
firebase_messaging: 8b96a4f09841c15a16b96973ef5c3dcfc1a064e4
|
||||||
FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b
|
FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b
|
||||||
FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d
|
FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d
|
||||||
FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629
|
FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629
|
||||||
FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917
|
FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917
|
||||||
FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8
|
FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8
|
||||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||||
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
|
flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
|
||||||
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
|
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
|
||||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||||
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
|
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
|
||||||
flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
|
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
|
||||||
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
|
flutter_webrtc: 57f32415b8744e806f9c2a96ccdb60c6a627ba33
|
||||||
|
gal: baecd024ebfd13c441269ca7404792a7152fde89
|
||||||
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
|
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
|
||||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||||
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
||||||
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
|
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||||
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
|
in_app_review: 5596fe56fab799e8edb3561c03d053363ab13457
|
||||||
Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
|
Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
|
||||||
livekit_client: 9819ebc8be8ef00ed0fae7d806bf8938ec689573
|
livekit_client: 08755cabfa4da4ed455642f460cfbb39bc518070
|
||||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||||
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
|
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
|
||||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
|
pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
|
||||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
|
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||||
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
|
|
||||||
SDWebImage: 33d0f23bddeb5d209ae959153883247be6703713
|
SDWebImage: 33d0f23bddeb5d209ae959153883247be6703713
|
||||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
|
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
|
||||||
sqlite3_flutter_libs: 069c435986dd4b63461aecd68f4b30be4a9e9daa
|
sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||||
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
|
video_compress: f2133a07762889d67f0711ac831faa26f956980e
|
||||||
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
|
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
|
||||||
wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
|
wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
|
||||||
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
|
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
|
||||||
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e
|
||||||
|
|
||||||
PODFILE CHECKSUM: 9b244e02f87527430136c8d21cbdcf1cd586b6bc
|
PODFILE CHECKSUM: 9b244e02f87527430136c8d21cbdcf1cd586b6bc
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,8 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
@@ -8,7 +7,10 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:surface/database/database.dart';
|
import 'package:surface/database/database.dart';
|
||||||
|
import 'package:surface/logger.dart';
|
||||||
|
import 'package:surface/providers/channel.dart';
|
||||||
import 'package:surface/providers/database.dart';
|
import 'package:surface/providers/database.dart';
|
||||||
|
import 'package:surface/providers/keypair.dart';
|
||||||
import 'package:surface/providers/sn_attachment.dart';
|
import 'package:surface/providers/sn_attachment.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/providers/user_directory.dart';
|
import 'package:surface/providers/user_directory.dart';
|
||||||
@@ -25,6 +27,8 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
late final WebSocketProvider _ws;
|
late final WebSocketProvider _ws;
|
||||||
late final SnAttachmentProvider _attach;
|
late final SnAttachmentProvider _attach;
|
||||||
late final DatabaseProvider _dt;
|
late final DatabaseProvider _dt;
|
||||||
|
late final ChatChannelProvider _ct;
|
||||||
|
late final KeyPairProvider _kp;
|
||||||
|
|
||||||
StreamSubscription? _wsSubscription;
|
StreamSubscription? _wsSubscription;
|
||||||
|
|
||||||
@@ -33,11 +37,14 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
_ud = context.read<UserDirectoryProvider>();
|
_ud = context.read<UserDirectoryProvider>();
|
||||||
_ws = context.read<WebSocketProvider>();
|
_ws = context.read<WebSocketProvider>();
|
||||||
_attach = context.read<SnAttachmentProvider>();
|
_attach = context.read<SnAttachmentProvider>();
|
||||||
|
_ct = context.read<ChatChannelProvider>();
|
||||||
_dt = context.read<DatabaseProvider>();
|
_dt = context.read<DatabaseProvider>();
|
||||||
|
_kp = context.read<KeyPairProvider>();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isPending = true;
|
bool isPending = true;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
|
bool isAggressiveLoading = false;
|
||||||
|
|
||||||
int? messageTotal;
|
int? messageTotal;
|
||||||
|
|
||||||
@@ -61,10 +68,7 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
channel = chan;
|
channel = chan;
|
||||||
|
|
||||||
// Fetch channel profile
|
// Fetch channel profile
|
||||||
final resp = await _sn.client.get(
|
profile = await _ct.getChannelProfile(channel!);
|
||||||
'/cgi/im/channels/${chan.keyPath}/me',
|
|
||||||
);
|
|
||||||
profile = SnChannelMember.fromJson(resp.data);
|
|
||||||
|
|
||||||
_wsSubscription = _ws.pk.stream.listen((event) {
|
_wsSubscription = _ws.pk.stream.listen((event) {
|
||||||
switch (event.method) {
|
switch (event.method) {
|
||||||
@@ -183,6 +187,7 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
messages.insert(0, message);
|
messages.insert(0, message);
|
||||||
}
|
}
|
||||||
|
notifyListeners();
|
||||||
await _applyMessage(message);
|
await _applyMessage(message);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
@@ -243,6 +248,24 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> _encodeMessageBody(
|
||||||
|
String text,
|
||||||
|
bool isEncrypted,
|
||||||
|
) async {
|
||||||
|
if (!isEncrypted || _kp.activeKp == null) {
|
||||||
|
return {
|
||||||
|
'text': text,
|
||||||
|
'algorithm': 'plain',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
'text': await _kp.encryptText(text),
|
||||||
|
'algorithm': 'rsa',
|
||||||
|
'keypair_id': _kp.activeKp!.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> sendMessage(
|
Future<void> sendMessage(
|
||||||
String type,
|
String type,
|
||||||
String content, {
|
String content, {
|
||||||
@@ -250,13 +273,13 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
int? relatedId,
|
int? relatedId,
|
||||||
List<String>? attachments,
|
List<String>? attachments,
|
||||||
SnChatMessage? editingMessage,
|
SnChatMessage? editingMessage,
|
||||||
|
bool isEncrypted = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (channel == null) return;
|
if (channel == null) return;
|
||||||
const uuid = Uuid();
|
const uuid = Uuid();
|
||||||
final nonce = uuid.v4();
|
final nonce = uuid.v4();
|
||||||
final body = {
|
final body = {
|
||||||
'text': content,
|
...(await _encodeMessageBody(content, isEncrypted)),
|
||||||
'algorithm': 'plain',
|
|
||||||
if (quoteId != null) 'quote_event': quoteId,
|
if (quoteId != null) 'quote_event': quoteId,
|
||||||
if (relatedId != null) 'related_event': relatedId,
|
if (relatedId != null) 'related_event': relatedId,
|
||||||
if (attachments != null && attachments.isNotEmpty)
|
if (attachments != null && attachments.isNotEmpty)
|
||||||
@@ -264,23 +287,26 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Mock the message locally
|
// Mock the message locally
|
||||||
final createdAt = DateTime.now();
|
// Do not mock the editing message
|
||||||
final message = SnChatMessage(
|
if (editingMessage == null) {
|
||||||
id: 0,
|
final createdAt = DateTime.now();
|
||||||
createdAt: createdAt,
|
final message = SnChatMessage(
|
||||||
updatedAt: createdAt,
|
id: 0,
|
||||||
deletedAt: null,
|
createdAt: createdAt,
|
||||||
uuid: nonce,
|
updatedAt: createdAt,
|
||||||
body: body,
|
deletedAt: null,
|
||||||
type: type,
|
uuid: nonce,
|
||||||
channel: channel!,
|
body: body,
|
||||||
channelId: channel!.id,
|
type: type,
|
||||||
sender: profile!,
|
channel: channel!,
|
||||||
senderId: profile!.id,
|
channelId: channel!.id,
|
||||||
quoteEventId: quoteId,
|
sender: profile!,
|
||||||
relatedEventId: relatedId,
|
senderId: profile!.id,
|
||||||
);
|
quoteEventId: quoteId,
|
||||||
_addUnconfirmedMessage(message);
|
relatedEventId: relatedId,
|
||||||
|
);
|
||||||
|
_addUnconfirmedMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
@@ -320,7 +346,7 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
/// Check the local storage is up to date with the server.
|
/// Check the local storage is up to date with the server.
|
||||||
/// If the local storage is not up to date, it will be updated.
|
/// If the local storage is not up to date, it will be updated.
|
||||||
Future<void> checkUpdate() async {
|
Future<void> checkUpdate() async {
|
||||||
isLoading = true;
|
isAggressiveLoading = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
final mostRecentMessage = await (_dt.db.snLocalChatMessage.select()
|
final mostRecentMessage = await (_dt.db.snLocalChatMessage.select()
|
||||||
@@ -334,6 +360,7 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
if (mostRecentMessage == null) {
|
if (mostRecentMessage == null) {
|
||||||
// Initial load
|
// Initial load
|
||||||
await loadMessages(take: 20);
|
await loadMessages(take: 20);
|
||||||
|
isAggressiveLoading = false;
|
||||||
isCheckedUpdate = true;
|
isCheckedUpdate = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -351,13 +378,19 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
final countToFetch = math.min(resp.data['count'] as int, 100);
|
final countToFetch = math.min(resp.data['count'] as int, 100);
|
||||||
|
|
||||||
for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) {
|
for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) {
|
||||||
await getMessages(kSingleBatchLoadLimit, idx, forceRemote: true);
|
final out = await getMessages(
|
||||||
|
kSingleBatchLoadLimit,
|
||||||
|
idx,
|
||||||
|
forceRemote: true,
|
||||||
|
);
|
||||||
|
messages.insertAll(0, out);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
rethrow;
|
rethrow;
|
||||||
} finally {
|
} finally {
|
||||||
await loadMessages();
|
await loadMessages();
|
||||||
isLoading = false;
|
isAggressiveLoading = false;
|
||||||
|
|
||||||
isCheckedUpdate = true;
|
isCheckedUpdate = true;
|
||||||
_saveMessageToLocal(incomeStrandedQueue).then((_) {
|
_saveMessageToLocal(incomeStrandedQueue).then((_) {
|
||||||
@@ -532,7 +565,7 @@ class ChatMessageController extends ChangeNotifier {
|
|||||||
},
|
},
|
||||||
).toJson(),
|
).toJson(),
|
||||||
));
|
));
|
||||||
log('[Messaging] Send read event request: $_readEventAnchor');
|
logging.debug('[Messaging] Send read event request: $_readEventAnchor');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ class PostWriteMedia {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file});
|
PostWriteMedia.fromBytes(this.raw, this.name, this.type,
|
||||||
|
{this.attachment, this.file});
|
||||||
|
|
||||||
bool get isEmpty => attachment == null && file == null && raw == null;
|
bool get isEmpty => attachment == null && file == null && raw == null;
|
||||||
|
|
||||||
@@ -105,7 +106,8 @@ class PostWriteMedia {
|
|||||||
}) {
|
}) {
|
||||||
if (attachment != null) {
|
if (attachment != null) {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
|
final ImageProvider provider =
|
||||||
|
UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
|
||||||
if (width != null && height != null && !kIsWeb) {
|
if (width != null && height != null && !kIsWeb) {
|
||||||
return ResizeImage(
|
return ResizeImage(
|
||||||
provider,
|
provider,
|
||||||
@@ -116,7 +118,8 @@ class PostWriteMedia {
|
|||||||
}
|
}
|
||||||
return provider;
|
return provider;
|
||||||
} else if (file != null) {
|
} else if (file != null) {
|
||||||
final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
|
final ImageProvider provider =
|
||||||
|
kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
|
||||||
if (width != null && height != null) {
|
if (width != null && height != null) {
|
||||||
return ResizeImage(
|
return ResizeImage(
|
||||||
provider,
|
provider,
|
||||||
@@ -159,11 +162,14 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
final TextEditingController aliasController = TextEditingController();
|
final TextEditingController aliasController = TextEditingController();
|
||||||
final TextEditingController rewardController = TextEditingController();
|
final TextEditingController rewardController = TextEditingController();
|
||||||
|
|
||||||
ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration(
|
ContentInsertionConfiguration get contentInsertionConfiguration =>
|
||||||
|
ContentInsertionConfiguration(
|
||||||
onContentInserted: (KeyboardInsertedContent content) {
|
onContentInserted: (KeyboardInsertedContent content) {
|
||||||
if (content.hasData) {
|
if (content.hasData) {
|
||||||
addAttachments(
|
addAttachments([
|
||||||
[PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]);
|
PostWriteMedia.fromBytes(content.data!,
|
||||||
|
'attachmentInsertedImage'.tr(), SnMediaType.image)
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -193,7 +199,8 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
|
|
||||||
String get description => descriptionController.text;
|
String get description => descriptionController.text;
|
||||||
|
|
||||||
bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
|
bool get isRelatedNull =>
|
||||||
|
![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
|
||||||
|
|
||||||
bool isLoading = false, isBusy = false;
|
bool isLoading = false, isBusy = false;
|
||||||
double? progress;
|
double? progress;
|
||||||
@@ -201,6 +208,7 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
SnRealm? realm;
|
SnRealm? realm;
|
||||||
SnPublisher? publisher;
|
SnPublisher? publisher;
|
||||||
SnPost? editingPost, repostingPost, replyingPost;
|
SnPost? editingPost, repostingPost, replyingPost;
|
||||||
|
bool editingDraft = false;
|
||||||
|
|
||||||
int visibility = 0;
|
int visibility = 0;
|
||||||
List<int> visibleUsers = List.empty();
|
List<int> visibleUsers = List.empty();
|
||||||
@@ -237,14 +245,20 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
publishedAt = post.publishedAt;
|
publishedAt = post.publishedAt;
|
||||||
publishedUntil = post.publishedUntil;
|
publishedUntil = post.publishedUntil;
|
||||||
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
|
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
|
||||||
invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true);
|
invisibleUsers =
|
||||||
|
List.from(post.invisibleUsersList ?? [], growable: true);
|
||||||
visibility = post.visibility;
|
visibility = post.visibility;
|
||||||
tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
|
tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
|
||||||
categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
|
categories =
|
||||||
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
|
List.from(post.categories.map((ele) => ele.alias), growable: true);
|
||||||
|
attachments.addAll(
|
||||||
|
post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
|
||||||
poll = post.preload?.poll;
|
poll = post.preload?.poll;
|
||||||
|
|
||||||
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
|
editingDraft = post.isDraft;
|
||||||
|
|
||||||
|
if (post.preload?.thumbnail != null &&
|
||||||
|
(post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
|
||||||
thumbnail = PostWriteMedia(post.preload!.thumbnail);
|
thumbnail = PostWriteMedia(post.preload!.thumbnail);
|
||||||
}
|
}
|
||||||
if (post.preload?.realm != null) {
|
if (post.preload?.realm != null) {
|
||||||
@@ -272,7 +286,8 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media,
|
Future<SnAttachment> _uploadAttachment(
|
||||||
|
BuildContext context, PostWriteMedia media,
|
||||||
{bool isCompressed = false}) async {
|
{bool isCompressed = false}) async {
|
||||||
final attach = context.read<SnAttachmentProvider>();
|
final attach = context.read<SnAttachmentProvider>();
|
||||||
|
|
||||||
@@ -281,7 +296,9 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
media.name,
|
media.name,
|
||||||
'interactive',
|
'interactive',
|
||||||
null,
|
null,
|
||||||
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
|
mimetype: media.raw != null && media.type == SnMediaType.image
|
||||||
|
? 'image/png'
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
var item = await attach.chunkedUploadParts(
|
var item = await attach.chunkedUploadParts(
|
||||||
@@ -297,9 +314,11 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
|
|
||||||
if (media.type == SnMediaType.video && !isCompressed && context.mounted) {
|
if (media.type == SnMediaType.video && !isCompressed && context.mounted) {
|
||||||
try {
|
try {
|
||||||
final compressedAttachment = await _tryCompressVideoCopy(context, media);
|
final compressedAttachment =
|
||||||
|
await _tryCompressVideoCopy(context, media);
|
||||||
if (compressedAttachment != null) {
|
if (compressedAttachment != null) {
|
||||||
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
|
item = await attach.updateOne(item,
|
||||||
|
compressedId: compressedAttachment.id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (context.mounted) context.showErrorDialog(err);
|
if (context.mounted) context.showErrorDialog(err);
|
||||||
@@ -309,8 +328,10 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async {
|
Future<SnAttachment?> _tryCompressVideoCopy(
|
||||||
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null;
|
BuildContext context, PostWriteMedia media) async {
|
||||||
|
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS))
|
||||||
|
return null;
|
||||||
if (media.type != SnMediaType.video) return null;
|
if (media.type != SnMediaType.video) return null;
|
||||||
if (media.file == null) return null;
|
if (media.file == null) return null;
|
||||||
if (VideoCompress.isCompressing) return null;
|
if (VideoCompress.isCompressing) return null;
|
||||||
@@ -334,7 +355,8 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
if (!context.mounted) return null;
|
if (!context.mounted) return null;
|
||||||
|
|
||||||
final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!));
|
final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!));
|
||||||
final compressedAttachment = await _uploadAttachment(context, compressedMedia, isCompressed: true);
|
final compressedAttachment =
|
||||||
|
await _uploadAttachment(context, compressedMedia, isCompressed: true);
|
||||||
|
|
||||||
return compressedAttachment;
|
return compressedAttachment;
|
||||||
}
|
}
|
||||||
@@ -370,18 +392,25 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
'content': contentController.text,
|
'content': contentController.text,
|
||||||
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
|
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
|
||||||
if (titleController.text.isNotEmpty) 'title': titleController.text,
|
if (titleController.text.isNotEmpty) 'title': titleController.text,
|
||||||
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
|
if (descriptionController.text.isNotEmpty)
|
||||||
|
'description': descriptionController.text,
|
||||||
if (rewardController.text.isNotEmpty) 'reward': rewardController.text,
|
if (rewardController.text.isNotEmpty) 'reward': rewardController.text,
|
||||||
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
|
if (thumbnail != null && thumbnail!.attachment != null)
|
||||||
'attachments':
|
'thumbnail': thumbnail!.attachment!.toJson(),
|
||||||
attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true),
|
'attachments': attachments
|
||||||
|
.where((e) => e.attachment != null)
|
||||||
|
.map((e) => e.attachment!.toJson())
|
||||||
|
.toList(growable: true),
|
||||||
'tags': tags.map((ele) => {'alias': ele}).toList(growable: true),
|
'tags': tags.map((ele) => {'alias': ele}).toList(growable: true),
|
||||||
'categories': categories.map((ele) => {'alias': ele}).toList(growable: true),
|
'categories':
|
||||||
|
categories.map((ele) => {'alias': ele}).toList(growable: true),
|
||||||
'visibility': visibility,
|
'visibility': visibility,
|
||||||
'visible_users_list': visibleUsers,
|
'visible_users_list': visibleUsers,
|
||||||
'invisible_users_list': invisibleUsers,
|
'invisible_users_list': invisibleUsers,
|
||||||
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
|
if (publishedAt != null)
|
||||||
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
|
'published_at': publishedAt!.toUtc().toIso8601String(),
|
||||||
|
if (publishedUntil != null)
|
||||||
|
'published_until': publishedAt!.toUtc().toIso8601String(),
|
||||||
if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
|
if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
|
||||||
if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
|
if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
|
||||||
if (poll != null) 'poll': poll!.toJson(),
|
if (poll != null) 'poll': poll!.toJson(),
|
||||||
@@ -391,6 +420,12 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get isNotEmpty =>
|
||||||
|
title.isNotEmpty ||
|
||||||
|
description.isNotEmpty ||
|
||||||
|
contentController.text.isNotEmpty ||
|
||||||
|
attachments.isNotEmpty;
|
||||||
|
|
||||||
bool temporaryRestored = false;
|
bool temporaryRestored = false;
|
||||||
|
|
||||||
void _temporaryLoad() {
|
void _temporaryLoad() {
|
||||||
@@ -403,18 +438,24 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
titleController.text = data['title'] ?? '';
|
titleController.text = data['title'] ?? '';
|
||||||
descriptionController.text = data['description'] ?? '';
|
descriptionController.text = data['description'] ?? '';
|
||||||
rewardController.text = data['reward']?.toString() ?? '';
|
rewardController.text = data['reward']?.toString() ?? '';
|
||||||
if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
|
if (data['thumbnail'] != null)
|
||||||
attachments
|
thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
|
||||||
.addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
|
attachments.addAll(data['attachments']
|
||||||
|
.map((ele) => PostWriteMedia(SnAttachment.fromJson(ele)))
|
||||||
|
.cast<PostWriteMedia>());
|
||||||
tags = List.from(data['tags'].map((ele) => ele['alias']));
|
tags = List.from(data['tags'].map((ele) => ele['alias']));
|
||||||
categories = List.from(data['categories'].map((ele) => ele['alias']));
|
categories = List.from(data['categories'].map((ele) => ele['alias']));
|
||||||
visibility = data['visibility'];
|
visibility = data['visibility'];
|
||||||
visibleUsers = List.from(data['visible_users_list'] ?? []);
|
visibleUsers = List.from(data['visible_users_list'] ?? []);
|
||||||
invisibleUsers = List.from(data['invisible_users_list'] ?? []);
|
invisibleUsers = List.from(data['invisible_users_list'] ?? []);
|
||||||
if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
|
if (data['published_at'] != null)
|
||||||
if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
|
publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
|
||||||
replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
|
if (data['published_until'] != null)
|
||||||
repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
|
publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
|
||||||
|
replyingPost =
|
||||||
|
data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
|
||||||
|
repostingPost =
|
||||||
|
data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
|
||||||
poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null;
|
poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null;
|
||||||
realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null;
|
realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null;
|
||||||
temporaryRestored = true;
|
temporaryRestored = true;
|
||||||
@@ -436,7 +477,10 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendPost(BuildContext context) async {
|
Future<void> sendPost(
|
||||||
|
BuildContext context, {
|
||||||
|
bool saveAsDraft = false,
|
||||||
|
}) async {
|
||||||
if (isBusy || publisher == null) return;
|
if (isBusy || publisher == null) return;
|
||||||
|
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
@@ -463,7 +507,9 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
media.name,
|
media.name,
|
||||||
'interactive',
|
'interactive',
|
||||||
null,
|
null,
|
||||||
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
|
mimetype: media.raw != null && media.type == SnMediaType.image
|
||||||
|
? 'image/png'
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
var item = await attach.chunkedUploadParts(
|
var item = await attach.chunkedUploadParts(
|
||||||
@@ -472,16 +518,20 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
place.$2,
|
place.$2,
|
||||||
onProgress: (value) {
|
onProgress: (value) {
|
||||||
// Calculate overall progress for attachments
|
// Calculate overall progress for attachments
|
||||||
progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value);
|
progress = math.max(
|
||||||
|
((i + value) / attachments.length) * kAttachmentProgressWeight,
|
||||||
|
value);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
final compressedAttachment = await _tryCompressVideoCopy(context, media);
|
final compressedAttachment =
|
||||||
|
await _tryCompressVideoCopy(context, media);
|
||||||
if (compressedAttachment != null) {
|
if (compressedAttachment != null) {
|
||||||
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
|
item = await attach.updateOne(item,
|
||||||
|
compressedId: compressedAttachment.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -508,7 +558,7 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
// Posting the content
|
// Posting the content
|
||||||
try {
|
try {
|
||||||
final baseProgressVal = progress!;
|
final baseProgressVal = progress!;
|
||||||
await sn.client.request(
|
final resp = await sn.client.request(
|
||||||
[
|
[
|
||||||
'/cgi/co/$mode',
|
'/cgi/co/$mode',
|
||||||
if (editingPost != null) '${editingPost!.id}',
|
if (editingPost != null) '${editingPost!.id}',
|
||||||
@@ -518,36 +568,56 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
'content': contentController.text,
|
'content': contentController.text,
|
||||||
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
|
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
|
||||||
if (titleController.text.isNotEmpty) 'title': titleController.text,
|
if (titleController.text.isNotEmpty) 'title': titleController.text,
|
||||||
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
|
if (descriptionController.text.isNotEmpty)
|
||||||
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
|
'description': descriptionController.text,
|
||||||
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
|
if (thumbnail != null && thumbnail!.attachment != null)
|
||||||
|
'thumbnail': thumbnail!.attachment!.rid,
|
||||||
|
'attachments': attachments
|
||||||
|
.where((e) => e.attachment != null)
|
||||||
|
.map((e) => e.attachment!.rid)
|
||||||
|
.toList(),
|
||||||
'tags': tags.map((ele) => {'alias': ele}).toList(),
|
'tags': tags.map((ele) => {'alias': ele}).toList(),
|
||||||
'categories': categories.map((ele) => {'alias': ele}).toList(),
|
'categories': categories.map((ele) => {'alias': ele}).toList(),
|
||||||
'visibility': visibility,
|
'visibility': visibility,
|
||||||
'visible_users_list': visibleUsers,
|
'visible_users_list': visibleUsers,
|
||||||
'invisible_users_list': invisibleUsers,
|
'invisible_users_list': invisibleUsers,
|
||||||
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
|
if (publishedAt != null)
|
||||||
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
|
'published_at': publishedAt!.toUtc().toIso8601String(),
|
||||||
|
if (publishedUntil != null)
|
||||||
|
'published_until': publishedAt!.toUtc().toIso8601String(),
|
||||||
if (replyingPost != null) 'reply_to': replyingPost!.id,
|
if (replyingPost != null) 'reply_to': replyingPost!.id,
|
||||||
if (repostingPost != null) 'repost_to': repostingPost!.id,
|
if (repostingPost != null) 'repost_to': repostingPost!.id,
|
||||||
if (reward != null) 'reward': reward,
|
if (reward != null) 'reward': reward,
|
||||||
if (videoAttachment != null) 'video': videoAttachment!.rid,
|
if (videoAttachment != null) 'video': videoAttachment!.rid,
|
||||||
if (poll != null) 'poll': poll!.id,
|
if (poll != null) 'poll': poll!.id,
|
||||||
if (realm != null) 'realm': realm!.id,
|
if (realm != null) 'realm': realm!.id,
|
||||||
|
'is_draft': saveAsDraft,
|
||||||
},
|
},
|
||||||
onSendProgress: (count, total) {
|
onSendProgress: (count, total) {
|
||||||
progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
|
progress =
|
||||||
|
baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
},
|
},
|
||||||
onReceiveProgress: (count, total) {
|
onReceiveProgress: (count, total) {
|
||||||
progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2);
|
progress = baseProgressVal +
|
||||||
|
(kPostingProgressWeight / 2) +
|
||||||
|
(count / total) * (kPostingProgressWeight / 2);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
},
|
},
|
||||||
options: Options(
|
options: Options(
|
||||||
method: editingPost != null ? 'PUT' : 'POST',
|
method: editingPost != null ? 'PUT' : 'POST',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
reset();
|
if (saveAsDraft) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
editingDraft = true;
|
||||||
|
final out = SnPost.fromJson(resp.data);
|
||||||
|
final pt = context.read<SnPostContentProvider>();
|
||||||
|
editingPost = await pt.completePostData(out);
|
||||||
|
notifyListeners();
|
||||||
|
} else {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -683,7 +753,8 @@ class PostWriteController extends ChangeNotifier {
|
|||||||
repostingPost = null;
|
repostingPost = null;
|
||||||
mode = kTitleMap.keys.first;
|
mode = kTitleMap.keys.first;
|
||||||
temporaryRestored = false;
|
temporaryRestored = false;
|
||||||
SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey));
|
SharedPreferences.getInstance()
|
||||||
|
.then((prefs) => prefs.remove(kTemporaryStorageKey));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
lib/database/account.dart
Normal file
42
lib/database/account.dart
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:surface/types/account.dart';
|
||||||
|
|
||||||
|
class SnAccountConverter extends TypeConverter<SnAccount, String>
|
||||||
|
with JsonTypeConverter2<SnAccount, String, Map<String, Object?>> {
|
||||||
|
const SnAccountConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnAccount fromSql(String fromDb) {
|
||||||
|
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(SnAccount value) {
|
||||||
|
return jsonEncode(toJson(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnAccount fromJson(Map<String, Object?> json) {
|
||||||
|
return SnAccount.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson(SnAccount value) {
|
||||||
|
return value.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TableIndex(name: 'idx_account_name', columns: {#name})
|
||||||
|
class SnLocalAccount extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
TextColumn get name => text()();
|
||||||
|
|
||||||
|
TextColumn get content => text().map(const SnAccountConverter())();
|
||||||
|
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
|
||||||
|
DateTimeColumn get cacheExpiredAt => dateTime()();
|
||||||
|
}
|
||||||
47
lib/database/attachment.dart
Normal file
47
lib/database/attachment.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:surface/types/attachment.dart';
|
||||||
|
|
||||||
|
class SnAttachmentConverter extends TypeConverter<SnAttachment, String>
|
||||||
|
with JsonTypeConverter2<SnAttachment, String, Map<String, Object?>> {
|
||||||
|
const SnAttachmentConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnAttachment fromSql(String fromDb) {
|
||||||
|
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(SnAttachment value) {
|
||||||
|
return jsonEncode(toJson(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnAttachment fromJson(Map<String, Object?> json) {
|
||||||
|
return SnAttachment.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson(SnAttachment value) {
|
||||||
|
return value.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TableIndex(name: 'idx_attachment_rid', columns: {#rid})
|
||||||
|
@TableIndex(name: 'idx_attachment_account', columns: {#accountId})
|
||||||
|
class SnLocalAttachment extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
TextColumn get rid => text().unique()();
|
||||||
|
|
||||||
|
TextColumn get uuid => text().unique()();
|
||||||
|
|
||||||
|
TextColumn get content => text().map(const SnAttachmentConverter())();
|
||||||
|
|
||||||
|
IntColumn get accountId => integer()();
|
||||||
|
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
|
||||||
|
DateTimeColumn get cacheExpiredAt => dateTime()();
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ class SnChannelConverter extends TypeConverter<SnChannel, String>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@TableIndex(name: 'idx_channel_alias', columns: {#alias})
|
||||||
class SnLocalChatChannel extends Table {
|
class SnLocalChatChannel extends Table {
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
@@ -63,12 +64,54 @@ class SnMessageConverter extends TypeConverter<SnChatMessage, String>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@TableIndex(name: 'idx_chat_channel', columns: {#channelId})
|
||||||
class SnLocalChatMessage extends Table {
|
class SnLocalChatMessage extends Table {
|
||||||
IntColumn get id => integer().autoIncrement()();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
IntColumn get channelId => integer()();
|
IntColumn get channelId => integer()();
|
||||||
|
|
||||||
|
IntColumn get senderId => integer().nullable()();
|
||||||
|
|
||||||
TextColumn get content => text().map(const SnMessageConverter())();
|
TextColumn get content => text().map(const SnMessageConverter())();
|
||||||
|
|
||||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SnChannelMemberConverter extends TypeConverter<SnChannelMember, String>
|
||||||
|
with JsonTypeConverter2<SnChannelMember, String, Map<String, Object?>> {
|
||||||
|
const SnChannelMemberConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnChannelMember fromSql(String fromDb) {
|
||||||
|
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(SnChannelMember value) {
|
||||||
|
return jsonEncode(toJson(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnChannelMember fromJson(Map<String, Object?> json) {
|
||||||
|
return SnChannelMember.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson(SnChannelMember value) {
|
||||||
|
return value.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnLocalChannelMember extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
IntColumn get channelId => integer()();
|
||||||
|
|
||||||
|
IntColumn get accountId => integer()();
|
||||||
|
|
||||||
|
TextColumn get content => text().map(SnChannelMemberConverter())();
|
||||||
|
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
|
||||||
|
DateTimeColumn get cacheExpiredAt => dateTime()();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:surface/database/account.dart';
|
||||||
|
import 'package:surface/database/attachment.dart';
|
||||||
import 'package:surface/database/chat.dart';
|
import 'package:surface/database/chat.dart';
|
||||||
|
import 'package:surface/database/database.steps.dart';
|
||||||
|
import 'package:surface/database/keypair.dart';
|
||||||
|
import 'package:surface/database/realm.dart';
|
||||||
|
import 'package:surface/database/sticker.dart';
|
||||||
import 'package:surface/types/chat.dart';
|
import 'package:surface/types/chat.dart';
|
||||||
|
import 'package:surface/types/attachment.dart';
|
||||||
|
import 'package:surface/types/account.dart';
|
||||||
|
import 'package:surface/types/realm.dart';
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@DriftDatabase(tables: [SnLocalChatChannel, SnLocalChatMessage])
|
@DriftDatabase(tables: [
|
||||||
|
SnLocalChatChannel,
|
||||||
|
SnLocalChatMessage,
|
||||||
|
SnLocalChannelMember,
|
||||||
|
SnLocalKeyPair,
|
||||||
|
SnLocalAccount,
|
||||||
|
SnLocalAttachment,
|
||||||
|
SnLocalSticker,
|
||||||
|
SnLocalStickerPack,
|
||||||
|
SnLocalRealm,
|
||||||
|
])
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase() : super(_openConnection());
|
AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 4;
|
||||||
|
|
||||||
static QueryExecutor _openConnection() {
|
static QueryExecutor _openConnection() {
|
||||||
return driftDatabase(
|
return driftDatabase(
|
||||||
@@ -25,4 +44,19 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
MigrationStrategy get migration {
|
||||||
|
return MigrationStrategy(
|
||||||
|
onUpgrade: stepByStep(from1To2: (m, schema) async {
|
||||||
|
// Nothing else to do here
|
||||||
|
}, from2To3: (m, schema) async {
|
||||||
|
// Nothing else to do here, too
|
||||||
|
}, from3To4: (m, schema) async {
|
||||||
|
m.createTable(schema.snLocalRealm);
|
||||||
|
m.createIndex(schema.idxRealmAccount);
|
||||||
|
m.createIndex(schema.idxRealmAlias);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
657
lib/database/database.steps.dart
Normal file
657
lib/database/database.steps.dart
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
// dart format width=80
|
||||||
|
import 'package:drift/internal/versioned_schema.dart' as i0;
|
||||||
|
import 'package:drift/drift.dart' as i1;
|
||||||
|
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
|
||||||
|
|
||||||
|
// GENERATED BY drift_dev, DO NOT MODIFY.
|
||||||
|
final class Schema2 extends i0.VersionedSchema {
|
||||||
|
Schema2({required super.database}) : super(version: 2);
|
||||||
|
@override
|
||||||
|
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||||
|
snLocalChatChannel,
|
||||||
|
snLocalChatMessage,
|
||||||
|
snLocalKeyPair,
|
||||||
|
];
|
||||||
|
late final Shape0 snLocalChatChannel = Shape0(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_chat_channel',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape1 snLocalChatMessage = Shape1(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_chat_message',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_4,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape2 snLocalKeyPair = Shape2(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_key_pair',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [
|
||||||
|
'PRIMARY KEY(id)',
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
_column_5,
|
||||||
|
_column_6,
|
||||||
|
_column_7,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Shape0 extends i0.VersionedTable {
|
||||||
|
Shape0({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get alias =>
|
||||||
|
columnsByName['alias']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get content =>
|
||||||
|
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<int> _column_0(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>('id', aliasedName, false,
|
||||||
|
hasAutoIncrement: true,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
defaultConstraints:
|
||||||
|
i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
|
||||||
|
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>('alias', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.string);
|
||||||
|
i1.GeneratedColumn<String> _column_2(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>('content', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.string);
|
||||||
|
i1.GeneratedColumn<DateTime> _column_3(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<DateTime>('created_at', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.dateTime,
|
||||||
|
defaultValue: const CustomExpression(
|
||||||
|
'CAST(strftime(\'%s\', CURRENT_TIMESTAMP) AS INTEGER)'));
|
||||||
|
|
||||||
|
class Shape1 extends i0.VersionedTable {
|
||||||
|
Shape1({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get channelId =>
|
||||||
|
columnsByName['channel_id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get content =>
|
||||||
|
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<int> _column_4(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>('channel_id', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.int);
|
||||||
|
|
||||||
|
class Shape2 extends i0.VersionedTable {
|
||||||
|
Shape2({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<String> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get accountId =>
|
||||||
|
columnsByName['account_id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get publicKey =>
|
||||||
|
columnsByName['public_key']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get privateKey =>
|
||||||
|
columnsByName['private_key']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<bool> get isActive =>
|
||||||
|
columnsByName['is_active']! as i1.GeneratedColumn<bool>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<String> _column_5(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>('id', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.string);
|
||||||
|
i1.GeneratedColumn<int> _column_6(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>('account_id', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.int);
|
||||||
|
i1.GeneratedColumn<String> _column_7(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>('public_key', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.string);
|
||||||
|
i1.GeneratedColumn<String> _column_8(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>('private_key', aliasedName, true,
|
||||||
|
type: i1.DriftSqlType.string);
|
||||||
|
i1.GeneratedColumn<bool> _column_9(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<bool>('is_active', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.bool,
|
||||||
|
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||||
|
'CHECK ("is_active" IN (0, 1))'),
|
||||||
|
defaultValue: const CustomExpression('0'));
|
||||||
|
|
||||||
|
final class Schema3 extends i0.VersionedSchema {
|
||||||
|
Schema3({required super.database}) : super(version: 3);
|
||||||
|
@override
|
||||||
|
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||||
|
snLocalChatChannel,
|
||||||
|
snLocalChatMessage,
|
||||||
|
snLocalChannelMember,
|
||||||
|
snLocalKeyPair,
|
||||||
|
snLocalAccount,
|
||||||
|
snLocalAttachment,
|
||||||
|
snLocalSticker,
|
||||||
|
snLocalStickerPack,
|
||||||
|
idxChannelAlias,
|
||||||
|
idxChatChannel,
|
||||||
|
idxAccountName,
|
||||||
|
idxAttachmentRid,
|
||||||
|
idxAttachmentAccount,
|
||||||
|
];
|
||||||
|
late final Shape0 snLocalChatChannel = Shape0(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_chat_channel',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape3 snLocalChatMessage = Shape3(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_chat_message',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_4,
|
||||||
|
_column_10,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape4 snLocalChannelMember = Shape4(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_channel_member',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_4,
|
||||||
|
_column_6,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
_column_11,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape2 snLocalKeyPair = Shape2(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_key_pair',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [
|
||||||
|
'PRIMARY KEY(id)',
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
_column_5,
|
||||||
|
_column_6,
|
||||||
|
_column_7,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape5 snLocalAccount = Shape5(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_account',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_12,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
_column_11,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape6 snLocalAttachment = Shape6(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_attachment',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_13,
|
||||||
|
_column_14,
|
||||||
|
_column_2,
|
||||||
|
_column_6,
|
||||||
|
_column_3,
|
||||||
|
_column_11,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape7 snLocalSticker = Shape7(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_sticker',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_15,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape8 snLocalStickerPack = Shape8(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_sticker_pack',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
final i1.Index idxChannelAlias = i1.Index('idx_channel_alias',
|
||||||
|
'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
|
||||||
|
final i1.Index idxChatChannel = i1.Index('idx_chat_channel',
|
||||||
|
'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)');
|
||||||
|
final i1.Index idxAccountName = i1.Index('idx_account_name',
|
||||||
|
'CREATE INDEX idx_account_name ON sn_local_account (name)');
|
||||||
|
final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid',
|
||||||
|
'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
|
||||||
|
final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account',
|
||||||
|
'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
class Shape3 extends i0.VersionedTable {
|
||||||
|
Shape3({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get channelId =>
|
||||||
|
columnsByName['channel_id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get senderId =>
|
||||||
|
columnsByName['sender_id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get content =>
|
||||||
|
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<int> _column_10(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>('sender_id', aliasedName, true,
|
||||||
|
type: i1.DriftSqlType.int);
|
||||||
|
|
||||||
|
class Shape4 extends i0.VersionedTable {
|
||||||
|
Shape4({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get channelId =>
|
||||||
|
columnsByName['channel_id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get accountId =>
|
||||||
|
columnsByName['account_id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get content =>
|
||||||
|
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
|
||||||
|
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<DateTime> _column_11(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<DateTime>('cache_expired_at', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.dateTime);
|
||||||
|
|
||||||
|
class Shape5 extends i0.VersionedTable {
|
||||||
|
Shape5({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get name =>
|
||||||
|
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get content =>
|
||||||
|
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
|
||||||
|
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<String> _column_12(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>('name', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.string);
|
||||||
|
|
||||||
|
class Shape6 extends i0.VersionedTable {
|
||||||
|
Shape6({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get rid =>
|
||||||
|
columnsByName['rid']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get uuid =>
|
||||||
|
columnsByName['uuid']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get content =>
|
||||||
|
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get accountId =>
|
||||||
|
columnsByName['account_id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
|
||||||
|
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<String> _column_13(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>('rid', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.string,
|
||||||
|
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
|
||||||
|
i1.GeneratedColumn<String> _column_14(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>('uuid', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.string,
|
||||||
|
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
|
||||||
|
|
||||||
|
class Shape7 extends i0.VersionedTable {
|
||||||
|
Shape7({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get alias =>
|
||||||
|
columnsByName['alias']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get fullAlias =>
|
||||||
|
columnsByName['full_alias']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get content =>
|
||||||
|
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<String> _column_15(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>('full_alias', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.string);
|
||||||
|
|
||||||
|
class Shape8 extends i0.VersionedTable {
|
||||||
|
Shape8({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get content =>
|
||||||
|
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Schema4 extends i0.VersionedSchema {
|
||||||
|
Schema4({required super.database}) : super(version: 4);
|
||||||
|
@override
|
||||||
|
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||||
|
snLocalChatChannel,
|
||||||
|
snLocalChatMessage,
|
||||||
|
snLocalChannelMember,
|
||||||
|
snLocalKeyPair,
|
||||||
|
snLocalAccount,
|
||||||
|
snLocalAttachment,
|
||||||
|
snLocalSticker,
|
||||||
|
snLocalStickerPack,
|
||||||
|
snLocalRealm,
|
||||||
|
idxChannelAlias,
|
||||||
|
idxChatChannel,
|
||||||
|
idxAccountName,
|
||||||
|
idxAttachmentRid,
|
||||||
|
idxAttachmentAccount,
|
||||||
|
idxRealmAlias,
|
||||||
|
idxRealmAccount,
|
||||||
|
];
|
||||||
|
late final Shape0 snLocalChatChannel = Shape0(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_chat_channel',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape3 snLocalChatMessage = Shape3(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_chat_message',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_4,
|
||||||
|
_column_10,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape4 snLocalChannelMember = Shape4(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_channel_member',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_4,
|
||||||
|
_column_6,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
_column_11,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape2 snLocalKeyPair = Shape2(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_key_pair',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [
|
||||||
|
'PRIMARY KEY(id)',
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
_column_5,
|
||||||
|
_column_6,
|
||||||
|
_column_7,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape5 snLocalAccount = Shape5(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_account',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_12,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
_column_11,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape6 snLocalAttachment = Shape6(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_attachment',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_13,
|
||||||
|
_column_14,
|
||||||
|
_column_2,
|
||||||
|
_column_6,
|
||||||
|
_column_3,
|
||||||
|
_column_11,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape7 snLocalSticker = Shape7(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_sticker',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_15,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape8 snLocalStickerPack = Shape8(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_sticker_pack',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_2,
|
||||||
|
_column_3,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
late final Shape9 snLocalRealm = Shape9(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'sn_local_realm',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_16,
|
||||||
|
_column_2,
|
||||||
|
_column_6,
|
||||||
|
_column_3,
|
||||||
|
_column_11,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null);
|
||||||
|
final i1.Index idxChannelAlias = i1.Index('idx_channel_alias',
|
||||||
|
'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
|
||||||
|
final i1.Index idxChatChannel = i1.Index('idx_chat_channel',
|
||||||
|
'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)');
|
||||||
|
final i1.Index idxAccountName = i1.Index('idx_account_name',
|
||||||
|
'CREATE INDEX idx_account_name ON sn_local_account (name)');
|
||||||
|
final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid',
|
||||||
|
'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
|
||||||
|
final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account',
|
||||||
|
'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
|
||||||
|
final i1.Index idxRealmAlias = i1.Index('idx_realm_alias',
|
||||||
|
'CREATE INDEX idx_realm_alias ON sn_local_realm (alias)');
|
||||||
|
final i1.Index idxRealmAccount = i1.Index('idx_realm_account',
|
||||||
|
'CREATE INDEX idx_realm_account ON sn_local_realm (account_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
class Shape9 extends i0.VersionedTable {
|
||||||
|
Shape9({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get alias =>
|
||||||
|
columnsByName['alias']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get content =>
|
||||||
|
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get accountId =>
|
||||||
|
columnsByName['account_id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
|
||||||
|
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<String> _column_16(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>('alias', aliasedName, false,
|
||||||
|
type: i1.DriftSqlType.string,
|
||||||
|
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
|
||||||
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
|
||||||
|
}) {
|
||||||
|
return (currentVersion, database) async {
|
||||||
|
switch (currentVersion) {
|
||||||
|
case 1:
|
||||||
|
final schema = Schema2(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from1To2(migrator, schema);
|
||||||
|
return 2;
|
||||||
|
case 2:
|
||||||
|
final schema = Schema3(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from2To3(migrator, schema);
|
||||||
|
return 3;
|
||||||
|
case 3:
|
||||||
|
final schema = Schema4(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from3To4(migrator, schema);
|
||||||
|
return 4;
|
||||||
|
default:
|
||||||
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.OnUpgrade stepByStep({
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
|
||||||
|
}) =>
|
||||||
|
i0.VersionedSchema.stepByStepHelper(
|
||||||
|
step: migrationSteps(
|
||||||
|
from1To2: from1To2,
|
||||||
|
from2To3: from2To3,
|
||||||
|
from3To4: from3To4,
|
||||||
|
));
|
||||||
16
lib/database/keypair.dart
Normal file
16
lib/database/keypair.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
class SnLocalKeyPair extends Table {
|
||||||
|
TextColumn get id => text()();
|
||||||
|
|
||||||
|
IntColumn get accountId => integer()();
|
||||||
|
|
||||||
|
TextColumn get publicKey => text()();
|
||||||
|
|
||||||
|
TextColumn get privateKey => text().nullable()();
|
||||||
|
|
||||||
|
BoolColumn get isActive => boolean().withDefault(Constant(false))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column<Object>> get primaryKey => {id};
|
||||||
|
}
|
||||||
45
lib/database/realm.dart
Normal file
45
lib/database/realm.dart
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:surface/types/realm.dart';
|
||||||
|
|
||||||
|
class SnRealmConverter extends TypeConverter<SnRealm, String>
|
||||||
|
with JsonTypeConverter2<SnRealm, String, Map<String, Object?>> {
|
||||||
|
const SnRealmConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnRealm fromSql(String fromDb) {
|
||||||
|
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(SnRealm value) {
|
||||||
|
return jsonEncode(toJson(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnRealm fromJson(Map<String, Object?> json) {
|
||||||
|
return SnRealm.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson(SnRealm value) {
|
||||||
|
return value.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TableIndex(name: 'idx_realm_alias', columns: {#alias})
|
||||||
|
@TableIndex(name: 'idx_realm_account', columns: {#accountId})
|
||||||
|
class SnLocalRealm extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
TextColumn get alias => text().unique()();
|
||||||
|
|
||||||
|
TextColumn get content => text().map(const SnRealmConverter())();
|
||||||
|
|
||||||
|
IntColumn get accountId => integer()();
|
||||||
|
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
|
||||||
|
DateTimeColumn get cacheExpiredAt => dateTime()();
|
||||||
|
}
|
||||||
74
lib/database/sticker.dart
Normal file
74
lib/database/sticker.dart
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:surface/types/attachment.dart';
|
||||||
|
|
||||||
|
class SnStickerConverter extends TypeConverter<SnSticker, String>
|
||||||
|
with JsonTypeConverter2<SnSticker, String, Map<String, Object?>> {
|
||||||
|
const SnStickerConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnSticker fromSql(String fromDb) {
|
||||||
|
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(SnSticker value) {
|
||||||
|
return jsonEncode(toJson(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnSticker fromJson(Map<String, Object?> json) {
|
||||||
|
return SnSticker.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson(SnSticker value) {
|
||||||
|
return value.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnLocalSticker extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
TextColumn get alias => text()();
|
||||||
|
|
||||||
|
TextColumn get fullAlias => text()();
|
||||||
|
|
||||||
|
TextColumn get content => text().map(const SnStickerConverter())();
|
||||||
|
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnStickerPackConverter extends TypeConverter<SnStickerPack, String>
|
||||||
|
with JsonTypeConverter2<SnStickerPack, String, Map<String, Object?>> {
|
||||||
|
const SnStickerPackConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnStickerPack fromSql(String fromDb) {
|
||||||
|
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(SnStickerPack value) {
|
||||||
|
return jsonEncode(toJson(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SnStickerPack fromJson(Map<String, Object?> json) {
|
||||||
|
return SnStickerPack.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Object?> toJson(SnStickerPack value) {
|
||||||
|
return value.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnLocalStickerPack extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
TextColumn get content => text().map(const SnStickerPackConverter())();
|
||||||
|
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
}
|
||||||
10
lib/logger.dart
Normal file
10
lib/logger.dart
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import 'package:talker/talker.dart';
|
||||||
|
|
||||||
|
final logging = Talker(
|
||||||
|
settings: TalkerSettings(
|
||||||
|
enabled: true,
|
||||||
|
useHistory: true,
|
||||||
|
maxHistoryItems: 1000,
|
||||||
|
useConsoleLogs: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
271
lib/main.dart
271
lib/main.dart
@@ -3,6 +3,7 @@ import 'dart:developer';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||||
import 'package:croppy/croppy.dart';
|
import 'package:croppy/croppy.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
@@ -12,6 +13,7 @@ import 'package:firebase_core/firebase_core.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
@@ -19,11 +21,14 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:relative_time/relative_time.dart';
|
import 'package:relative_time/relative_time.dart';
|
||||||
import 'package:responsive_framework/responsive_framework.dart';
|
import 'package:responsive_framework/responsive_framework.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:surface/firebase_options.dart';
|
import 'package:surface/firebase_options.dart';
|
||||||
|
import 'package:surface/logger.dart';
|
||||||
import 'package:surface/providers/channel.dart';
|
import 'package:surface/providers/channel.dart';
|
||||||
import 'package:surface/providers/chat_call.dart';
|
import 'package:surface/providers/chat_call.dart';
|
||||||
import 'package:surface/providers/config.dart';
|
import 'package:surface/providers/config.dart';
|
||||||
import 'package:surface/providers/database.dart';
|
import 'package:surface/providers/database.dart';
|
||||||
|
import 'package:surface/providers/keypair.dart';
|
||||||
import 'package:surface/providers/link_preview.dart';
|
import 'package:surface/providers/link_preview.dart';
|
||||||
import 'package:surface/providers/navigation.dart';
|
import 'package:surface/providers/navigation.dart';
|
||||||
import 'package:surface/providers/notification.dart';
|
import 'package:surface/providers/notification.dart';
|
||||||
@@ -35,6 +40,7 @@ import 'package:surface/providers/sn_realm.dart';
|
|||||||
import 'package:surface/providers/sn_sticker.dart';
|
import 'package:surface/providers/sn_sticker.dart';
|
||||||
import 'package:surface/providers/special_day.dart';
|
import 'package:surface/providers/special_day.dart';
|
||||||
import 'package:surface/providers/theme.dart';
|
import 'package:surface/providers/theme.dart';
|
||||||
|
import 'package:surface/providers/translation.dart';
|
||||||
import 'package:surface/providers/user_directory.dart';
|
import 'package:surface/providers/user_directory.dart';
|
||||||
import 'package:surface/providers/userinfo.dart';
|
import 'package:surface/providers/userinfo.dart';
|
||||||
import 'package:surface/providers/websocket.dart';
|
import 'package:surface/providers/websocket.dart';
|
||||||
@@ -42,6 +48,8 @@ import 'package:surface/providers/widget.dart';
|
|||||||
import 'package:surface/router.dart';
|
import 'package:surface/router.dart';
|
||||||
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
|
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/menu_bar.dart';
|
||||||
|
import 'package:surface/widgets/version_label.dart';
|
||||||
import 'package:tray_manager/tray_manager.dart';
|
import 'package:tray_manager/tray_manager.dart';
|
||||||
import 'package:version/version.dart';
|
import 'package:version/version.dart';
|
||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
@@ -67,13 +75,40 @@ void appBackgroundDispatcher() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Desktop size tools
|
||||||
|
|
||||||
|
Future<Size> _getSavedWindowSize() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
String? sizeString = prefs.getString(kAppWindowSize);
|
||||||
|
|
||||||
|
if (sizeString != null) {
|
||||||
|
List<String> parts = sizeString.split('x');
|
||||||
|
if (parts.length == 2) {
|
||||||
|
double? width = double.tryParse(parts[0]);
|
||||||
|
double? height = double.tryParse(parts[1]);
|
||||||
|
if (width != null && height != null) {
|
||||||
|
return Size(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Size(1280, 720); // Default size
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveWindowSize() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final size = appWindow.size;
|
||||||
|
await prefs.setString(kAppWindowSize, '${size.width}x${size.height}');
|
||||||
|
}
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
|
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
|
||||||
|
final Size savedSize = await _getSavedWindowSize();
|
||||||
doWhenWindowReady(() {
|
doWhenWindowReady(() {
|
||||||
appWindow.minSize = Size(480, 640);
|
appWindow.minSize = Size(480, 640);
|
||||||
appWindow.size = Size(1280, 720);
|
appWindow.size = savedSize;
|
||||||
appWindow.alignment = Alignment.center;
|
appWindow.alignment = Alignment.center;
|
||||||
appWindow.show();
|
appWindow.show();
|
||||||
});
|
});
|
||||||
@@ -83,18 +118,15 @@ void main() async {
|
|||||||
|
|
||||||
if (!kIsWeb && !Platform.isLinux) {
|
if (!kIsWeb && !Platform.isLinux) {
|
||||||
await Firebase.initializeApp(
|
await Firebase.initializeApp(
|
||||||
options: DefaultFirebaseOptions.currentPlatform,
|
options: DefaultFirebaseOptions.currentPlatform);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GoRouter.optionURLReflectsImperativeAPIs = true;
|
GoRouter.optionURLReflectsImperativeAPIs = true;
|
||||||
usePathUrlStrategy();
|
usePathUrlStrategy();
|
||||||
|
|
||||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||||
Workmanager().initialize(
|
Workmanager()
|
||||||
appBackgroundDispatcher,
|
.initialize(appBackgroundDispatcher, isInDebugMode: kDebugMode);
|
||||||
isInDebugMode: kDebugMode,
|
|
||||||
);
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
Workmanager().registerPeriodicTask(
|
Workmanager().registerPeriodicTask(
|
||||||
"widget-update-random-post",
|
"widget-update-random-post",
|
||||||
@@ -129,7 +161,7 @@ class SolianApp extends StatelessWidget {
|
|||||||
Locale('en', 'US'),
|
Locale('en', 'US'),
|
||||||
Locale('zh', 'CN'),
|
Locale('zh', 'CN'),
|
||||||
Locale('zh', 'TW'),
|
Locale('zh', 'TW'),
|
||||||
Locale('zh', 'HK'),
|
Locale('zh', 'HK')
|
||||||
],
|
],
|
||||||
fallbackLocale: Locale('en', 'US'),
|
fallbackLocale: Locale('en', 'US'),
|
||||||
useFallbackTranslations: true,
|
useFallbackTranslations: true,
|
||||||
@@ -153,16 +185,18 @@ class SolianApp extends StatelessWidget {
|
|||||||
Provider(create: (ctx) => SnNetworkProvider(ctx)),
|
Provider(create: (ctx) => SnNetworkProvider(ctx)),
|
||||||
Provider(create: (ctx) => UserDirectoryProvider(ctx)),
|
Provider(create: (ctx) => UserDirectoryProvider(ctx)),
|
||||||
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
|
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
|
||||||
Provider(create: (ctx) => SnRealmProvider(ctx)),
|
ChangeNotifierProvider(create: (ctx) => SnRealmProvider(ctx)),
|
||||||
Provider(create: (ctx) => SnPostContentProvider(ctx)),
|
Provider(create: (ctx) => SnPostContentProvider(ctx)),
|
||||||
Provider(create: (ctx) => SnRelationshipProvider(ctx)),
|
Provider(create: (ctx) => SnRelationshipProvider(ctx)),
|
||||||
Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
|
Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
|
||||||
Provider(create: (ctx) => SnStickerProvider(ctx)),
|
Provider(create: (ctx) => SnStickerProvider(ctx)),
|
||||||
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
|
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
|
||||||
ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
|
ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
|
||||||
|
Provider(create: (ctx) => KeyPairProvider(ctx)),
|
||||||
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
|
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
|
||||||
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
|
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
|
||||||
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
|
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
|
||||||
|
Provider(create: (ctx) => SnTranslator()),
|
||||||
|
|
||||||
// Additional helper layer
|
// Additional helper layer
|
||||||
Provider(create: (ctx) => SpecialDayProvider(ctx)),
|
Provider(create: (ctx) => SpecialDayProvider(ctx)),
|
||||||
@@ -222,6 +256,9 @@ class _AppSplashScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||||
|
bool _isBusy = false;
|
||||||
|
String _phaseText = 'appInitStarting';
|
||||||
|
|
||||||
void _tryRequestRating() async {
|
void _tryRequestRating() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
if (prefs.containsKey('first_boot_time')) {
|
if (prefs.containsKey('first_boot_time')) {
|
||||||
@@ -235,7 +272,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
|||||||
await inAppReview.requestReview();
|
await inAppReview.requestReview();
|
||||||
prefs.setBool('rating_requested', true);
|
prefs.setBool('rating_requested', true);
|
||||||
} else {
|
} else {
|
||||||
log('Unable request app review, unavailable');
|
logging.error('Unable request app review, unavailable');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -250,12 +287,10 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
|||||||
final localVersionString = '${info.version}+${info.buildNumber}';
|
final localVersionString = '${info.version}+${info.buildNumber}';
|
||||||
final resp = await Dio(
|
final resp = await Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
sendTimeout: const Duration(seconds: 60),
|
sendTimeout: const Duration(seconds: 60),
|
||||||
receiveTimeout: const Duration(seconds: 60),
|
receiveTimeout: const Duration(seconds: 60)),
|
||||||
),
|
|
||||||
).get(
|
).get(
|
||||||
'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest',
|
'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest');
|
||||||
);
|
|
||||||
final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0';
|
final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0';
|
||||||
final remoteVersion = Version.parse(remoteVersionString.split('+').first);
|
final remoteVersion = Version.parse(remoteVersionString.split('+').first);
|
||||||
final localVersion = Version.parse(localVersionString.split('+').first);
|
final localVersion = Version.parse(localVersionString.split('+').first);
|
||||||
@@ -263,21 +298,27 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
|||||||
int.tryParse(remoteVersionString.split('+').last) ?? 0;
|
int.tryParse(remoteVersionString.split('+').last) ?? 0;
|
||||||
final localBuildNumber =
|
final localBuildNumber =
|
||||||
int.tryParse(localVersionString.split('+').last) ?? 0;
|
int.tryParse(localVersionString.split('+').last) ?? 0;
|
||||||
log("[Update] Local: $localVersionString, Remote: $remoteVersionString");
|
logging.info(
|
||||||
|
"[Update] Local: $localVersionString, Remote: $remoteVersionString");
|
||||||
if ((remoteVersion > localVersion ||
|
if ((remoteVersion > localVersion ||
|
||||||
remoteBuildNumber > localBuildNumber) &&
|
remoteBuildNumber > localBuildNumber) &&
|
||||||
mounted) {
|
mounted) {
|
||||||
final config = context.read<ConfigProvider>();
|
final config = context.read<ConfigProvider>();
|
||||||
config.setUpdate(
|
config.setUpdate(
|
||||||
remoteVersionString, resp.data?['body'] ?? 'No changelog');
|
remoteVersionString, resp.data?['body'] ?? 'No changelog');
|
||||||
log("[Update] Update available: $remoteVersionString");
|
logging.info("[Update] Update available: $remoteVersionString");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('[Error] Unable to check update: $e');
|
logging.error('[Error] Unable to check update...', e);
|
||||||
if (mounted) context.showErrorDialog('Unable to check update: $e');
|
if (mounted) context.showErrorDialog('Unable to check update: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _setPhaseText(String text) {
|
||||||
|
_phaseText = 'appInit${text.capitalize()}'.tr();
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initialize() async {
|
Future<void> _initialize() async {
|
||||||
try {
|
try {
|
||||||
final cfg = context.read<ConfigProvider>();
|
final cfg = context.read<ConfigProvider>();
|
||||||
@@ -290,23 +331,52 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
|||||||
// The Network initialization must be done after the HomeWidget initialization
|
// The Network initialization must be done after the HomeWidget initialization
|
||||||
// The Network initialization will save the server url to the HomeWidget
|
// The Network initialization will save the server url to the HomeWidget
|
||||||
// The Network initialization will also save initialize the Config, so it not need to be initialized again
|
// The Network initialization will also save initialize the Config, so it not need to be initialized again
|
||||||
|
_setPhaseText('network');
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
await sn.initializeUserAgent();
|
await sn.initializeUserAgent();
|
||||||
await sn.setConfigWithNative();
|
await sn.setConfigWithNative();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
_setPhaseText('userdata');
|
||||||
final ua = context.read<UserProvider>();
|
final ua = context.read<UserProvider>();
|
||||||
await ua.initialize();
|
await ua.initialize();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
_setPhaseText('websocket');
|
||||||
final ws = context.read<WebSocketProvider>();
|
final ws = context.read<WebSocketProvider>();
|
||||||
await ws.tryConnect();
|
await ws.tryConnect();
|
||||||
if (!mounted) return;
|
try {
|
||||||
final notify = context.read<NotificationProvider>();
|
if (!mounted) return;
|
||||||
notify.listen();
|
_setPhaseText('keyPair');
|
||||||
await notify.registerPushNotifications();
|
final kp = context.read<KeyPairProvider>();
|
||||||
if (!mounted) return;
|
await kp.reloadActive();
|
||||||
final sticker = context.read<SnStickerProvider>();
|
kp.listen();
|
||||||
await sticker.listSticker();
|
} catch (_) {}
|
||||||
log('[Bootstrap] Everything initialized!');
|
if (ua.isAuthorized) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_setPhaseText('notification');
|
||||||
|
final notify = context.read<NotificationProvider>();
|
||||||
|
notify.listen();
|
||||||
|
try {
|
||||||
|
notify.registerPushNotifications();
|
||||||
|
} catch (_) {}
|
||||||
|
if (!mounted) return;
|
||||||
|
_setPhaseText('stickers');
|
||||||
|
final sticker = context.read<SnStickerProvider>();
|
||||||
|
await sticker.listSticker();
|
||||||
|
if (!mounted) return;
|
||||||
|
_setPhaseText('userDirectory');
|
||||||
|
final ud = context.read<UserDirectoryProvider>();
|
||||||
|
await ud.loadAccountCache();
|
||||||
|
if (!mounted) return;
|
||||||
|
_setPhaseText('realm');
|
||||||
|
final rm = context.read<SnRealmProvider>();
|
||||||
|
await rm.refreshAvailableRealms();
|
||||||
|
if (!mounted) return;
|
||||||
|
_setPhaseText('chat');
|
||||||
|
final ct = context.read<ChatChannelProvider>();
|
||||||
|
await ct.refreshAvailableChannels();
|
||||||
|
_setPhaseText('done');
|
||||||
|
_playIntro();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await context.showErrorDialog(err);
|
await context.showErrorDialog(err);
|
||||||
@@ -319,42 +389,31 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
|||||||
|
|
||||||
Future<void> _hotkeyInitialization() async {
|
Future<void> _hotkeyInitialization() async {
|
||||||
if (kIsWeb) return;
|
if (kIsWeb) return;
|
||||||
|
// The quit key has been removed, and the logic of the quit key is moved to system menu bar activator.
|
||||||
|
}
|
||||||
|
|
||||||
if (Platform.isMacOS) {
|
void _playIntro() async {
|
||||||
HotKey quitHotKey = HotKey(
|
final cfg = context.read<ConfigProvider>();
|
||||||
key: PhysicalKeyboardKey.keyQ,
|
if (!cfg.soundEffects) return;
|
||||||
modifiers: [HotKeyModifier.meta],
|
|
||||||
scope: HotKeyScope.inapp,
|
final player = AudioPlayer(playerId: 'launch-intro-player');
|
||||||
);
|
await player.play(AssetSource('audio/sfx/launch-intro.mp3'), volume: 0.5);
|
||||||
await hotKeyManager.register(quitHotKey, keyUpHandler: (_) {
|
player.onPlayerComplete.listen((_) {
|
||||||
_appLifecycleListener?.dispose();
|
player.dispose();
|
||||||
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final Menu _appTrayMenu = Menu(
|
final Menu _appTrayMenu = Menu(
|
||||||
items: [
|
items: [
|
||||||
MenuItem(
|
MenuItem(key: 'version_label', label: 'Solian', disabled: true),
|
||||||
key: 'version_label',
|
|
||||||
label: 'Solian',
|
|
||||||
disabled: true,
|
|
||||||
),
|
|
||||||
MenuItem.separator(),
|
MenuItem.separator(),
|
||||||
MenuItem.checkbox(
|
MenuItem.checkbox(
|
||||||
checked: false,
|
checked: false,
|
||||||
key: 'mute_notification',
|
key: 'mute_notification',
|
||||||
label: 'trayMenuMuteNotification'.tr(),
|
label: 'trayMenuMuteNotification'.tr()),
|
||||||
),
|
|
||||||
MenuItem.separator(),
|
MenuItem.separator(),
|
||||||
MenuItem(
|
MenuItem(key: 'window_show', label: 'trayMenuShow'.tr()),
|
||||||
key: 'window_show',
|
MenuItem(key: 'exit', label: 'trayMenuExit'.tr()),
|
||||||
label: 'trayMenuShow'.tr(),
|
|
||||||
),
|
|
||||||
MenuItem(
|
|
||||||
key: 'exit',
|
|
||||||
label: 'trayMenuExit'.tr(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -382,9 +441,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
|||||||
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
|
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
|
||||||
|
|
||||||
await localNotifier.setup(
|
await localNotifier.setup(
|
||||||
appName: 'Solian',
|
appName: 'Solian', shortcutPolicy: ShortcutPolicy.requireCreate);
|
||||||
shortcutPolicy: ShortcutPolicy.requireCreate,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AppLifecycleListener? _appLifecycleListener;
|
AppLifecycleListener? _appLifecycleListener;
|
||||||
@@ -393,10 +450,10 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
_isBusy = true;
|
||||||
if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
|
if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
|
||||||
_appLifecycleListener = AppLifecycleListener(
|
_appLifecycleListener =
|
||||||
onExitRequested: _onExitRequested,
|
AppLifecycleListener(onExitRequested: _onExitRequested);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_trayInitialization();
|
_trayInitialization();
|
||||||
@@ -406,6 +463,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
|||||||
_postInitialization();
|
_postInitialization();
|
||||||
_tryRequestRating();
|
_tryRequestRating();
|
||||||
_checkForUpdate();
|
_checkForUpdate();
|
||||||
|
setState(() => _isBusy = false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +472,16 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
|||||||
return AppExitResponse.cancel;
|
return AppExitResponse.cancel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _quitApp() {
|
||||||
|
_saveWindowSize();
|
||||||
|
_appLifecycleListener?.dispose();
|
||||||
|
if (Platform.isWindows) {
|
||||||
|
appWindow.close();
|
||||||
|
} else {
|
||||||
|
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onTrayIconMouseDown() {
|
void onTrayIconMouseDown() {
|
||||||
if (Platform.isWindows) {
|
if (Platform.isWindows) {
|
||||||
@@ -448,12 +516,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
|||||||
Timer(const Duration(milliseconds: 100), () => appWindow.show());
|
Timer(const Duration(milliseconds: 100), () => appWindow.show());
|
||||||
break;
|
break;
|
||||||
case 'exit':
|
case 'exit':
|
||||||
_appLifecycleListener?.dispose();
|
_quitApp();
|
||||||
if (Platform.isWindows) {
|
|
||||||
appWindow.close();
|
|
||||||
} else {
|
|
||||||
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,23 +533,73 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cfg = context.read<ConfigProvider>();
|
final cfg = context.read<ConfigProvider>();
|
||||||
return NotificationListener<SizeChangedLayoutNotification>(
|
return AppSystemMenuBar(
|
||||||
onNotification: (notification) {
|
onQuit: _quitApp,
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
child: NotificationListener<SizeChangedLayoutNotification>(
|
||||||
cfg.calcDrawerSize(context);
|
onNotification: (notification) {
|
||||||
});
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
child: OrientationBuilder(
|
|
||||||
builder: (context, orientation) {
|
|
||||||
final cfg = context.read<ConfigProvider>();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
cfg.calcDrawerSize(context);
|
cfg.calcDrawerSize(context);
|
||||||
});
|
});
|
||||||
return SizeChangedLayoutNotifier(
|
return false;
|
||||||
child: widget.child,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
child: OrientationBuilder(
|
||||||
|
builder: (context, orientation) {
|
||||||
|
final cfg = context.read<ConfigProvider>();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
cfg.calcDrawerSize(context);
|
||||||
|
});
|
||||||
|
Future.delayed(const Duration(milliseconds: 300), () {
|
||||||
|
if (context.mounted) {
|
||||||
|
cfg.calcDrawerSize(context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return SizeChangedLayoutNotifier(
|
||||||
|
child: _isBusy
|
||||||
|
? Material(
|
||||||
|
key: Key('app-splash-screen-$_isBusy'),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
image: DecorationImage(
|
||||||
|
image: AssetImage('assets/icon/kanban-1st.jpg'),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
opacity: 0.1,
|
||||||
|
),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
backgroundBlendMode: BlendMode.darken,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 240),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Image.asset(
|
||||||
|
'assets/icon/icon.png',
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
color:
|
||||||
|
Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
Text('Solar Network').bold(),
|
||||||
|
AppVersionLabel(),
|
||||||
|
Gap(8),
|
||||||
|
Text(_phaseText, textAlign: TextAlign.center),
|
||||||
|
Gap(16),
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: widget.child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:surface/providers/database.dart';
|
|||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/providers/sn_realm.dart';
|
import 'package:surface/providers/sn_realm.dart';
|
||||||
import 'package:surface/providers/user_directory.dart';
|
import 'package:surface/providers/user_directory.dart';
|
||||||
|
import 'package:surface/providers/userinfo.dart';
|
||||||
import 'package:surface/types/chat.dart';
|
import 'package:surface/types/chat.dart';
|
||||||
|
|
||||||
class ChatChannelProvider extends ChangeNotifier {
|
class ChatChannelProvider extends ChangeNotifier {
|
||||||
@@ -15,16 +16,36 @@ class ChatChannelProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
late final SnNetworkProvider _sn;
|
late final SnNetworkProvider _sn;
|
||||||
late final UserDirectoryProvider _ud;
|
late final UserDirectoryProvider _ud;
|
||||||
|
late final UserProvider _ua;
|
||||||
late final DatabaseProvider _dt;
|
late final DatabaseProvider _dt;
|
||||||
late final SnRealmProvider _rels;
|
late final SnRealmProvider _rels;
|
||||||
|
|
||||||
ChatChannelProvider(BuildContext context) {
|
ChatChannelProvider(BuildContext context) {
|
||||||
_sn = context.read<SnNetworkProvider>();
|
_sn = context.read<SnNetworkProvider>();
|
||||||
_ud = context.read<UserDirectoryProvider>();
|
_ud = context.read<UserDirectoryProvider>();
|
||||||
|
_ua = context.read<UserProvider>();
|
||||||
_dt = context.read<DatabaseProvider>();
|
_dt = context.read<DatabaseProvider>();
|
||||||
_rels = context.read<SnRealmProvider>();
|
_rels = context.read<SnRealmProvider>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final List<SnChannel> _availableChannels = List.empty(growable: true);
|
||||||
|
|
||||||
|
List<SnChannel> get availableChannels => _availableChannels;
|
||||||
|
|
||||||
|
Future<void> refreshAvailableChannels() async {
|
||||||
|
final stream = fetchChannels();
|
||||||
|
stream.listen((ele) {
|
||||||
|
_availableChannels.clear();
|
||||||
|
_availableChannels.addAll(ele);
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void addAvailableChannel(SnChannel channel) {
|
||||||
|
_availableChannels.add(channel);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
|
Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
|
||||||
await Future.wait(
|
await Future.wait(
|
||||||
channels.map(
|
channels.map(
|
||||||
@@ -149,4 +170,60 @@ class ChatChannelProvider extends ChangeNotifier {
|
|||||||
await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet());
|
await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet());
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveMemberToLocal(Iterable<SnChannelMember> members) async {
|
||||||
|
final queries = members.map((ele) {
|
||||||
|
return _dt.db.snLocalChannelMember.insertOne(
|
||||||
|
SnLocalChannelMemberCompanion.insert(
|
||||||
|
id: Value(ele.id),
|
||||||
|
channelId: ele.channelId,
|
||||||
|
accountId: ele.accountId,
|
||||||
|
content: ele,
|
||||||
|
cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
|
||||||
|
),
|
||||||
|
onConflict: DoUpdate(
|
||||||
|
(_) => SnLocalChannelMemberCompanion.custom(
|
||||||
|
content: Constant(jsonEncode(ele.toJson())),
|
||||||
|
cacheExpiredAt:
|
||||||
|
Constant(DateTime.now().add(const Duration(days: 7))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await Future.wait(queries);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeLocalChannel(SnChannel channel) async {
|
||||||
|
await _dt.db.transaction(() async {
|
||||||
|
await (_dt.db.snLocalChannelMember.delete()
|
||||||
|
..where((e) => e.channelId.equals(channel.id)))
|
||||||
|
.go();
|
||||||
|
await (_dt.db.snLocalChatChannel.delete()
|
||||||
|
..where((e) => e.id.equals(channel.id)))
|
||||||
|
.go();
|
||||||
|
await (_dt.db.snLocalChatMessage.delete()
|
||||||
|
..where((e) => e.channelId.equals(channel.id)))
|
||||||
|
.go();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateChannelProfile(SnChannelMember member) {
|
||||||
|
return _saveMemberToLocal([member]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SnChannelMember> getChannelProfile(SnChannel channel) async {
|
||||||
|
if (_ua.user == null) throw Exception('User not logged in');
|
||||||
|
final local = await (_dt.db.snLocalChannelMember.select()
|
||||||
|
..where((e) => e.channelId.equals(channel.id))
|
||||||
|
..where((e) => e.accountId.equals(_ua.user!.id)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (local != null) {
|
||||||
|
return local.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resp = await _sn.client.get('/cgi/im/channels/${channel.keyPath}/me');
|
||||||
|
final out = SnChannelMember.fromJson(resp.data);
|
||||||
|
_saveMemberToLocal([out]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ const kAppNotifyWithHaptic = 'app_notify_with_haptic';
|
|||||||
const kAppExpandPostLink = 'app_expand_post_link';
|
const kAppExpandPostLink = 'app_expand_post_link';
|
||||||
const kAppExpandChatLink = 'app_expand_chat_link';
|
const kAppExpandChatLink = 'app_expand_chat_link';
|
||||||
const kAppRealmCompactView = 'app_realm_compact_view';
|
const kAppRealmCompactView = 'app_realm_compact_view';
|
||||||
|
const kAppCustomFonts = 'app_custom_fonts';
|
||||||
|
const kAppMixedFeed = 'app_mixed_feed';
|
||||||
|
const kAppAutoTranslate = 'app_auto_translate';
|
||||||
|
const kAppHideBottomNav = 'app_hide_bottom_nav';
|
||||||
|
const kAppSoundEffects = 'app_sound_effects';
|
||||||
|
const kAppAprilFoolFeatures = 'app_april_fool_features';
|
||||||
|
const kAppWindowSize = 'app_window_size';
|
||||||
|
|
||||||
const Map<String, FilterQuality> kImageQualityLevel = {
|
const Map<String, FilterQuality> kImageQualityLevel = {
|
||||||
'settingsImageQualityLowest': FilterQuality.none,
|
'settingsImageQualityLowest': FilterQuality.none,
|
||||||
@@ -80,8 +87,54 @@ class ConfigProvider extends ChangeNotifier {
|
|||||||
return prefs.getBool(kAppRealmCompactView) ?? false;
|
return prefs.getBool(kAppRealmCompactView) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get mixedFeed {
|
||||||
|
return prefs.getBool(kAppMixedFeed) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get autoTranslate {
|
||||||
|
return prefs.getBool(kAppAutoTranslate) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hideBottomNav {
|
||||||
|
return prefs.getBool(kAppHideBottomNav) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get aprilFoolFeatures {
|
||||||
|
return prefs.getBool(kAppAprilFoolFeatures) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get soundEffects {
|
||||||
|
return prefs.getBool(kAppSoundEffects) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
set soundEffects(bool value) {
|
||||||
|
prefs.setBool(kAppSoundEffects, value);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
set aprilFoolFeatures(bool value) {
|
||||||
|
prefs.setBool(kAppAprilFoolFeatures, value);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
set hideBottomNav(bool value) {
|
||||||
|
prefs.setBool(kAppHideBottomNav, value);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
set autoTranslate(bool value) {
|
||||||
|
prefs.setBool(kAppAutoTranslate, value);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
set mixedFeed(bool value) {
|
||||||
|
prefs.setBool(kAppMixedFeed, value);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
set realmCompactView(bool value) {
|
set realmCompactView(bool value) {
|
||||||
prefs.setBool(kAppRealmCompactView, value);
|
prefs.setBool(kAppRealmCompactView, value);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
set serverUrl(String url) {
|
set serverUrl(String url) {
|
||||||
|
|||||||
245
lib/providers/keypair.dart
Normal file
245
lib/providers/keypair.dart
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/database/database.dart';
|
||||||
|
import 'package:surface/logger.dart';
|
||||||
|
import 'package:surface/providers/database.dart';
|
||||||
|
import 'package:surface/providers/userinfo.dart';
|
||||||
|
import 'package:surface/providers/websocket.dart';
|
||||||
|
import 'package:surface/types/keypair.dart';
|
||||||
|
import 'package:fast_rsa/fast_rsa.dart';
|
||||||
|
import 'package:surface/types/websocket.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
// Currently the keypair only provide RSA encryption
|
||||||
|
// Supported by the `fast_rsa` package
|
||||||
|
class KeyPairProvider {
|
||||||
|
late final DatabaseProvider _dt;
|
||||||
|
late final UserProvider _ua;
|
||||||
|
late final WebSocketProvider _ws;
|
||||||
|
|
||||||
|
SnKeyPair? activeKp;
|
||||||
|
|
||||||
|
KeyPairProvider(BuildContext context) {
|
||||||
|
_dt = context.read<DatabaseProvider>();
|
||||||
|
_ua = context.read<UserProvider>();
|
||||||
|
_ws = context.read<WebSocketProvider>();
|
||||||
|
}
|
||||||
|
|
||||||
|
void listen() {
|
||||||
|
_ws.pk.stream.listen((event) {
|
||||||
|
switch (event.method) {
|
||||||
|
case 'kex.ack':
|
||||||
|
ackKeyExchange(event);
|
||||||
|
break;
|
||||||
|
case 'kex.ask':
|
||||||
|
replyAskKeyExchange(event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> decryptText(String text, String kpId, {int? kpOwner}) async {
|
||||||
|
String? publicKey;
|
||||||
|
final kp = await (_dt.db.snLocalKeyPair.select()
|
||||||
|
..where((e) => e.id.equals(kpId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (kp == null) {
|
||||||
|
if (kpOwner != null) {
|
||||||
|
final out = await askKeyExchange(kpOwner, kpId);
|
||||||
|
publicKey = out.publicKey;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
publicKey = kp.publicKey;
|
||||||
|
}
|
||||||
|
if (publicKey == null) {
|
||||||
|
throw Exception('Key pair not found');
|
||||||
|
}
|
||||||
|
return await RSA.decryptPKCS1v15(text, publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> encryptText(String text) async {
|
||||||
|
if (activeKp == null) throw Exception('No active key pair');
|
||||||
|
return await RSA.encryptPKCS1v15(text, activeKp!.privateKey!);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Completer<SnKeyPair>> _requests = {};
|
||||||
|
|
||||||
|
Future<SnKeyPair> askKeyExchange(int kpOwner, String kpId) async {
|
||||||
|
if (_requests.containsKey(kpId)) return await _requests[kpId]!.future;
|
||||||
|
|
||||||
|
final completer = Completer<SnKeyPair>();
|
||||||
|
_requests[kpId] = completer;
|
||||||
|
|
||||||
|
_ws.conn?.sink.add(
|
||||||
|
jsonEncode(WebSocketPackage(
|
||||||
|
method: 'kex.ask',
|
||||||
|
endpoint: 'id',
|
||||||
|
payload: {
|
||||||
|
'keypair_id': kpId,
|
||||||
|
'user_id': kpOwner,
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Future.any([
|
||||||
|
_requests[kpId]!.future,
|
||||||
|
Future.delayed(const Duration(seconds: 60), () {
|
||||||
|
_requests.remove(kpId);
|
||||||
|
throw TimeoutException("Key exchange timed out");
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> ackKeyExchange(WebSocketPackage pkt) async {
|
||||||
|
if (pkt.payload == null) return;
|
||||||
|
final kpMeta = SnKeyPair(
|
||||||
|
id: pkt.payload!['keypair_id'] as String,
|
||||||
|
accountId: pkt.payload!['user_id'] as int,
|
||||||
|
publicKey: pkt.payload!['public_key'] as String,
|
||||||
|
privateKey: pkt.payload?['private_key'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_requests.containsKey(kpMeta.id)) {
|
||||||
|
_requests[kpMeta.id]!.complete(kpMeta);
|
||||||
|
_requests.remove(kpMeta.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the keypair to the local database
|
||||||
|
await _dt.db.snLocalKeyPair.insertOne(
|
||||||
|
SnLocalKeyPairCompanion.insert(
|
||||||
|
id: kpMeta.id,
|
||||||
|
accountId: kpMeta.accountId,
|
||||||
|
publicKey: kpMeta.publicKey,
|
||||||
|
privateKey: Value(kpMeta.privateKey),
|
||||||
|
),
|
||||||
|
onConflict: DoNothing(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replyAskKeyExchange(WebSocketPackage pkt) async {
|
||||||
|
final kpId = pkt.payload!['keypair_id'] as String;
|
||||||
|
final userId = pkt.payload!['user_id'] as int;
|
||||||
|
final clientId = pkt.payload!['client_id'] as String;
|
||||||
|
|
||||||
|
final localKp = await (_dt.db.snLocalKeyPair.select()
|
||||||
|
..where((e) => e.id.equals(kpId))
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (localKp == null) return;
|
||||||
|
|
||||||
|
logging.info(
|
||||||
|
'[Kex] Reply to key exchange request of $kpId from user $userId',
|
||||||
|
);
|
||||||
|
|
||||||
|
// We do not give the private key to the client
|
||||||
|
_ws.conn?.sink.add(jsonEncode(
|
||||||
|
WebSocketPackage(
|
||||||
|
method: 'kex.ack',
|
||||||
|
endpoint: 'id',
|
||||||
|
payload: {
|
||||||
|
'keypair_id': localKp.id,
|
||||||
|
'user_id': localKp.accountId,
|
||||||
|
'public_key': localKp.publicKey,
|
||||||
|
'client_id': clientId,
|
||||||
|
},
|
||||||
|
).toJson(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SnKeyPair?> reloadActive({bool autoEnroll = true}) async {
|
||||||
|
final kp = await (_dt.db.snLocalKeyPair.select()
|
||||||
|
..where((e) => e.accountId.equals(_ua.user!.id))
|
||||||
|
..where((e) => e.privateKey.isNotNull())
|
||||||
|
..where((e) => e.isActive.equals(true))
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
|
||||||
|
if (kp != null) {
|
||||||
|
activeKp = SnKeyPair(
|
||||||
|
id: kp.id,
|
||||||
|
accountId: kp.accountId,
|
||||||
|
publicKey: kp.publicKey,
|
||||||
|
privateKey: kp.privateKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kp == null && autoEnroll) {
|
||||||
|
return await enrollNew();
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeKp;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SnKeyPair>> listKeyPair() async {
|
||||||
|
final kps = await (_dt.db.snLocalKeyPair.select()).get();
|
||||||
|
return kps
|
||||||
|
.map((e) => SnKeyPair(
|
||||||
|
id: e.id,
|
||||||
|
accountId: e.accountId,
|
||||||
|
publicKey: e.publicKey,
|
||||||
|
privateKey: e.privateKey,
|
||||||
|
isActive: e.isActive,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> activeKeyPair(String kpId) async {
|
||||||
|
final kp = await (_dt.db.snLocalKeyPair.select()
|
||||||
|
..where((e) => e.id.equals(kpId))
|
||||||
|
..where((e) => e.privateKey.isNotNull())
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (kp == null) return;
|
||||||
|
|
||||||
|
await _dt.db.transaction(() async {
|
||||||
|
await (_dt.db.update(_dt.db.snLocalKeyPair)
|
||||||
|
..where((e) => e.isActive.equals(true)))
|
||||||
|
.write(SnLocalKeyPairCompanion(isActive: Value(false)));
|
||||||
|
|
||||||
|
await (_dt.db.update(_dt.db.snLocalKeyPair)
|
||||||
|
..where((e) => e.id.equals(kp.id)))
|
||||||
|
.write(SnLocalKeyPairCompanion(isActive: Value(true)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SnKeyPair> enrollNew() async {
|
||||||
|
if (!_ua.isAuthorized) throw Exception('Unauthorized');
|
||||||
|
|
||||||
|
final id = const Uuid().v4();
|
||||||
|
final kp = await RSA.generate(2048);
|
||||||
|
final kpMeta = SnKeyPair(
|
||||||
|
id: id,
|
||||||
|
accountId: _ua.user!.id,
|
||||||
|
// This is work as expected
|
||||||
|
// We need to share private key to let everyone can decode the message
|
||||||
|
publicKey: kp.privateKey,
|
||||||
|
privateKey: kp.publicKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save the keypair to the local database
|
||||||
|
// If there is already one with private key, it will be overwritten
|
||||||
|
await _dt.db.transaction(() async {
|
||||||
|
await (_dt.db.update(_dt.db.snLocalKeyPair)
|
||||||
|
..where((e) => e.isActive.equals(true)))
|
||||||
|
.write(SnLocalKeyPairCompanion(isActive: Value(false)));
|
||||||
|
|
||||||
|
await _dt.db.snLocalKeyPair.insertOne(
|
||||||
|
SnLocalKeyPairCompanion.insert(
|
||||||
|
id: kpMeta.id,
|
||||||
|
accountId: kpMeta.accountId,
|
||||||
|
publicKey: kpMeta.publicKey,
|
||||||
|
privateKey: Value(kpMeta.privateKey),
|
||||||
|
isActive: Value(true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await reloadActive(autoEnroll: false);
|
||||||
|
|
||||||
|
return kpMeta;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/logger.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/types/link.dart';
|
import 'package:surface/types/link.dart';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ class SnLinkPreviewProvider {
|
|||||||
final target = b64.encode(url);
|
final target = b64.encode(url);
|
||||||
if (_cache.containsKey(target)) return _cache[target];
|
if (_cache.containsKey(target)) return _cache[target];
|
||||||
|
|
||||||
log('[LinkPreview] Fetching $url ($target)');
|
logging.debug('[LinkPreview] Fetching $url ($target)');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final resp = await _sn.client.get('/cgi/re/link/$target');
|
final resp = await _sn.client.get('/cgi/re/link/$target');
|
||||||
@@ -28,7 +28,7 @@ class SnLinkPreviewProvider {
|
|||||||
_cache[url] = meta;
|
_cache[url] = meta;
|
||||||
return meta;
|
return meta;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('[LinkPreview] Failed to fetch $url ($target)...');
|
logging.warning('[LinkPreview] Failed to fetch $url ($target)...', err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:surface/types/realm.dart';
|
||||||
|
|
||||||
|
class AppNavListItem {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final String screen;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const AppNavListItem({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.screen,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class AppNavDestination {
|
class AppNavDestination {
|
||||||
final String label;
|
final String label;
|
||||||
@@ -24,13 +39,10 @@ class NavigationProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
int? get currentIndex => _currentIndex;
|
int? get currentIndex => _currentIndex;
|
||||||
|
|
||||||
static const List<String> kShowBottomNavScreen = [
|
List<String> get showBottomNavScreen => destinations
|
||||||
'home',
|
.where((ele) => ele.isPinned)
|
||||||
'explore',
|
.map((ele) => ele.screen)
|
||||||
'account',
|
.toList();
|
||||||
'album',
|
|
||||||
'chat',
|
|
||||||
];
|
|
||||||
|
|
||||||
static const List<AppNavDestination> kAllDestination = [
|
static const List<AppNavDestination> kAllDestination = [
|
||||||
AppNavDestination(
|
AppNavDestination(
|
||||||
@@ -48,11 +60,6 @@ class NavigationProvider extends ChangeNotifier {
|
|||||||
screen: 'chat',
|
screen: 'chat',
|
||||||
label: 'screenChat',
|
label: 'screenChat',
|
||||||
),
|
),
|
||||||
AppNavDestination(
|
|
||||||
icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
|
|
||||||
screen: 'account',
|
|
||||||
label: 'screenAccount',
|
|
||||||
),
|
|
||||||
AppNavDestination(
|
AppNavDestination(
|
||||||
icon: Icon(Symbols.group, weight: 400, opticalSize: 20),
|
icon: Icon(Symbols.group, weight: 400, opticalSize: 20),
|
||||||
screen: 'realm',
|
screen: 'realm',
|
||||||
@@ -64,31 +71,16 @@ class NavigationProvider extends ChangeNotifier {
|
|||||||
label: 'screenNews',
|
label: 'screenNews',
|
||||||
),
|
),
|
||||||
AppNavDestination(
|
AppNavDestination(
|
||||||
icon: Icon(Symbols.emoji_emotions, weight: 400, opticalSize: 20),
|
icon: Icon(Symbols.settings, weight: 400, opticalSize: 20),
|
||||||
screen: 'stickers',
|
screen: 'settings',
|
||||||
label: 'screenStickers',
|
label: 'screenSettings',
|
||||||
),
|
|
||||||
AppNavDestination(
|
|
||||||
icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20),
|
|
||||||
screen: 'album',
|
|
||||||
label: 'screenAlbum',
|
|
||||||
),
|
|
||||||
AppNavDestination(
|
|
||||||
icon: Icon(Symbols.diversity_4, weight: 400, opticalSize: 20),
|
|
||||||
screen: 'friend',
|
|
||||||
label: 'screenFriend',
|
|
||||||
),
|
|
||||||
AppNavDestination(
|
|
||||||
icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20),
|
|
||||||
screen: 'notification',
|
|
||||||
label: 'screenNotification',
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
static const List<String> kDefaultPinnedDestination = [
|
static const List<String> kDefaultPinnedDestination = [
|
||||||
'home',
|
'home',
|
||||||
'explore',
|
'explore',
|
||||||
'chat',
|
'chat',
|
||||||
'account',
|
'realm',
|
||||||
];
|
];
|
||||||
|
|
||||||
List<AppNavDestination> destinations = [];
|
List<AppNavDestination> destinations = [];
|
||||||
@@ -143,4 +135,11 @@ class NavigationProvider extends ChangeNotifier {
|
|||||||
_currentIndex = idx;
|
_currentIndex = idx;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SnRealm? focusedRealm;
|
||||||
|
|
||||||
|
void setFocusedRealm(SnRealm? realm) {
|
||||||
|
focusedRealm = realm;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'dart:developer';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@@ -9,6 +9,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_udid/flutter_udid.dart';
|
import 'package:flutter_udid/flutter_udid.dart';
|
||||||
import 'package:local_notifier/local_notifier.dart';
|
import 'package:local_notifier/local_notifier.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/logger.dart';
|
||||||
import 'package:surface/providers/config.dart';
|
import 'package:surface/providers/config.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/providers/userinfo.dart';
|
import 'package:surface/providers/userinfo.dart';
|
||||||
@@ -22,6 +23,8 @@ class NotificationProvider extends ChangeNotifier {
|
|||||||
late final WebSocketProvider _ws;
|
late final WebSocketProvider _ws;
|
||||||
late final ConfigProvider _cfg;
|
late final ConfigProvider _cfg;
|
||||||
|
|
||||||
|
final AudioPlayer _notifySoundPlayer = AudioPlayer(playerId: 'notify-sound');
|
||||||
|
|
||||||
NotificationProvider(BuildContext context) {
|
NotificationProvider(BuildContext context) {
|
||||||
_sn = context.read<SnNetworkProvider>();
|
_sn = context.read<SnNetworkProvider>();
|
||||||
_ua = context.read<UserProvider>();
|
_ua = context.read<UserProvider>();
|
||||||
@@ -48,11 +51,13 @@ class NotificationProvider extends ChangeNotifier {
|
|||||||
var deviceUuid = await FlutterUdid.consistentUdid;
|
var deviceUuid = await FlutterUdid.consistentUdid;
|
||||||
|
|
||||||
if (deviceUuid.isEmpty) {
|
if (deviceUuid.isEmpty) {
|
||||||
log("Unable to active push notifications, couldn't get device uuid");
|
logging.warning(
|
||||||
|
'[Push Notification] Unable to active push notifications, couldn\'t get device uuid');
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
log('Device UUID is $deviceUuid');
|
logging.info('[Push Notification] Device UUID is $deviceUuid');
|
||||||
log('Registering device push notifications...');
|
logging
|
||||||
|
.info('[Push Notification] Registering device push notifications...');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Platform.isIOS || Platform.isMacOS) {
|
if (Platform.isIOS || Platform.isMacOS) {
|
||||||
@@ -62,16 +67,21 @@ class NotificationProvider extends ChangeNotifier {
|
|||||||
provider = 'fcm';
|
provider = 'fcm';
|
||||||
token = await FirebaseMessaging.instance.getToken();
|
token = await FirebaseMessaging.instance.getToken();
|
||||||
}
|
}
|
||||||
log('Device Push Token is $token');
|
logging.info('[Push Notification] Device Push Token is $token');
|
||||||
|
|
||||||
await _sn.client.post(
|
try {
|
||||||
'/cgi/id/notifications/subscription',
|
await _sn.client.post(
|
||||||
data: {
|
'/cgi/id/notifications/subscription',
|
||||||
'provider': provider,
|
data: {
|
||||||
'device_token': token,
|
'provider': provider,
|
||||||
'device_id': deviceUuid,
|
'device_token': token,
|
||||||
},
|
'device_id': deviceUuid
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logging.error(
|
||||||
|
'[Push Notification] Unable to register push notifications: $err');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int showingCount = 0;
|
int showingCount = 0;
|
||||||
@@ -89,6 +99,16 @@ class NotificationProvider extends ChangeNotifier {
|
|||||||
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
|
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
|
||||||
if (doHaptic) HapticFeedback.mediumImpact();
|
if (doHaptic) HapticFeedback.mediumImpact();
|
||||||
|
|
||||||
|
// April fool notification sfx
|
||||||
|
if (_cfg.prefs.getBool(kAppAprilFoolFeatures) ?? true) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (now.day == 1 && now.month == 4) {
|
||||||
|
_notifySoundPlayer.play(
|
||||||
|
AssetSource('audio/notify/metal-pipe.mp3'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (notification.topic == 'messaging.message' &&
|
if (notification.topic == 'messaging.message' &&
|
||||||
skippableNotifyChannel != null) {
|
skippableNotifyChannel != null) {
|
||||||
if (notification.metadata['channel_id'] != null &&
|
if (notification.metadata['channel_id'] != null &&
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class SnPostContentProvider {
|
|||||||
|
|
||||||
Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
|
Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
|
||||||
Set<String> rids = {};
|
Set<String> rids = {};
|
||||||
|
Set<int> uids = {};
|
||||||
for (var i = 0; i < out.length; i++) {
|
for (var i = 0; i < out.length; i++) {
|
||||||
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
|
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
|
||||||
if (out[i].body['thumbnail'] != null) {
|
if (out[i].body['thumbnail'] != null) {
|
||||||
@@ -41,6 +42,9 @@ class SnPostContentProvider {
|
|||||||
repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
|
repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (out[i].publisher.type == 0) {
|
||||||
|
uids.add(out[i].publisher.accountId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final attachments = await _attach.getMultiple(rids.toList());
|
final attachments = await _attach.getMultiple(rids.toList());
|
||||||
@@ -56,24 +60,32 @@ class SnPostContentProvider {
|
|||||||
|
|
||||||
out[i] = out[i].copyWith(
|
out[i] = out[i].copyWith(
|
||||||
preload: SnPostPreload(
|
preload: SnPostPreload(
|
||||||
thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull,
|
thumbnail: attachments
|
||||||
attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(),
|
.where((ele) => ele?.rid == out[i].body['thumbnail'])
|
||||||
video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull,
|
.firstOrNull,
|
||||||
|
attachments: attachments
|
||||||
|
.where((ele) =>
|
||||||
|
out[i].body['attachments']?.contains(ele?.rid) ?? false)
|
||||||
|
.toList(),
|
||||||
|
video: attachments
|
||||||
|
.where((ele) => ele?.rid == out[i].body['video'])
|
||||||
|
.firstOrNull,
|
||||||
poll: poll,
|
poll: poll,
|
||||||
realm: realm,
|
realm: realm,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _ud.listAccount(
|
uids.addAll(
|
||||||
attachments.where((ele) => ele != null).map((ele) => ele!.accountId).toSet(),
|
attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
|
||||||
);
|
await _ud.listAccount(uids);
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
|
Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
|
||||||
Set<String> rids = {};
|
Set<String> rids = {};
|
||||||
|
Set<int> uids = {};
|
||||||
rids.addAll(out.body['attachments']?.cast<String>() ?? []);
|
rids.addAll(out.body['attachments']?.cast<String>() ?? []);
|
||||||
if (out.body['thumbnail'] != null) {
|
if (out.body['thumbnail'] != null) {
|
||||||
rids.add(out.body['thumbnail']);
|
rids.add(out.body['thumbnail']);
|
||||||
@@ -86,6 +98,9 @@ class SnPostContentProvider {
|
|||||||
repostTo: await _preloadRelatedDataSingle(out.repostTo!),
|
repostTo: await _preloadRelatedDataSingle(out.repostTo!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (out.publisher.type == 0) {
|
||||||
|
uids.add(out.publisher.accountId);
|
||||||
|
}
|
||||||
|
|
||||||
final attachments = await _attach.getMultiple(rids.toList());
|
final attachments = await _attach.getMultiple(rids.toList());
|
||||||
|
|
||||||
@@ -100,14 +115,25 @@ class SnPostContentProvider {
|
|||||||
|
|
||||||
out = out.copyWith(
|
out = out.copyWith(
|
||||||
preload: SnPostPreload(
|
preload: SnPostPreload(
|
||||||
thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
|
thumbnail: attachments
|
||||||
attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
|
.where((ele) => ele?.rid == out.body['thumbnail'])
|
||||||
video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull,
|
.firstOrNull,
|
||||||
|
attachments: attachments
|
||||||
|
.where(
|
||||||
|
(ele) => out.body['attachments']?.contains(ele?.rid) ?? false)
|
||||||
|
.toList(),
|
||||||
|
video: attachments
|
||||||
|
.where((ele) => ele?.rid == out.body['video'])
|
||||||
|
.firstOrNull,
|
||||||
poll: poll,
|
poll: poll,
|
||||||
realm: realm,
|
realm: realm,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
uids.addAll(
|
||||||
|
attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
|
||||||
|
await _ud.listAccount(uids);
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +145,36 @@ class SnPostContentProvider {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<SnFeedEntry>> getFeed({int take = 20, DateTime? cursor}) async {
|
||||||
|
final resp =
|
||||||
|
await _sn.client.get('/cgi/co/recommendations/feed', queryParameters: {
|
||||||
|
'take': take,
|
||||||
|
if (cursor != null) 'cursor': cursor.toUtc().millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
final List<SnFeedEntry> out =
|
||||||
|
List.from(resp.data.map((ele) => SnFeedEntry.fromJson(ele)));
|
||||||
|
|
||||||
|
List<SnPost> posts = List.empty(growable: true);
|
||||||
|
for (var idx = 0; idx < out.length; idx++) {
|
||||||
|
final ele = out[idx];
|
||||||
|
if (ele.type == 'interactive.post') {
|
||||||
|
posts.add(SnPost.fromJson(ele.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
posts = await _preloadRelatedDataInBatch(posts);
|
||||||
|
|
||||||
|
var postsIdx = 0;
|
||||||
|
for (var idx = 0; idx < out.length; idx++) {
|
||||||
|
final ele = out[idx];
|
||||||
|
if (ele.type == 'interactive.post') {
|
||||||
|
out[idx] = ele.copyWith(data: posts[postsIdx].toJson());
|
||||||
|
postsIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
Future<(List<SnPost>, int)> listPosts({
|
Future<(List<SnPost>, int)> listPosts({
|
||||||
int take = 10,
|
int take = 10,
|
||||||
int offset = 0,
|
int offset = 0,
|
||||||
@@ -128,17 +184,25 @@ class SnPostContentProvider {
|
|||||||
Iterable<String>? tags,
|
Iterable<String>? tags,
|
||||||
String? realm,
|
String? realm,
|
||||||
String? channel,
|
String? channel,
|
||||||
|
bool isDraft = false,
|
||||||
|
bool isShuffle = false,
|
||||||
}) async {
|
}) async {
|
||||||
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
|
final resp = await _sn.client.get(
|
||||||
'take': take,
|
isShuffle
|
||||||
'offset': offset,
|
? '/cgi/co/recommendations/shuffle'
|
||||||
if (type != null) 'type': type,
|
: '/cgi/co/posts${isDraft ? '/drafts' : ''}',
|
||||||
if (author != null) 'author': author,
|
queryParameters: {
|
||||||
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
|
'take': take,
|
||||||
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
|
'offset': offset,
|
||||||
if (realm != null) 'realm': realm,
|
if (type != null) 'type': type,
|
||||||
if (channel != null) 'channel': channel,
|
if (author != null) 'author': author,
|
||||||
});
|
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
|
||||||
|
if (categories?.isNotEmpty ?? false)
|
||||||
|
'categories': categories!.join(','),
|
||||||
|
if (realm != null) 'realm': realm,
|
||||||
|
if (channel != null) 'channel': channel,
|
||||||
|
},
|
||||||
|
);
|
||||||
final List<SnPost> out = await _preloadRelatedDataInBatch(
|
final List<SnPost> out = await _preloadRelatedDataInBatch(
|
||||||
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
|
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
|
||||||
);
|
);
|
||||||
@@ -151,7 +215,8 @@ class SnPostContentProvider {
|
|||||||
int take = 10,
|
int take = 10,
|
||||||
int offset = 0,
|
int offset = 0,
|
||||||
}) async {
|
}) async {
|
||||||
final resp = await _sn.client.get('/cgi/co/posts/$parentId/replies', queryParameters: {
|
final resp = await _sn.client
|
||||||
|
.get('/cgi/co/posts/$parentId/replies', queryParameters: {
|
||||||
'take': take,
|
'take': take,
|
||||||
'offset': offset,
|
'offset': offset,
|
||||||
});
|
});
|
||||||
@@ -190,4 +255,9 @@ class SnPostContentProvider {
|
|||||||
);
|
);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<SnPost> completePostData(SnPost post) async {
|
||||||
|
final out = await _preloadRelatedDataSingle(post);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:cross_file/cross_file.dart';
|
import 'package:cross_file/cross_file.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/database/database.dart';
|
||||||
|
import 'package:surface/providers/database.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/types/attachment.dart';
|
import 'package:surface/types/attachment.dart';
|
||||||
|
|
||||||
@@ -13,10 +16,12 @@ const kConcurrentUploadChunks = 5;
|
|||||||
|
|
||||||
class SnAttachmentProvider {
|
class SnAttachmentProvider {
|
||||||
late final SnNetworkProvider _sn;
|
late final SnNetworkProvider _sn;
|
||||||
|
late final DatabaseProvider _dt;
|
||||||
final Map<String, SnAttachment> _cache = {};
|
final Map<String, SnAttachment> _cache = {};
|
||||||
|
|
||||||
SnAttachmentProvider(BuildContext context) {
|
SnAttachmentProvider(BuildContext context) {
|
||||||
_sn = context.read<SnNetworkProvider>();
|
_sn = context.read<SnNetworkProvider>();
|
||||||
|
_dt = context.read<DatabaseProvider>();
|
||||||
}
|
}
|
||||||
|
|
||||||
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
|
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
|
||||||
@@ -28,21 +33,33 @@ class SnAttachmentProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<SnAttachment> getOne(String rid, {noCache = false}) async {
|
Future<SnAttachment> getOne(String rid, {noCache = false}) async {
|
||||||
|
// In-memory cache
|
||||||
if (!noCache && _cache.containsKey(rid)) {
|
if (!noCache && _cache.containsKey(rid)) {
|
||||||
return _cache[rid]!;
|
return _cache[rid]!;
|
||||||
}
|
}
|
||||||
|
// On-disk cache
|
||||||
|
final dbResp = await (_dt.db.snLocalAttachment.select()
|
||||||
|
..where((e) => e.rid.equals(rid))
|
||||||
|
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (dbResp != null) {
|
||||||
|
_cache[rid] = dbResp.content;
|
||||||
|
return dbResp.content;
|
||||||
|
}
|
||||||
|
// Remote server
|
||||||
final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
|
final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
|
||||||
final out = SnAttachment.fromJson(resp.data);
|
final out = SnAttachment.fromJson(resp.data);
|
||||||
if (out.isAnalyzed) {
|
if (out.isAnalyzed) {
|
||||||
_cache[rid] = out;
|
_cache[rid] = out;
|
||||||
}
|
}
|
||||||
|
_saveToLocal([out]);
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<SnAttachment?>> getMultiple(List<String> rids,
|
Future<List<SnAttachment?>> getMultiple(List<String> rids,
|
||||||
{noCache = false}) async {
|
{bool noCache = false}) async {
|
||||||
|
// In-memory cache
|
||||||
final result = List<SnAttachment?>.filled(rids.length, null);
|
final result = List<SnAttachment?>.filled(rids.length, null);
|
||||||
final Map<String, int> randomMapping = {};
|
final Map<String, int> randomMapping = {};
|
||||||
for (int i = 0; i < rids.length; i++) {
|
for (int i = 0; i < rids.length; i++) {
|
||||||
@@ -53,29 +70,44 @@ class SnAttachmentProvider {
|
|||||||
result[i] = _cache[rid]!;
|
result[i] = _cache[rid]!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final pendingFetch = randomMapping.keys;
|
var pendingFetch = randomMapping.keys;
|
||||||
|
// On-disk cache
|
||||||
if (pendingFetch.isNotEmpty) {
|
if (pendingFetch.isEmpty) return result;
|
||||||
final resp = await _sn.client.get(
|
if (!noCache) {
|
||||||
'/cgi/uc/attachments',
|
final dbResp = await (_dt.db.snLocalAttachment.select()
|
||||||
queryParameters: {
|
..where((e) => e.rid.isIn(pendingFetch))
|
||||||
'take': pendingFetch.length,
|
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
|
||||||
'id': pendingFetch.join(','),
|
.get();
|
||||||
},
|
for (final item in dbResp) {
|
||||||
);
|
if (item.content.isAnalyzed) {
|
||||||
final List<SnAttachment?> out = resp.data['data']
|
_cache[item.rid] = item.content;
|
||||||
.map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e))
|
|
||||||
.cast<SnAttachment?>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
for (final item in out) {
|
|
||||||
if (item == null) continue;
|
|
||||||
if (item.isAnalyzed) {
|
|
||||||
_cache[item.rid] = item;
|
|
||||||
}
|
}
|
||||||
result[randomMapping[item.rid]!] = item;
|
result[randomMapping[item.rid]!] = item.content;
|
||||||
|
randomMapping.remove(item.rid);
|
||||||
}
|
}
|
||||||
|
pendingFetch = randomMapping.keys;
|
||||||
}
|
}
|
||||||
|
// Remote server
|
||||||
|
if (pendingFetch.isEmpty) return result;
|
||||||
|
final resp = await _sn.client.get(
|
||||||
|
'/cgi/uc/attachments',
|
||||||
|
queryParameters: {
|
||||||
|
'take': pendingFetch.length,
|
||||||
|
'id': pendingFetch.join(','),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final List<SnAttachment?> out = resp.data['data']
|
||||||
|
.map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e))
|
||||||
|
.cast<SnAttachment?>()
|
||||||
|
.toList();
|
||||||
|
for (final item in out) {
|
||||||
|
if (item == null) continue;
|
||||||
|
if (item.isAnalyzed) {
|
||||||
|
_cache[item.rid] = item;
|
||||||
|
}
|
||||||
|
result[randomMapping[item.rid]!] = item;
|
||||||
|
}
|
||||||
|
_saveToLocal(out.where((ele) => ele != null).cast());
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -274,6 +306,31 @@ class SnAttachmentProvider {
|
|||||||
'metadata': metadata ?? item.usermeta,
|
'metadata': metadata ?? item.usermeta,
|
||||||
'is_indexable': isIndexable ?? item.isIndexable,
|
'is_indexable': isIndexable ?? item.isIndexable,
|
||||||
});
|
});
|
||||||
return SnAttachment.fromJson(resp.data);
|
final out = SnAttachment.fromJson(resp.data);
|
||||||
|
_saveToLocal([out]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveToLocal(Iterable<SnAttachment> out) async {
|
||||||
|
for (final ele in out) {
|
||||||
|
if (!ele.isAnalyzed || ele.destination == 0) continue;
|
||||||
|
await _dt.db.snLocalAttachment.insertOne(
|
||||||
|
SnLocalAttachmentCompanion.insert(
|
||||||
|
id: Value(ele.id),
|
||||||
|
rid: ele.rid,
|
||||||
|
uuid: ele.uuid,
|
||||||
|
content: ele,
|
||||||
|
accountId: ele.accountId,
|
||||||
|
cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
onConflict: DoUpdate(
|
||||||
|
(_) => SnLocalAttachmentCompanion.custom(
|
||||||
|
content: Constant(jsonEncode(ele.toJson())),
|
||||||
|
cacheExpiredAt:
|
||||||
|
Constant(DateTime.now().add(const Duration(hours: 1))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
@@ -11,9 +10,26 @@ import 'package:package_info_plus/package_info_plus.dart';
|
|||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.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:surface/logger.dart';
|
||||||
import 'package:surface/providers/config.dart';
|
import 'package:surface/providers/config.dart';
|
||||||
import 'package:surface/providers/widget.dart';
|
import 'package:surface/providers/widget.dart';
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart';
|
||||||
|
import 'package:talker_dio_logger/talker_dio_logger_settings.dart';
|
||||||
|
|
||||||
|
enum ServiceStatus { operational, downgraded, failed }
|
||||||
|
|
||||||
|
const Map<String, String> kServicesName = {
|
||||||
|
'ai': 'Insights',
|
||||||
|
'co': 'Interactive',
|
||||||
|
're': 'Reader',
|
||||||
|
'im': 'Messaging',
|
||||||
|
'ma': 'Matrix',
|
||||||
|
'uc': 'Paperclip',
|
||||||
|
'wa': 'Wallet',
|
||||||
|
'id': 'Passport',
|
||||||
|
'pusher': 'Pusher',
|
||||||
|
};
|
||||||
|
|
||||||
const kNetworkServerDirectory = [
|
const kNetworkServerDirectory = [
|
||||||
('Solar Network', 'https://api.sn.solsynth.dev'),
|
('Solar Network', 'https://api.sn.solsynth.dev'),
|
||||||
@@ -36,6 +52,19 @@ class SnNetworkProvider {
|
|||||||
|
|
||||||
client = Dio();
|
client = Dio();
|
||||||
|
|
||||||
|
client.interceptors.add(
|
||||||
|
TalkerDioLogger(
|
||||||
|
talker: logging,
|
||||||
|
settings: const TalkerDioLoggerSettings(
|
||||||
|
printRequestHeaders: false,
|
||||||
|
printResponseHeaders: false,
|
||||||
|
printResponseMessage: false,
|
||||||
|
printResponseData: false,
|
||||||
|
printRequestData: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
client.interceptors.add(RetryInterceptor(
|
client.interceptors.add(RetryInterceptor(
|
||||||
dio: client,
|
dio: client,
|
||||||
retries: 3,
|
retries: 3,
|
||||||
@@ -69,7 +98,6 @@ class SnNetworkProvider {
|
|||||||
_prefs = _config.prefs;
|
_prefs = _config.prefs;
|
||||||
client.options.baseUrl = _config.serverUrl;
|
client.options.baseUrl = _config.serverUrl;
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Dio> createOffContextClient() async {
|
static Future<Dio> createOffContextClient() async {
|
||||||
@@ -91,7 +119,8 @@ class SnNetworkProvider {
|
|||||||
RequestOptions options,
|
RequestOptions options,
|
||||||
RequestInterceptorHandler handler,
|
RequestInterceptorHandler handler,
|
||||||
) async {
|
) async {
|
||||||
final atk = await _getFreshAtk(client, prefs.getString(kAtkStoreKey), prefs.getString(kRtkStoreKey), (atk, rtk) {
|
final atk = await _getFreshAtk(client, prefs.getString(kAtkStoreKey),
|
||||||
|
prefs.getString(kRtkStoreKey), (atk, rtk) {
|
||||||
prefs.setString(kAtkStoreKey, atk);
|
prefs.setString(kAtkStoreKey, atk);
|
||||||
prefs.setString(kRtkStoreKey, rtk);
|
prefs.setString(kRtkStoreKey, rtk);
|
||||||
});
|
});
|
||||||
@@ -103,7 +132,8 @@ class SnNetworkProvider {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
client.options.baseUrl = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
|
client.options.baseUrl =
|
||||||
|
prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
@@ -119,7 +149,8 @@ class SnNetworkProvider {
|
|||||||
platformInfo = 'Web; ${deviceInfo.vendor}';
|
platformInfo = 'Web; ${deviceInfo.vendor}';
|
||||||
} else if (Platform.isAndroid) {
|
} else if (Platform.isAndroid) {
|
||||||
final deviceInfo = await DeviceInfoPlugin().androidInfo;
|
final deviceInfo = await DeviceInfoPlugin().androidInfo;
|
||||||
platformInfo = 'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}';
|
platformInfo =
|
||||||
|
'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}';
|
||||||
} else if (Platform.isIOS) {
|
} else if (Platform.isIOS) {
|
||||||
final deviceInfo = await DeviceInfoPlugin().iosInfo;
|
final deviceInfo = await DeviceInfoPlugin().iosInfo;
|
||||||
platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}';
|
platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}';
|
||||||
@@ -128,7 +159,8 @@ class SnNetworkProvider {
|
|||||||
platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}';
|
platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}';
|
||||||
} else if (Platform.isWindows) {
|
} else if (Platform.isWindows) {
|
||||||
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
|
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
|
||||||
platformInfo = 'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
|
platformInfo =
|
||||||
|
'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
|
||||||
} else if (Platform.isLinux) {
|
} else if (Platform.isLinux) {
|
||||||
final deviceInfo = await DeviceInfoPlugin().linuxInfo;
|
final deviceInfo = await DeviceInfoPlugin().linuxInfo;
|
||||||
platformInfo = 'Linux; ${deviceInfo.prettyName}';
|
platformInfo = 'Linux; ${deviceInfo.prettyName}';
|
||||||
@@ -148,12 +180,15 @@ class SnNetworkProvider {
|
|||||||
final tkLock = Lock();
|
final tkLock = Lock();
|
||||||
|
|
||||||
Future<String?> getFreshAtk() async {
|
Future<String?> getFreshAtk() async {
|
||||||
return await _getFreshAtk(client, _prefs.getString(kAtkStoreKey), _prefs.getString(kRtkStoreKey), (atk, rtk) {
|
return await _getFreshAtk(
|
||||||
|
client, _prefs.getString(kAtkStoreKey), _prefs.getString(kRtkStoreKey),
|
||||||
|
(atk, rtk) {
|
||||||
setTokenPair(atk, rtk);
|
setTokenPair(atk, rtk);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<String?> _getFreshAtk(Dio client, String? atk, String? rtk, Function(String atk, String rtk)? onRefresh) async {
|
static Future<String?> _getFreshAtk(Dio client, String? atk, String? rtk,
|
||||||
|
Function(String atk, String rtk)? onRefresh) async {
|
||||||
if (_refreshCompleter != null) {
|
if (_refreshCompleter != null) {
|
||||||
return await _refreshCompleter!.future;
|
return await _refreshCompleter!.future;
|
||||||
} else {
|
} else {
|
||||||
@@ -185,7 +220,8 @@ class SnNetworkProvider {
|
|||||||
final payload = b64.decode(rawPayload);
|
final payload = b64.decode(rawPayload);
|
||||||
final exp = jsonDecode(payload)['exp'];
|
final exp = jsonDecode(payload)['exp'];
|
||||||
if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
|
if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
|
||||||
log('Access token need refresh, doing it at ${DateTime.now()}');
|
logging.debug(
|
||||||
|
'[Auth] Access token need refresh, doing it at ${DateTime.now()}');
|
||||||
final result = await _refreshToken(client.options.baseUrl, rtk);
|
final result = await _refreshToken(client.options.baseUrl, rtk);
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
atk = null;
|
atk = null;
|
||||||
@@ -199,12 +235,12 @@ class SnNetworkProvider {
|
|||||||
_refreshCompleter!.complete(atk);
|
_refreshCompleter!.complete(atk);
|
||||||
return atk;
|
return atk;
|
||||||
} else {
|
} else {
|
||||||
log('Access token refresh failed...');
|
logging.error('[Auth] Access token refresh failed...');
|
||||||
_refreshCompleter!.complete(null);
|
_refreshCompleter!.complete(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('Failed to authenticate user: $err');
|
logging.error('[Auth] Failed to authenticate user...', err);
|
||||||
_refreshCompleter!.completeError(err);
|
_refreshCompleter!.completeError(err);
|
||||||
} finally {
|
} finally {
|
||||||
_refreshCompleter = null;
|
_refreshCompleter = null;
|
||||||
@@ -237,7 +273,8 @@ class SnNetworkProvider {
|
|||||||
return result.$1;
|
return result.$1;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<(String, String)?> _refreshToken(String baseUrl, String? rtk) async {
|
static Future<(String, String)?> _refreshToken(
|
||||||
|
String baseUrl, String? rtk) async {
|
||||||
if (rtk == null) return null;
|
if (rtk == null) return null;
|
||||||
|
|
||||||
final dio = Dio();
|
final dio = Dio();
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/database/database.dart';
|
||||||
|
import 'package:surface/providers/database.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/types/realm.dart';
|
import 'package:surface/types/realm.dart';
|
||||||
|
|
||||||
class SnRealmProvider {
|
class SnRealmProvider extends ChangeNotifier {
|
||||||
late final SnNetworkProvider _sn;
|
late final SnNetworkProvider _sn;
|
||||||
|
late final DatabaseProvider _dt;
|
||||||
|
|
||||||
SnRealmProvider(BuildContext context) {
|
SnRealmProvider(BuildContext context) {
|
||||||
_sn = context.read<SnNetworkProvider>();
|
_sn = context.read<SnNetworkProvider>();
|
||||||
|
_dt = context.read<DatabaseProvider>();
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, SnRealm> _cache = {};
|
final Map<String, SnRealm> _cache = {};
|
||||||
|
List<SnRealm> _availableRealms = List.empty(growable: true);
|
||||||
|
|
||||||
|
Future<void> refreshAvailableRealms() async {
|
||||||
|
_availableRealms = await listAvailableRealms();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SnRealm> get availableRealms => _availableRealms;
|
||||||
|
|
||||||
Future<List<SnRealm>> listAvailableRealms() async {
|
Future<List<SnRealm>> listAvailableRealms() async {
|
||||||
final resp = await _sn.client.get('/cgi/id/realms/me/available');
|
final resp = await _sn.client.get('/cgi/id/realms/me/available');
|
||||||
@@ -21,17 +35,56 @@ class SnRealmProvider {
|
|||||||
_cache[realm.alias] = realm;
|
_cache[realm.alias] = realm;
|
||||||
_cache[realm.id.toString()] = realm;
|
_cache[realm.id.toString()] = realm;
|
||||||
}
|
}
|
||||||
|
_saveToLocal(out);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void addAvailableRealm(SnRealm realm) {
|
||||||
|
_availableRealms.add(realm);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<SnRealm> getRealm(dynamic aliasOrId) async {
|
Future<SnRealm> getRealm(dynamic aliasOrId) async {
|
||||||
if (_cache.containsKey(aliasOrId.toString())) {
|
if (_cache.containsKey(aliasOrId.toString())) {
|
||||||
return _cache[aliasOrId.toString()]!;
|
return _cache[aliasOrId.toString()]!;
|
||||||
}
|
}
|
||||||
|
final localResp = await (_dt.db.snLocalRealm.select()
|
||||||
|
..where((e) =>
|
||||||
|
e.id.equals(aliasOrId is int ? aliasOrId : 0) |
|
||||||
|
e.alias.equals(aliasOrId.toString()))
|
||||||
|
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (localResp != null) {
|
||||||
|
_cache[localResp.content.id.toString()] = localResp.content;
|
||||||
|
_cache[localResp.content.alias] = localResp.content;
|
||||||
|
return localResp.content;
|
||||||
|
}
|
||||||
final resp = await _sn.client.get('/cgi/id/realms/$aliasOrId');
|
final resp = await _sn.client.get('/cgi/id/realms/$aliasOrId');
|
||||||
final out = SnRealm.fromJson(resp.data);
|
final out = SnRealm.fromJson(resp.data);
|
||||||
_cache[out.alias] = out;
|
_cache[out.alias] = out;
|
||||||
_cache[out.id.toString()] = out;
|
_cache[out.id.toString()] = out;
|
||||||
|
_saveToLocal([out]);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveToLocal(Iterable<SnRealm> out) async {
|
||||||
|
for (final ele in out) {
|
||||||
|
await _dt.db.snLocalRealm.insertOne(
|
||||||
|
SnLocalRealmCompanion.insert(
|
||||||
|
id: Value(ele.id),
|
||||||
|
alias: ele.alias,
|
||||||
|
content: ele,
|
||||||
|
accountId: ele.accountId,
|
||||||
|
cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
onConflict: DoUpdate(
|
||||||
|
(_) => SnLocalRealmCompanion.custom(
|
||||||
|
content: Constant(jsonEncode(ele.toJson())),
|
||||||
|
cacheExpiredAt:
|
||||||
|
Constant(DateTime.now().add(const Duration(hours: 1))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import 'dart:developer';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/database/database.dart';
|
||||||
|
import 'package:surface/logger.dart';
|
||||||
|
import 'package:surface/providers/database.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/types/attachment.dart';
|
import 'package:surface/types/attachment.dart';
|
||||||
|
|
||||||
class SnStickerProvider {
|
class SnStickerProvider {
|
||||||
late final SnNetworkProvider _sn;
|
late final SnNetworkProvider _sn;
|
||||||
|
late final DatabaseProvider _dt;
|
||||||
final Map<String, SnSticker?> _cache = {};
|
final Map<String, SnSticker?> _cache = {};
|
||||||
|
|
||||||
final Map<int, List<SnSticker>> stickersByPack = {};
|
final Map<int, List<SnSticker>> stickersByPack = {};
|
||||||
@@ -16,6 +21,7 @@ class SnStickerProvider {
|
|||||||
|
|
||||||
SnStickerProvider(BuildContext context) {
|
SnStickerProvider(BuildContext context) {
|
||||||
_sn = context.read<SnNetworkProvider>();
|
_sn = context.read<SnNetworkProvider>();
|
||||||
|
_dt = context.read<DatabaseProvider>();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool hasNotSticker(String alias) {
|
bool hasNotSticker(String alias) {
|
||||||
@@ -32,32 +38,54 @@ class SnStickerProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void putSticker(Iterable<SnSticker> sticker) {
|
void putSticker(Iterable<SnSticker> stickers) {
|
||||||
for (final ele in sticker) {
|
for (final ele in stickers) {
|
||||||
_cacheSticker(ele);
|
_cacheSticker(ele);
|
||||||
}
|
}
|
||||||
|
_saveStickerToLocal(stickers);
|
||||||
|
_saveStickerPackToLocal(stickers.map((ele) => ele.pack).toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SnSticker?> lookupSticker(String alias) async {
|
Future<SnSticker?> lookupSticker(String alias) async {
|
||||||
|
// In-memory cache
|
||||||
if (_cache.containsKey(alias)) {
|
if (_cache.containsKey(alias)) {
|
||||||
return _cache[alias];
|
return _cache[alias];
|
||||||
}
|
}
|
||||||
|
// On-disk cache
|
||||||
|
final localStickers = await (_dt.db.snLocalSticker.select()
|
||||||
|
..where((e) => e.fullAlias.equals(alias)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (localStickers != null) {
|
||||||
|
_cache[alias] = localStickers.content;
|
||||||
|
return localStickers.content;
|
||||||
|
}
|
||||||
|
// Remote server
|
||||||
try {
|
try {
|
||||||
final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
|
final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
|
||||||
final sticker = SnSticker.fromJson(resp.data);
|
final sticker = SnSticker.fromJson(resp.data);
|
||||||
_cacheSticker(sticker);
|
putSticker([sticker]);
|
||||||
|
|
||||||
return sticker;
|
return sticker;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_cache[alias] = null;
|
_cache[alias] = null;
|
||||||
log('[Sticker] Failed to lookup sticker $alias: $err');
|
logging.warning('[Sticker] Failed to lookup sticker $alias', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> listSticker() async {
|
Future<void> listSticker() async {
|
||||||
|
final localPacks = await _dt.db.snLocalStickerPack.select().get();
|
||||||
|
final localStickers = await _dt.db.snLocalSticker.select().get();
|
||||||
|
final local = localStickers.map((ele) {
|
||||||
|
return ele.content.copyWith(
|
||||||
|
pack: localPacks
|
||||||
|
.firstWhere((pk) => pk.content.id == ele.content.packId)
|
||||||
|
.content,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
for (final sticker in local) {
|
||||||
|
_cacheSticker(sticker);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final resp = await _sn.client.get('/cgi/uc/stickers');
|
final resp = await _sn.client.get('/cgi/uc/stickers');
|
||||||
final data = resp.data;
|
final data = resp.data;
|
||||||
@@ -66,8 +94,39 @@ class SnStickerProvider {
|
|||||||
_cacheSticker(sticker);
|
_cacheSticker(sticker);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('[Sticker] Failed to list stickers: $err');
|
logging.error('[Sticker] Failed to list stickers...', err);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveStickerToLocal(Iterable<SnSticker> stickers) async {
|
||||||
|
await _dt.db.snLocalSticker.insertAll(
|
||||||
|
stickers.map(
|
||||||
|
(ele) => SnLocalStickerCompanion.insert(
|
||||||
|
id: Value(ele.id),
|
||||||
|
alias: ele.alias,
|
||||||
|
fullAlias: '${ele.pack.prefix}${ele.alias}',
|
||||||
|
content: ele,
|
||||||
|
createdAt: Value(ele.createdAt),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onConflict: DoNothing(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveStickerPackToLocal(Iterable<SnStickerPack> packs) async {
|
||||||
|
final queries = packs
|
||||||
|
.map(
|
||||||
|
(ele) => _dt.db.snLocalStickerPack.insertOne(
|
||||||
|
SnLocalStickerPackCompanion.insert(
|
||||||
|
id: Value(ele.id),
|
||||||
|
content: ele,
|
||||||
|
createdAt: Value(ele.createdAt),
|
||||||
|
),
|
||||||
|
onConflict: DoUpdate((_) => SnLocalStickerPackCompanion.custom(
|
||||||
|
content: Constant(jsonEncode(ele.toJson()))))),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
await Future.wait(queries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,16 @@ class ThemeProvider extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void reloadTheme({Color? seedColorOverride, bool? useMaterial3}) {
|
void reloadTheme({
|
||||||
createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) {
|
Color? seedColorOverride,
|
||||||
|
bool? useMaterial3,
|
||||||
|
String? customFonts,
|
||||||
|
}) {
|
||||||
|
createAppThemeSet(
|
||||||
|
seedColorOverride: seedColorOverride,
|
||||||
|
useMaterial3: useMaterial3,
|
||||||
|
customFonts: customFonts,
|
||||||
|
).then((value) {
|
||||||
theme = value;
|
theme = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
|
|||||||
55
lib/providers/translation.dart
Normal file
55
lib/providers/translation.dart
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:surface/logger.dart';
|
||||||
|
|
||||||
|
const kTranslateApiBaseUrl = 'https://translate.solsynth.dev';
|
||||||
|
|
||||||
|
class SnTranslator {
|
||||||
|
final Dio client = Dio(
|
||||||
|
BaseOptions(
|
||||||
|
baseUrl: kTranslateApiBaseUrl,
|
||||||
|
connectTimeout: Duration(seconds: 3),
|
||||||
|
sendTimeout: Duration(seconds: 3),
|
||||||
|
receiveTimeout: Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Map<String, String> _cache = {};
|
||||||
|
|
||||||
|
Future<String> translate(
|
||||||
|
String text, {
|
||||||
|
required String to,
|
||||||
|
String from = 'auto',
|
||||||
|
bool skipCache = false,
|
||||||
|
}) async {
|
||||||
|
if (text.isEmpty) return text;
|
||||||
|
|
||||||
|
final cacheKey = md5.convert(utf8.encode('$text$from$to')).toString();
|
||||||
|
if (!skipCache && _cache.containsKey(cacheKey)) {
|
||||||
|
return _cache[cacheKey]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.info('[Translator] Translate $text from $from to $to');
|
||||||
|
|
||||||
|
final resp = await client.post(
|
||||||
|
'/translate',
|
||||||
|
data: {
|
||||||
|
'q': text,
|
||||||
|
'source': from,
|
||||||
|
'target': to,
|
||||||
|
'format': 'text',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (resp.statusCode == 200) {
|
||||||
|
final out = resp.data['translatedText'];
|
||||||
|
if (out.isNotEmpty) {
|
||||||
|
logging.info('[Translator] Translated $text from $from to $to');
|
||||||
|
_cache[cacheKey] = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Exception('translate failed: $resp');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,44 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/database/database.dart';
|
||||||
|
import 'package:surface/providers/database.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/types/account.dart';
|
import 'package:surface/types/account.dart';
|
||||||
|
|
||||||
class UserDirectoryProvider {
|
class UserDirectoryProvider {
|
||||||
late final SnNetworkProvider _sn;
|
late final SnNetworkProvider _sn;
|
||||||
|
late final DatabaseProvider _dt;
|
||||||
|
|
||||||
UserDirectoryProvider(BuildContext context) {
|
UserDirectoryProvider(BuildContext context) {
|
||||||
_sn = context.read<SnNetworkProvider>();
|
_sn = context.read<SnNetworkProvider>();
|
||||||
|
_dt = context.read<DatabaseProvider>();
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, int> _idCache = {};
|
final Map<String, int> _idCache = {};
|
||||||
final Map<int, SnAccount> _cache = {};
|
final Map<int, SnAccount> _cache = {};
|
||||||
|
DateTime? _cacheExpiredAt;
|
||||||
|
|
||||||
|
Future<int> loadAccountCache({int max = 100}) async {
|
||||||
|
final out = await (_dt.db.snLocalAccount.select()..limit(max)).get();
|
||||||
|
for (final ele in out) {
|
||||||
|
_cache[ele.id] = ele.content;
|
||||||
|
_idCache[ele.name] = ele.id;
|
||||||
|
}
|
||||||
|
_cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
|
||||||
|
return out.length;
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
|
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
|
||||||
|
// In-memory cache
|
||||||
|
if (_cacheExpiredAt != null && _cacheExpiredAt!.isBefore(DateTime.now())) {
|
||||||
|
_cache.clear();
|
||||||
|
_cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
|
||||||
|
} else {
|
||||||
|
_cacheExpiredAt ??= DateTime.now().add(const Duration(hours: 1));
|
||||||
|
}
|
||||||
final out = List<SnAccount?>.generate(id.length, (e) => null);
|
final out = List<SnAccount?>.generate(id.length, (e) => null);
|
||||||
final plannedQuery = <int>{};
|
final plannedQuery = <int>{};
|
||||||
for (var idx = 0; idx < out.length; idx++) {
|
for (var idx = 0; idx < out.length; idx++) {
|
||||||
@@ -27,8 +52,30 @@ class UserDirectoryProvider {
|
|||||||
plannedQuery.add(item);
|
plannedQuery.add(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final resp = await _sn.client.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});
|
// On-disk cache
|
||||||
final respDecoded = resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList();
|
if (plannedQuery.isEmpty) return out;
|
||||||
|
final dbResp = await (_dt.db.snLocalAccount.select()
|
||||||
|
..where((e) => e.id.isIn(plannedQuery))
|
||||||
|
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))
|
||||||
|
..limit(plannedQuery.length))
|
||||||
|
.get();
|
||||||
|
for (var idx = 0; idx < out.length; idx++) {
|
||||||
|
if (out[idx] != null) continue;
|
||||||
|
if (dbResp.length <= idx) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out[idx] = dbResp[idx].content;
|
||||||
|
_cache[dbResp[idx].id] = dbResp[idx].content;
|
||||||
|
_idCache[dbResp[idx].name] = dbResp[idx].id;
|
||||||
|
plannedQuery.remove(dbResp[idx].id);
|
||||||
|
}
|
||||||
|
// Remote server
|
||||||
|
_saveToLocal(out.where((ele) => ele != null).cast());
|
||||||
|
if (plannedQuery.isEmpty) return out;
|
||||||
|
final resp = await _sn.client
|
||||||
|
.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});
|
||||||
|
final respDecoded =
|
||||||
|
resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList();
|
||||||
var sideIdx = 0;
|
var sideIdx = 0;
|
||||||
for (var idx = 0; idx < out.length; idx++) {
|
for (var idx = 0; idx < out.length; idx++) {
|
||||||
if (out[idx] != null) continue;
|
if (out[idx] != null) continue;
|
||||||
@@ -40,17 +87,29 @@ class UserDirectoryProvider {
|
|||||||
_idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id;
|
_idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id;
|
||||||
sideIdx++;
|
sideIdx++;
|
||||||
}
|
}
|
||||||
|
if (respDecoded.isNotEmpty) _saveToLocal(respDecoded);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SnAccount?> getAccount(dynamic id) async {
|
Future<SnAccount?> getAccount(dynamic id) async {
|
||||||
|
// In-memory cache
|
||||||
if (id is String && _idCache.containsKey(id)) {
|
if (id is String && _idCache.containsKey(id)) {
|
||||||
id = _idCache[id];
|
id = _idCache[id];
|
||||||
}
|
}
|
||||||
if (_cache.containsKey(id)) {
|
if (_cache.containsKey(id)) {
|
||||||
return _cache[id];
|
return _cache[id];
|
||||||
}
|
}
|
||||||
|
// On-disk cache
|
||||||
|
final dbResp = await (_dt.db.snLocalAccount.select()
|
||||||
|
..where((e) => e.id.equals(id))
|
||||||
|
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (dbResp != null) {
|
||||||
|
_cache[dbResp.id] = dbResp.content;
|
||||||
|
_idCache[dbResp.name] = dbResp.id;
|
||||||
|
return dbResp.content;
|
||||||
|
}
|
||||||
|
// Remote server
|
||||||
try {
|
try {
|
||||||
final resp = await _sn.client.get('/cgi/id/users/$id');
|
final resp = await _sn.client.get('/cgi/id/users/$id');
|
||||||
final account = SnAccount.fromJson(
|
final account = SnAccount.fromJson(
|
||||||
@@ -58,16 +117,42 @@ class UserDirectoryProvider {
|
|||||||
);
|
);
|
||||||
_cache[account.id] = account;
|
_cache[account.id] = account;
|
||||||
if (id is String) _idCache[id] = account.id;
|
if (id is String) _idCache[id] = account.id;
|
||||||
|
_saveToLocal([account]);
|
||||||
return account;
|
return account;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SnAccount? getAccountFromCache(dynamic id) {
|
SnAccount? getFromCache(dynamic id) {
|
||||||
if (id is String && _idCache.containsKey(id)) {
|
if (id is String && _idCache.containsKey(id)) {
|
||||||
id = _idCache[id];
|
id = _idCache[id];
|
||||||
}
|
}
|
||||||
return _cache[id];
|
return _cache[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveToLocal(Iterable<SnAccount> out) async {
|
||||||
|
// For better on conflict resolution
|
||||||
|
// And consider the method usually called with usually small amount of data
|
||||||
|
// Use for to insert each record instead of bulk insert
|
||||||
|
List<Future<int>> queries = out.map((ele) {
|
||||||
|
return _dt.db.snLocalAccount.insertOne(
|
||||||
|
SnLocalAccountCompanion.insert(
|
||||||
|
id: Value(ele.id),
|
||||||
|
name: ele.name,
|
||||||
|
content: ele,
|
||||||
|
cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
onConflict: DoUpdate(
|
||||||
|
(_) => SnLocalAccountCompanion.custom(
|
||||||
|
name: Constant(ele.name),
|
||||||
|
content: Constant(jsonEncode(ele.toJson())),
|
||||||
|
cacheExpiredAt:
|
||||||
|
Constant(DateTime.now().add(const Duration(hours: 1))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
await Future.wait(queries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import 'dart:developer';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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:surface/logger.dart';
|
||||||
import 'package:surface/providers/config.dart';
|
import 'package:surface/providers/config.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/types/account.dart';
|
import 'package:surface/types/account.dart';
|
||||||
@@ -30,13 +31,40 @@ class UserProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
refreshUser().then((value) async {
|
refreshUser().then((value) async {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
log('Logged in as @${value.name}');
|
logging.info('[Auth] Logged in as @${value.name}');
|
||||||
log('Atk: ${await atk}');
|
logging.debug('[Auth] Access token: ${await atk}');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> get atkClaims async {
|
||||||
|
final tk = (await atk);
|
||||||
|
if (tk == null) return null;
|
||||||
|
final atkParts = tk.split('.');
|
||||||
|
if (atkParts.length != 3) {
|
||||||
|
throw Exception('invalid format of access token');
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
|
||||||
|
switch (rawPayload.length % 4) {
|
||||||
|
case 0:
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
rawPayload += '==';
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
rawPayload += '=';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw Exception('illegal format of access token payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
final b64 = utf8.fuse(base64Url);
|
||||||
|
return jsonDecode(b64.decode(rawPayload));
|
||||||
|
}
|
||||||
|
|
||||||
Future<SnAccount?> refreshUser() async {
|
Future<SnAccount?> refreshUser() async {
|
||||||
|
if (!isAuthorized) return null;
|
||||||
final resp = await _sn.client.get('/cgi/id/users/me');
|
final resp = await _sn.client.get('/cgi/id/users/me');
|
||||||
final out = SnAccount.fromJson(resp.data);
|
final out = SnAccount.fromJson(resp.data);
|
||||||
|
|
||||||
@@ -48,7 +76,13 @@ class UserProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void logoutUser() async {
|
void logoutUser() async {
|
||||||
_sn.clearTokenPair();
|
atkClaims.then((value) async {
|
||||||
|
if (value != null) {
|
||||||
|
await _sn.client.delete('/cgi/id/users/me/tickets/${value['sed']}');
|
||||||
|
logging.info('[Auth] Current session has been destroyed.');
|
||||||
|
}
|
||||||
|
_sn.clearTokenPair();
|
||||||
|
});
|
||||||
isAuthorized = false;
|
isAuthorized = false;
|
||||||
user = null;
|
user = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_udid/flutter_udid.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/logger.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/providers/userinfo.dart';
|
import 'package:surface/providers/userinfo.dart';
|
||||||
import 'package:surface/types/websocket.dart';
|
import 'package:surface/types/websocket.dart';
|
||||||
|
import 'package:web_socket_channel/io.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
class WebSocketProvider extends ChangeNotifier {
|
class WebSocketProvider extends ChangeNotifier {
|
||||||
@@ -30,7 +33,7 @@ class WebSocketProvider extends ChangeNotifier {
|
|||||||
if (isConnected) return;
|
if (isConnected) return;
|
||||||
if (!_ua.isAuthorized) return;
|
if (!_ua.isAuthorized) return;
|
||||||
|
|
||||||
log('[WebSocket] Connecting to the server...');
|
logging.debug('[WebSocket] Connecting to the server...');
|
||||||
await connect();
|
await connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +42,7 @@ class WebSocketProvider extends ChangeNotifier {
|
|||||||
Future<void> connect({noRetry = false}) async {
|
Future<void> connect({noRetry = false}) async {
|
||||||
if (_connectCompleter != null) {
|
if (_connectCompleter != null) {
|
||||||
await _connectCompleter!.future;
|
await _connectCompleter!.future;
|
||||||
_connectCompleter = null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_ua.isAuthorized) return;
|
if (!_ua.isAuthorized) return;
|
||||||
@@ -52,27 +55,39 @@ class WebSocketProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
final atk = await _sn.getFreshAtk();
|
final atk = await _sn.getFreshAtk();
|
||||||
final uri = Uri.parse(
|
final uri = Uri.parse(
|
||||||
'${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
|
kIsWeb
|
||||||
|
? '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk'
|
||||||
|
: '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?clientId=${await FlutterUdid.consistentUdid}tk=$atk',
|
||||||
);
|
);
|
||||||
|
|
||||||
isBusy = true;
|
isBusy = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
conn = WebSocketChannel.connect(uri);
|
conn = kIsWeb
|
||||||
|
? WebSocketChannel.connect(uri)
|
||||||
|
: IOWebSocketChannel.connect(
|
||||||
|
uri,
|
||||||
|
headers: {'Authorization': 'Bearer $atk'},
|
||||||
|
);
|
||||||
await conn!.ready;
|
await conn!.ready;
|
||||||
_wsStream = conn!.stream.asBroadcastStream();
|
_wsStream = conn!.stream.asBroadcastStream();
|
||||||
listen();
|
listen();
|
||||||
log('[WebSocket] Connected to server!');
|
logging.info('[WebSocket] Connected to server!');
|
||||||
isConnected = true;
|
isConnected = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err is WebSocketChannelException) {
|
if (err is WebSocketChannelException) {
|
||||||
log('Failed to connect to websocket: ${(err.inner as dynamic).message}');
|
logging.error(
|
||||||
|
'[WebSocket] Failed to connect to websocket...',
|
||||||
|
err.inner,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
log('Failed to connect to websocket: $err');
|
logging.error('[WebSocket] Failed to connect to websocket...', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!noRetry) {
|
if (!noRetry) {
|
||||||
log('Retry connecting to websocket in 3 seconds...');
|
logging.warning(
|
||||||
|
'[WebSocket] Retry connecting to websocket in 3 seconds...',
|
||||||
|
);
|
||||||
return Future.delayed(
|
return Future.delayed(
|
||||||
const Duration(seconds: 3),
|
const Duration(seconds: 3),
|
||||||
() => connect(noRetry: true),
|
() => connect(noRetry: true),
|
||||||
@@ -100,7 +115,9 @@ class WebSocketProvider extends ChangeNotifier {
|
|||||||
_wsStream!.listen(
|
_wsStream!.listen(
|
||||||
(event) {
|
(event) {
|
||||||
final packet = WebSocketPackage.fromJson(jsonDecode(event));
|
final packet = WebSocketPackage.fromJson(jsonDecode(event));
|
||||||
log('Websocket incoming message: ${packet.method} ${packet.message}');
|
logging.debug(
|
||||||
|
'[Websocket] Incoming message: ${packet.method} ${packet.message}',
|
||||||
|
);
|
||||||
pk.sink.add(packet);
|
pk.sink.add(packet);
|
||||||
},
|
},
|
||||||
onDone: () {
|
onDone: () {
|
||||||
|
|||||||
307
lib/router.dart
307
lib/router.dart
@@ -3,13 +3,22 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:surface/screens/abuse_report.dart';
|
import 'package:surface/screens/abuse_report.dart';
|
||||||
import 'package:surface/screens/account.dart';
|
import 'package:surface/screens/account.dart';
|
||||||
import 'package:surface/screens/account/account_settings.dart';
|
import 'package:surface/screens/account/punishments.dart';
|
||||||
|
import 'package:surface/screens/account/settings.dart';
|
||||||
|
import 'package:surface/screens/account/action_events.dart';
|
||||||
|
import 'package:surface/screens/account/badges.dart';
|
||||||
|
import 'package:surface/screens/account/contact_methods.dart';
|
||||||
import 'package:surface/screens/account/factor_settings.dart';
|
import 'package:surface/screens/account/factor_settings.dart';
|
||||||
|
import 'package:surface/screens/account/keypairs.dart';
|
||||||
|
import 'package:surface/screens/account/prefs/notify.dart';
|
||||||
|
import 'package:surface/screens/account/prefs/security.dart';
|
||||||
import 'package:surface/screens/account/profile_page.dart';
|
import 'package:surface/screens/account/profile_page.dart';
|
||||||
import 'package:surface/screens/account/profile_edit.dart';
|
import 'package:surface/screens/account/profile_edit.dart';
|
||||||
|
import 'package:surface/screens/account/programs.dart';
|
||||||
import 'package:surface/screens/account/publishers/publisher_edit.dart';
|
import 'package:surface/screens/account/publishers/publisher_edit.dart';
|
||||||
import 'package:surface/screens/account/publishers/publisher_new.dart';
|
import 'package:surface/screens/account/publishers/publisher_new.dart';
|
||||||
import 'package:surface/screens/account/publishers/publishers.dart';
|
import 'package:surface/screens/account/publishers/publishers.dart';
|
||||||
|
import 'package:surface/screens/account/auth_tickets.dart';
|
||||||
import 'package:surface/screens/album.dart';
|
import 'package:surface/screens/album.dart';
|
||||||
import 'package:surface/screens/auth/login.dart';
|
import 'package:surface/screens/auth/login.dart';
|
||||||
import 'package:surface/screens/auth/register.dart';
|
import 'package:surface/screens/auth/register.dart';
|
||||||
@@ -21,14 +30,18 @@ import 'package:surface/screens/chat/room.dart';
|
|||||||
import 'package:surface/screens/explore.dart';
|
import 'package:surface/screens/explore.dart';
|
||||||
import 'package:surface/screens/friend.dart';
|
import 'package:surface/screens/friend.dart';
|
||||||
import 'package:surface/screens/home.dart';
|
import 'package:surface/screens/home.dart';
|
||||||
|
import 'package:surface/screens/logging.dart';
|
||||||
import 'package:surface/screens/news/news_detail.dart';
|
import 'package:surface/screens/news/news_detail.dart';
|
||||||
import 'package:surface/screens/news/news_list.dart';
|
import 'package:surface/screens/news/news_list.dart';
|
||||||
import 'package:surface/screens/notification.dart';
|
import 'package:surface/screens/notification.dart';
|
||||||
import 'package:surface/screens/post/post_detail.dart';
|
import 'package:surface/screens/post/post_detail.dart';
|
||||||
|
import 'package:surface/screens/post/post_draft.dart';
|
||||||
import 'package:surface/screens/post/post_editor.dart';
|
import 'package:surface/screens/post/post_editor.dart';
|
||||||
|
import 'package:surface/screens/post/post_shuffle.dart';
|
||||||
import 'package:surface/screens/post/publisher_page.dart';
|
import 'package:surface/screens/post/publisher_page.dart';
|
||||||
import 'package:surface/screens/post/post_search.dart';
|
import 'package:surface/screens/post/post_search.dart';
|
||||||
import 'package:surface/screens/realm.dart';
|
import 'package:surface/screens/realm.dart';
|
||||||
|
import 'package:surface/screens/realm/community.dart';
|
||||||
import 'package:surface/screens/realm/manage.dart';
|
import 'package:surface/screens/realm/manage.dart';
|
||||||
import 'package:surface/screens/realm/realm_detail.dart';
|
import 'package:surface/screens/realm/realm_detail.dart';
|
||||||
import 'package:surface/screens/realm/realm_discovery.dart';
|
import 'package:surface/screens/realm/realm_discovery.dart';
|
||||||
@@ -59,14 +72,19 @@ final _appRoutes = [
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/posts',
|
path: '/posts',
|
||||||
name: 'explore',
|
name: 'posts',
|
||||||
builder: (context, state) => const ExploreScreen(),
|
builder: (_, __) => const SizedBox.shrink(),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/write/:mode',
|
path: '/draft',
|
||||||
|
name: 'postDraftBox',
|
||||||
|
builder: (context, state) => const PostDraftBox(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/write',
|
||||||
name: 'postEditor',
|
name: 'postEditor',
|
||||||
builder: (context, state) => PostEditorScreen(
|
builder: (context, state) => PostEditorScreen(
|
||||||
mode: state.pathParameters['mode']!,
|
mode: state.uri.queryParameters['mode'],
|
||||||
postEditId: int.tryParse(
|
postEditId: int.tryParse(
|
||||||
state.uri.queryParameters['editing'] ?? '',
|
state.uri.queryParameters['editing'] ?? '',
|
||||||
),
|
),
|
||||||
@@ -79,6 +97,11 @@ final _appRoutes = [
|
|||||||
extraProps: state.extra as PostEditorExtra?,
|
extraProps: state.extra as PostEditorExtra?,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/shuffle',
|
||||||
|
name: 'postShuffle',
|
||||||
|
builder: (context, state) => const PostShuffleScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/search',
|
path: '/search',
|
||||||
name: 'postSearch',
|
name: 'postSearch',
|
||||||
@@ -88,108 +111,194 @@ final _appRoutes = [
|
|||||||
state.uri.queryParameters['categories']?.split(','),
|
state.uri.queryParameters['categories']?.split(','),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) => ResponsiveScaffold(
|
||||||
|
asideFlex: 2,
|
||||||
|
contentFlex: 3,
|
||||||
|
aside: const ExploreScreen(),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/explore',
|
||||||
|
name: 'explore',
|
||||||
|
builder: (context, state) => const ResponsiveScaffoldLanding(
|
||||||
|
child: ExploreScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/posts/:slug',
|
||||||
|
name: 'postDetail',
|
||||||
|
builder: (context, state) => PostDetailScreen(
|
||||||
|
key: ValueKey(state.pathParameters['slug']!),
|
||||||
|
slug: state.pathParameters['slug']!,
|
||||||
|
preload: state.extra as SnPost?,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/publishers/:name',
|
path: '/publishers/:name',
|
||||||
name: 'postPublisher',
|
name: 'postPublisher',
|
||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
PostPublisherScreen(name: state.pathParameters['name']!),
|
PostPublisherScreen(name: state.pathParameters['name']!),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) => ResponsiveScaffold(
|
||||||
|
aside: const AccountScreen(),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/:slug',
|
path: '/account',
|
||||||
name: 'postDetail',
|
name: 'account',
|
||||||
builder: (context, state) => PostDetailScreen(
|
builder: (context, state) =>
|
||||||
slug: state.pathParameters['slug']!,
|
const ResponsiveScaffoldLanding(child: AccountScreen()),
|
||||||
preload: state.extra as SnPost?,
|
routes: [
|
||||||
),
|
GoRoute(
|
||||||
|
path: '/punishments',
|
||||||
|
name: 'accountPunishments',
|
||||||
|
builder: (context, state) => const PunishmentsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/programs',
|
||||||
|
name: 'accountProgram',
|
||||||
|
builder: (context, state) => const AccountProgramScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/contacts',
|
||||||
|
name: 'accountContactMethods',
|
||||||
|
builder: (context, state) => const AccountContactMethod(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/events',
|
||||||
|
name: 'accountActionEvents',
|
||||||
|
builder: (context, state) => const ActionEventScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/tickets',
|
||||||
|
name: 'accountAuthTickets',
|
||||||
|
builder: (context, state) => const AccountAuthTicket(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/badges',
|
||||||
|
name: 'accountBadges',
|
||||||
|
builder: (context, state) => const AccountBadgesScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/wallet',
|
||||||
|
name: 'accountWallet',
|
||||||
|
builder: (context, state) => const WalletScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/keypairs',
|
||||||
|
name: 'accountKeyPairs',
|
||||||
|
builder: (context, state) => const KeyPairScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/settings',
|
||||||
|
name: 'accountSettings',
|
||||||
|
builder: (context, state) => AccountSettingsScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/notify',
|
||||||
|
name: 'accountSettingsNotify',
|
||||||
|
builder: (context, state) => const AccountNotifyPrefsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/auth',
|
||||||
|
name: 'accountSettingsSecurity',
|
||||||
|
builder: (context, state) => const AccountSecurityPrefsScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/settings/factors',
|
||||||
|
name: 'factorSettings',
|
||||||
|
builder: (context, state) => FactorSettingsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/profile/edit',
|
||||||
|
name: 'accountProfileEdit',
|
||||||
|
builder: (context, state) => ProfileEditScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/publishers',
|
||||||
|
name: 'accountPublishers',
|
||||||
|
builder: (context, state) => PublisherScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/publishers/new',
|
||||||
|
name: 'accountPublisherNew',
|
||||||
|
builder: (context, state) => AccountPublisherNewScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/publishers/edit/:name',
|
||||||
|
name: 'accountPublisherEdit',
|
||||||
|
builder: (context, state) => AccountPublisherEditScreen(
|
||||||
|
name: state.pathParameters['name']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/account',
|
path: '/accounts/:name',
|
||||||
name: 'account',
|
name: 'accountProfilePage',
|
||||||
builder: (context, state) => const AccountScreen(),
|
pageBuilder: (context, state) => NoTransitionPage(
|
||||||
routes: [
|
child: UserScreen(name: state.pathParameters['name']!),
|
||||||
GoRoute(
|
),
|
||||||
path: '/wallet',
|
),
|
||||||
name: 'accountWallet',
|
ShellRoute(
|
||||||
builder: (context, state) => const WalletScreen(),
|
builder: (context, state, child) =>
|
||||||
),
|
ResponsiveScaffold(aside: const ChatScreen(), child: child),
|
||||||
GoRoute(
|
|
||||||
path: '/settings',
|
|
||||||
name: 'accountSettings',
|
|
||||||
builder: (context, state) => AccountSettingsScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/settings/factors',
|
|
||||||
name: 'factorSettings',
|
|
||||||
builder: (context, state) => FactorSettingsScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/profile/edit',
|
|
||||||
name: 'accountProfileEdit',
|
|
||||||
builder: (context, state) => ProfileEditScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/publishers',
|
|
||||||
name: 'accountPublishers',
|
|
||||||
builder: (context, state) => PublisherScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/publishers/new',
|
|
||||||
name: 'accountPublisherNew',
|
|
||||||
builder: (context, state) => AccountPublisherNewScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/publishers/edit/:name',
|
|
||||||
name: 'accountPublisherEdit',
|
|
||||||
builder: (context, state) => AccountPublisherEditScreen(
|
|
||||||
name: state.pathParameters['name']!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/:name',
|
|
||||||
name: 'accountProfilePage',
|
|
||||||
pageBuilder: (context, state) => NoTransitionPage(
|
|
||||||
child: UserScreen(name: state.pathParameters['name']!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
GoRoute(
|
|
||||||
path: '/chat',
|
|
||||||
name: 'chat',
|
|
||||||
builder: (context, state) => const ChatScreen(),
|
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/:scope/:alias',
|
path: '/chat',
|
||||||
name: 'chatRoom',
|
name: 'chat',
|
||||||
builder: (context, state) => ChatRoomScreen(
|
builder: (context, state) => const ResponsiveScaffoldLanding(
|
||||||
scope: state.pathParameters['scope']!,
|
child: ChatScreen(),
|
||||||
alias: state.pathParameters['alias']!,
|
|
||||||
extra: state.extra as ChatRoomScreenExtra?,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/:scope/:alias/call',
|
|
||||||
name: 'chatCallRoom',
|
|
||||||
builder: (context, state) => CallRoomScreen(
|
|
||||||
scope: state.pathParameters['scope']!,
|
|
||||||
alias: state.pathParameters['alias']!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/:scope/:alias/detail',
|
|
||||||
name: 'channelDetail',
|
|
||||||
builder: (context, state) => ChannelDetailScreen(
|
|
||||||
scope: state.pathParameters['scope']!,
|
|
||||||
alias: state.pathParameters['alias']!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/manage',
|
|
||||||
name: 'chatManage',
|
|
||||||
builder: (context, state) => ChatManageScreen(
|
|
||||||
editingChannelAlias: state.uri.queryParameters['editing'],
|
|
||||||
),
|
),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/:scope/:alias',
|
||||||
|
name: 'chatRoom',
|
||||||
|
builder: (context, state) => ChatRoomScreen(
|
||||||
|
key: ValueKey(
|
||||||
|
'${state.pathParameters['scope']!}:${state.pathParameters['alias']!}',
|
||||||
|
),
|
||||||
|
scope: state.pathParameters['scope']!,
|
||||||
|
alias: state.pathParameters['alias']!,
|
||||||
|
extra: state.extra as ChatRoomScreenExtra?,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/:scope/:alias/call',
|
||||||
|
name: 'chatCallRoom',
|
||||||
|
builder: (context, state) => CallRoomScreen(
|
||||||
|
scope: state.pathParameters['scope']!,
|
||||||
|
alias: state.pathParameters['alias']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/:scope/:alias/detail',
|
||||||
|
name: 'channelDetail',
|
||||||
|
builder: (context, state) => ChannelDetailScreen(
|
||||||
|
scope: state.pathParameters['scope']!,
|
||||||
|
alias: state.pathParameters['alias']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/manage',
|
||||||
|
name: 'chatManage',
|
||||||
|
builder: (context, state) => ChatManageScreen(
|
||||||
|
editingChannelAlias: state.uri.queryParameters['editing'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -201,6 +310,13 @@ final _appRoutes = [
|
|||||||
child: const RealmScreen(),
|
child: const RealmScreen(),
|
||||||
),
|
),
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/:alias/community',
|
||||||
|
name: 'realmCommunity',
|
||||||
|
builder: (context, state) => RealmCommunityScreen(
|
||||||
|
alias: state.pathParameters['alias']!,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/manage',
|
path: '/manage',
|
||||||
name: 'realmManage',
|
name: 'realmManage',
|
||||||
@@ -249,6 +365,11 @@ final _appRoutes = [
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/debug/logging',
|
||||||
|
name: 'debugLogging',
|
||||||
|
builder: (context, state) => const DebugLoggingScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/album',
|
path: '/album',
|
||||||
name: 'album',
|
name: 'album',
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:surface/providers/database.dart';
|
import 'package:surface/providers/database.dart';
|
||||||
|
import 'package:surface/providers/navigation.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/providers/userinfo.dart';
|
import 'package:surface/providers/userinfo.dart';
|
||||||
import 'package:surface/providers/websocket.dart';
|
import 'package:surface/providers/websocket.dart';
|
||||||
|
import 'package:surface/types/account.dart';
|
||||||
import 'package:surface/widgets/account/account_image.dart';
|
import 'package:surface/widgets/account/account_image.dart';
|
||||||
|
import 'package:surface/widgets/account/account_status.dart';
|
||||||
import 'package:surface/widgets/app_bar_leading.dart';
|
import 'package:surface/widgets/app_bar_leading.dart';
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
@@ -20,27 +23,97 @@ import 'package:surface/widgets/universal_image.dart';
|
|||||||
class AccountScreen extends StatelessWidget {
|
class AccountScreen extends StatelessWidget {
|
||||||
const AccountScreen({super.key});
|
const AccountScreen({super.key});
|
||||||
|
|
||||||
|
static const List<AppNavListItem> kNavList = [
|
||||||
|
AppNavListItem(
|
||||||
|
title: "accountPublishers",
|
||||||
|
subtitle: "accountPublishersSubtitle",
|
||||||
|
screen: "accountPublishers",
|
||||||
|
icon: Symbols.face,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "accountProgram",
|
||||||
|
subtitle: "accountProgramDescription",
|
||||||
|
screen: "accountProgram",
|
||||||
|
icon: Symbols.communities,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "friends",
|
||||||
|
subtitle: "friendsDescription",
|
||||||
|
screen: "friend",
|
||||||
|
icon: Symbols.person,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "album",
|
||||||
|
subtitle: "albumDescription",
|
||||||
|
screen: "album",
|
||||||
|
icon: Symbols.photo_library,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "stickers",
|
||||||
|
subtitle: "stickersDescription",
|
||||||
|
screen: "stickers",
|
||||||
|
icon: Symbols.emoji_emotions,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "accountWallet",
|
||||||
|
subtitle: "accountWalletSubtitle",
|
||||||
|
screen: "accountWallet",
|
||||||
|
icon: Symbols.wallet,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "accountBadges",
|
||||||
|
subtitle: "accountBadgesDescription",
|
||||||
|
screen: "accountBadges",
|
||||||
|
icon: Symbols.award_star,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "accountKeyPairs",
|
||||||
|
subtitle: "accountKeyPairsDescription",
|
||||||
|
screen: "accountKeyPairs",
|
||||||
|
icon: Symbols.key,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "accountPunishments",
|
||||||
|
subtitle: "accountPunishmentsDescription",
|
||||||
|
screen: "accountPunishments",
|
||||||
|
icon: Symbols.credit_score,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "accountActionEvent",
|
||||||
|
subtitle: "accountActionEventDescription",
|
||||||
|
screen: "accountActionEvents",
|
||||||
|
icon: Symbols.history,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "accountAuthTickets",
|
||||||
|
subtitle: "accountAuthTicketsDescription",
|
||||||
|
screen: "accountAuthTickets",
|
||||||
|
icon: Symbols.confirmation_number,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "accountSettings",
|
||||||
|
subtitle: "accountSettingsSubtitle",
|
||||||
|
screen: "accountSettings",
|
||||||
|
icon: Symbols.manage_accounts,
|
||||||
|
),
|
||||||
|
AppNavListItem(
|
||||||
|
title: "abuseReport",
|
||||||
|
subtitle: "abuseReportActionDescription",
|
||||||
|
screen: "abuseReport",
|
||||||
|
icon: Symbols.flag,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final ua = context.watch<UserProvider>();
|
final ua = context.watch<UserProvider>();
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: AutoAppBarLeading(),
|
leading: AutoAppBarLeading(),
|
||||||
title: Text(
|
title: Text("screenAccount").tr(),
|
||||||
"screenAccount",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
shadows: [
|
|
||||||
Shadow(
|
|
||||||
offset: Offset(1, 1),
|
|
||||||
blurRadius: 5.0,
|
|
||||||
color: Color.fromARGB(255, 0, 0, 0),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
).tr(),
|
|
||||||
flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
|
flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
|
||||||
? Stack(
|
? Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
@@ -69,15 +142,6 @@ class AccountScreen extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Symbols.settings, fill: 1),
|
|
||||||
onPressed: () {
|
|
||||||
GoRouter.of(context).pushNamed('settings');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: ua.isAuthorized
|
child: ua.isAuthorized
|
||||||
@@ -112,7 +176,25 @@ class _AuthorizedAccountScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
AccountImage(content: ua.user!.avatar, radius: 28),
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
child: AccountImage(
|
||||||
|
content: ua.user!.avatar,
|
||||||
|
radius: 28,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
GoRouter.of(context)
|
||||||
|
.pushNamed('accountProfilePage', pathParameters: {
|
||||||
|
'name': ua.user!.name,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_AccountStatusWidget(account: ua.user!),
|
||||||
|
],
|
||||||
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
@@ -125,82 +207,55 @@ class _AuthorizedAccountScreen extends StatelessWidget {
|
|||||||
.textStyle(Theme.of(context).textTheme.bodySmall!),
|
.textStyle(Theme.of(context).textTheme.bodySmall!),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Text(ua.user!.description)
|
Text(
|
||||||
.textStyle(Theme.of(context).textTheme.bodyMedium!),
|
(ua.user!.profile?.description.isNotEmpty ?? false)
|
||||||
|
? ua.user!.profile!.description
|
||||||
|
: 'userNoDescription'.tr(),
|
||||||
|
style: (ua.user!.profile?.description.isEmpty ?? true)
|
||||||
|
? TextStyle(fontStyle: FontStyle.italic)
|
||||||
|
: null,
|
||||||
|
).textStyle(Theme.of(context).textTheme.bodyMedium!),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).padding(all: 20),
|
}).padding(all: 20),
|
||||||
).padding(horizontal: 8, top: 16, bottom: 4),
|
).padding(horizontal: 8, top: 16, bottom: 4),
|
||||||
ListTile(
|
for (final item in AccountScreen.kNavList)
|
||||||
title: Text('accountPublishers').tr(),
|
Tooltip(
|
||||||
subtitle: Text('accountPublishersSubtitle').tr(),
|
message: item.subtitle.tr(),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
child: ListTile(
|
||||||
leading: const Icon(Symbols.face),
|
minTileHeight: 48,
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
title: Text(item.title).tr(),
|
||||||
onTap: () {
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
GoRouter.of(context).pushNamed('accountPublishers');
|
leading: Icon(item.icon),
|
||||||
},
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
),
|
onTap: () {
|
||||||
ListTile(
|
GoRouter.of(context).pushNamed(item.screen);
|
||||||
title: Text('abuseReport').tr(),
|
},
|
||||||
subtitle: Text('abuseReportActionDescription').tr(),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
),
|
||||||
leading: const Icon(Symbols.flag),
|
Tooltip(
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
message: 'accountLogoutSubtitle'.tr(),
|
||||||
onTap: () {
|
child: ListTile(
|
||||||
GoRouter.of(context).pushNamed('abuseReport');
|
title: Text('accountLogout').tr(),
|
||||||
},
|
minTileHeight: 48,
|
||||||
),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
ListTile(
|
leading: const Icon(Symbols.logout),
|
||||||
title: Text('factorSettings').tr(),
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
subtitle: Text('factorSettingsSubtitle').tr(),
|
onTap: () async {
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
final confirm = await context.showConfirmDialog(
|
||||||
leading: const Icon(Symbols.lock),
|
'accountLogoutConfirmTitle'.tr(),
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
'accountLogoutConfirm'.tr(),
|
||||||
onTap: () {
|
);
|
||||||
GoRouter.of(context).pushNamed('factorSettings');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
title: Text('accountWallet').tr(),
|
|
||||||
subtitle: Text('accountWalletSubtitle').tr(),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
leading: const Icon(Symbols.wallet),
|
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
|
||||||
onTap: () {
|
|
||||||
GoRouter.of(context).pushNamed('accountWallet');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
title: Text('accountSettings').tr(),
|
|
||||||
subtitle: Text('accountSettingsSubtitle').tr(),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
leading: const Icon(Symbols.manage_accounts),
|
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
|
||||||
onTap: () {
|
|
||||||
GoRouter.of(context).pushNamed('accountSettings');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
title: Text('accountLogout').tr(),
|
|
||||||
subtitle: Text('accountLogoutSubtitle').tr(),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
leading: const Icon(Symbols.logout),
|
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
|
||||||
onTap: () async {
|
|
||||||
final confirm = await context.showConfirmDialog(
|
|
||||||
'accountLogoutConfirmTitle'.tr(),
|
|
||||||
'accountLogoutConfirm'.tr(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirm) return;
|
if (!confirm) return;
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ua.logoutUser();
|
ua.logoutUser();
|
||||||
final ws = context.read<WebSocketProvider>();
|
final ws = context.read<WebSocketProvider>();
|
||||||
ws.disconnect();
|
ws.disconnect();
|
||||||
context.read<DatabaseProvider>().removeDatabase();
|
context.read<DatabaseProvider>().removeDatabase();
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -243,9 +298,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
|
|||||||
GoRouter.of(context).pushNamed('authLogin').then((value) {
|
GoRouter.of(context).pushNamed('authLogin').then((value) {
|
||||||
if (value == true && context.mounted) {
|
if (value == true && context.mounted) {
|
||||||
final ua = context.read<UserProvider>();
|
final ua = context.read<UserProvider>();
|
||||||
context.showSnackbar('loginSuccess'.tr(args: [
|
ua.refreshUser();
|
||||||
'@${ua.user?.name} (${ua.user?.nick})',
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -264,3 +317,81 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AccountStatusWidget extends StatefulWidget {
|
||||||
|
final SnAccount account;
|
||||||
|
const _AccountStatusWidget({required this.account});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AccountStatusWidget> createState() => _AccountStatusWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountStatusWidgetState extends State<_AccountStatusWidget> {
|
||||||
|
SnAccountStatusInfo? _status;
|
||||||
|
|
||||||
|
Future<void> _fetchStatus() async {
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp =
|
||||||
|
await sn.client.get('/cgi/id/users/${widget.account.name}/status');
|
||||||
|
setState(() {
|
||||||
|
_status = SnAccountStatusInfo.fromJson(resp.data);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_status != null
|
||||||
|
? (_status!.status?.label.isNotEmpty ?? false)
|
||||||
|
? _status!.status!.label
|
||||||
|
: _status!.isOnline
|
||||||
|
? 'accountStatusOnline'.tr()
|
||||||
|
: 'accountStatusOffline'.tr()
|
||||||
|
: 'loading'.tr(),
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
Icon(
|
||||||
|
(_status?.isDisturbable ?? true)
|
||||||
|
? Symbols.circle
|
||||||
|
: Symbols.do_not_disturb_on,
|
||||||
|
fill: (_status?.isOnline ?? false) ? 1 : 0,
|
||||||
|
size: 16,
|
||||||
|
color: (_status?.isOnline ?? false)
|
||||||
|
? (_status?.isDisturbable ?? true)
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red
|
||||||
|
: Colors.grey,
|
||||||
|
).padding(all: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AccountStatusActionPopup(
|
||||||
|
currentStatus: _status,
|
||||||
|
),
|
||||||
|
).then((value) {
|
||||||
|
if (value == true && mounted) {
|
||||||
|
_fetchStatus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
161
lib/screens/account/action_events.dart
Normal file
161
lib/screens/account/action_events.dart
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:relative_time/relative_time.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/types/account.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
import 'package:timelines_plus/timelines_plus.dart';
|
||||||
|
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||||
|
|
||||||
|
class ActionEventScreen extends StatefulWidget {
|
||||||
|
const ActionEventScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ActionEventScreen> createState() => _ActionEventScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActionEventScreenState extends State<ActionEventScreen> {
|
||||||
|
bool _isBusy = false;
|
||||||
|
int? _totalCount;
|
||||||
|
final List<SnActionEvent> _actionEvents = List.empty(growable: true);
|
||||||
|
|
||||||
|
Future<void> _fetchActionEvents() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp = await sn.client.get(
|
||||||
|
'/cgi/id/users/me/events',
|
||||||
|
queryParameters: {
|
||||||
|
'take': 10,
|
||||||
|
'offset': _actionEvents.length,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_totalCount = resp.data['count'];
|
||||||
|
_actionEvents.addAll(
|
||||||
|
(resp.data['data'] as List<dynamic>)
|
||||||
|
.map((e) => SnActionEvent.fromJson(e)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchActionEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: const PageBackButton(),
|
||||||
|
title: Text('accountActionEvent').tr(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: () {
|
||||||
|
_totalCount = null;
|
||||||
|
return _fetchActionEvents();
|
||||||
|
},
|
||||||
|
child: InfiniteList(
|
||||||
|
padding: EdgeInsets.only(left: 20, right: 8),
|
||||||
|
itemCount: _actionEvents.length,
|
||||||
|
isLoading: _isBusy,
|
||||||
|
hasReachedMax:
|
||||||
|
_totalCount != null && _actionEvents.length >= _totalCount!,
|
||||||
|
onFetchData: _fetchActionEvents,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
final event = _actionEvents[idx];
|
||||||
|
return TimelineTile(
|
||||||
|
nodeAlign: TimelineNodeAlign.start,
|
||||||
|
contents: Card(
|
||||||
|
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
event.type,
|
||||||
|
maxLines: 1,
|
||||||
|
style: GoogleFonts.robotoMono(),
|
||||||
|
),
|
||||||
|
if (event.ipAddress.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
event.ipAddress,
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
if (event.location?.isNotEmpty ?? false)
|
||||||
|
Text(event.location!),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(DateFormat()
|
||||||
|
.format(event.createdAt.toLocal()))
|
||||||
|
.fontSize(12),
|
||||||
|
Text(' · ')
|
||||||
|
.fontSize(12)
|
||||||
|
.padding(horizontal: 4),
|
||||||
|
Text(RelativeTime(context)
|
||||||
|
.format(event.createdAt.toLocal()))
|
||||||
|
.fontSize(12),
|
||||||
|
],
|
||||||
|
).opacity(0.75).padding(top: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (event.metadata != null)
|
||||||
|
ExpansionTile(
|
||||||
|
minTileHeight: 40,
|
||||||
|
tilePadding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
title: Text('eventMetadata').tr(),
|
||||||
|
expandedAlignment: Alignment.topLeft,
|
||||||
|
expandedCrossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
JsonEncoder.withIndent('\t')
|
||||||
|
.convert(event.metadata),
|
||||||
|
style: GoogleFonts.robotoMono(),
|
||||||
|
).padding(vertical: 8, horizontal: 16),
|
||||||
|
],
|
||||||
|
).padding(bottom: 6),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
node: TimelineNode(
|
||||||
|
indicator: DotIndicator(),
|
||||||
|
startConnector: SolidLineConnector(),
|
||||||
|
endConnector: SolidLineConnector(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
lib/screens/account/auth_tickets.dart
Normal file
187
lib/screens/account/auth_tickets.dart
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/providers/userinfo.dart';
|
||||||
|
import 'package:surface/types/auth.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||||
|
|
||||||
|
const Map<String, IconData> kAuthTicketIcon = {
|
||||||
|
'ios': Symbols.ios,
|
||||||
|
'android': Symbols.android,
|
||||||
|
'macos': Symbols.computer,
|
||||||
|
'windows nt': Symbols.laptop_windows,
|
||||||
|
'linux': Symbols.laptop,
|
||||||
|
};
|
||||||
|
|
||||||
|
class AccountAuthTicket extends StatefulWidget {
|
||||||
|
const AccountAuthTicket({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountAuthTicket> createState() => _AccountAuthTicketState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountAuthTicketState extends State<AccountAuthTicket> {
|
||||||
|
bool _isBusy = false;
|
||||||
|
int? _totalCount;
|
||||||
|
final List<SnAuthTicket> _authTickets = List.empty(growable: true);
|
||||||
|
|
||||||
|
Future<void> _fetchAuthTickets() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp = await sn.client.get(
|
||||||
|
'/cgi/id/users/me/tickets',
|
||||||
|
queryParameters: {
|
||||||
|
'take': 10,
|
||||||
|
'offset': _authTickets.length,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_totalCount = resp.data['count'];
|
||||||
|
_authTickets.addAll(
|
||||||
|
(resp.data['data'] as List<dynamic>)
|
||||||
|
.map((e) => SnAuthTicket.fromJson(e)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteAuthTicket(SnAuthTicket ticket) async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
await sn.client.delete(
|
||||||
|
'/cgi/id/users/me/tickets/${ticket.id}',
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_authTickets.remove(ticket);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _currentTicketId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchAuthTickets();
|
||||||
|
|
||||||
|
final ua = context.read<UserProvider>();
|
||||||
|
ua.atkClaims.then((value) {
|
||||||
|
if (value == null) return;
|
||||||
|
_currentTicketId = int.parse(value['sed']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: const PageBackButton(),
|
||||||
|
title: Text('accountAuthTickets').tr(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: () {
|
||||||
|
_totalCount = null;
|
||||||
|
return _fetchAuthTickets();
|
||||||
|
},
|
||||||
|
child: InfiniteList(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onFetchData: _fetchAuthTickets,
|
||||||
|
isLoading: _isBusy,
|
||||||
|
hasReachedMax:
|
||||||
|
_totalCount != null && _authTickets.length >= _totalCount!,
|
||||||
|
itemCount: _authTickets.length,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
final ticket = _authTickets[idx];
|
||||||
|
final platform = RegExp(r'\(([^;]+);')
|
||||||
|
.firstMatch(ticket.userAgent)
|
||||||
|
?.group(1);
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
kAuthTicketIcon[platform!.toLowerCase()] ?? Symbols.web,
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
ticket.ipAddress,
|
||||||
|
style: TextStyle(fontSize: 15),
|
||||||
|
),
|
||||||
|
Text(ticket.userAgent).opacity(0.8),
|
||||||
|
if (ticket.location?.isNotEmpty ?? false)
|
||||||
|
const Gap(4),
|
||||||
|
if (ticket.location?.isNotEmpty ?? false)
|
||||||
|
Text(ticket.location!).opacity(0.8),
|
||||||
|
const Gap(4),
|
||||||
|
Text('authTicketCreatedAt'.tr(args: [
|
||||||
|
(DateFormat().format(ticket.createdAt.toLocal()))
|
||||||
|
])).fontSize(12).opacity(0.75),
|
||||||
|
if (ticket.expiredAt != null)
|
||||||
|
Text('authTicketExpiredAt'.tr(args: [
|
||||||
|
(DateFormat()
|
||||||
|
.format(ticket.expiredAt!.toLocal()))
|
||||||
|
])).fontSize(12).opacity(0.75),
|
||||||
|
if (ticket.lastGrantAt != null)
|
||||||
|
Text('authTicketLastGrantAt'.tr(args: [
|
||||||
|
(DateFormat()
|
||||||
|
.format(ticket.lastGrantAt!.toLocal()))
|
||||||
|
])).fontSize(12).opacity(0.75),
|
||||||
|
const Gap(4),
|
||||||
|
if (_currentTicketId == ticket.id)
|
||||||
|
Text('authTicketCurrent'.tr())
|
||||||
|
.fontSize(11)
|
||||||
|
.bold()
|
||||||
|
.opacity(0.75),
|
||||||
|
Text('#${ticket.id}').fontSize(11).opacity(0.75),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
iconSize: 20,
|
||||||
|
visualDensity:
|
||||||
|
VisualDensity(horizontal: -4, vertical: -4),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: const Icon(Symbols.logout),
|
||||||
|
onPressed: _currentTicketId == ticket.id
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
_deleteAuthTicket(ticket);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 16, vertical: 12);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
lib/screens/account/badges.dart
Normal file
141
lib/screens/account/badges.dart
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/screens/account/profile_page.dart' show kBadgesMeta;
|
||||||
|
import 'package:surface/theme.dart';
|
||||||
|
import 'package:surface/types/account.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
|
||||||
|
class AccountBadgesScreen extends StatefulWidget {
|
||||||
|
const AccountBadgesScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountBadgesScreen> createState() => _AccountBadgesScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountBadgesScreenState extends State<AccountBadgesScreen> {
|
||||||
|
bool _isBusy = false;
|
||||||
|
List<SnAccountBadge>? _badges;
|
||||||
|
|
||||||
|
Future<void> _fetchBadges() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp = await sn.client.get('/cgi/id/badges/me');
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(
|
||||||
|
() => _badges = List<SnAccountBadge>.from(
|
||||||
|
resp.data?.map((e) => SnAccountBadge.fromJson(e)) ?? [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isActivating = false;
|
||||||
|
|
||||||
|
Future<void> _activateBadge(SnAccountBadge badge) async {
|
||||||
|
try {
|
||||||
|
setState(() => _isActivating = true);
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
await sn.client.post('/cgi/id/badges/${badge.id}/active');
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showSnackbar('badgeActivated'
|
||||||
|
.tr(args: [(kBadgesMeta[badge.type]?.$1 ?? 'unknown').tr()]));
|
||||||
|
await _fetchBadges();
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isActivating = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchBadges();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('screenAccountBadges').tr(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
if (_badges != null)
|
||||||
|
Expanded(
|
||||||
|
child: MediaQuery.removePadding(
|
||||||
|
context: context,
|
||||||
|
removeTop: true,
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _fetchBadges,
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _badges!.length,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
final badge = _badges![idx];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
|
||||||
|
).tr(),
|
||||||
|
contentPadding: const EdgeInsets.only(
|
||||||
|
left: 24,
|
||||||
|
right: 16,
|
||||||
|
top: 4,
|
||||||
|
bottom: 4,
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (badge.metadata['title'] != null)
|
||||||
|
Text(badge.metadata['title']).fontSize(14).bold()
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
'#${badge.id.toString().padLeft(8, '0')}',
|
||||||
|
style: GoogleFonts.robotoMono(),
|
||||||
|
).fontSize(14).bold(),
|
||||||
|
Text(
|
||||||
|
DateFormat('y/M/d').format(badge.createdAt),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Symbols.check),
|
||||||
|
onPressed: (badge.isActive || _isActivating)
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
_activateBadge(badge);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
leading: Icon(
|
||||||
|
kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
|
||||||
|
color: badge.metadata['color'] != null
|
||||||
|
? HexColor.fromHex(badge.metadata['color']!)
|
||||||
|
: kBadgesMeta[badge.type]?.$3,
|
||||||
|
fill: 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
323
lib/screens/account/contact_methods.dart
Normal file
323
lib/screens/account/contact_methods.dart
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/types/account.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
|
||||||
|
const kContactMethodsIcons = [Symbols.email, Symbols.phone, Symbols.map];
|
||||||
|
const kContactMethodsName = ['Email', 'Phone', 'Address'];
|
||||||
|
|
||||||
|
class AccountContactMethod extends StatefulWidget {
|
||||||
|
const AccountContactMethod({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountContactMethod> createState() => _AccountContactMethodState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountContactMethodState extends State<AccountContactMethod> {
|
||||||
|
bool _isBusy = false;
|
||||||
|
List<SnAccountContact> _contactMethods = List.empty(growable: true);
|
||||||
|
|
||||||
|
Future<void> _fetchContactMethods() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp = await sn.client.get('/cgi/id/users/me/contacts');
|
||||||
|
_contactMethods = List.from((resp.data as List<dynamic>)
|
||||||
|
.map((e) => SnAccountContact.fromJson(e)));
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteContactMethod(SnAccountContact contact) async {
|
||||||
|
final confirm = await context.showConfirmDialog(
|
||||||
|
'accountContactMethodsDelete'.tr(),
|
||||||
|
'accountContactMethodsDeleteDescription'.tr(args: [contact.content]),
|
||||||
|
);
|
||||||
|
if (!confirm || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
await sn.client.delete('/cgi/id/users/me/contacts/${contact.id}');
|
||||||
|
if (!mounted) return;
|
||||||
|
await _fetchContactMethods();
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchContactMethods();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: const PageBackButton(),
|
||||||
|
title: Text('accountContactMethods').tr(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
ListTile(
|
||||||
|
title: Text('accountContactMethodsAdd').tr(),
|
||||||
|
subtitle: Text('accountContactMethodsAddDescription').tr(),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Symbols.add),
|
||||||
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _ContactMethodEditor(),
|
||||||
|
).then((value) {
|
||||||
|
if (value) {
|
||||||
|
_fetchContactMethods();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _fetchContactMethods,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: _contactMethods.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final method = _contactMethods[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(method.content),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'accountContactMethodsName${kContactMethodsName[method.type]}',
|
||||||
|
).tr().bold(),
|
||||||
|
if (method.isPrimary ||
|
||||||
|
method.isPublic ||
|
||||||
|
method.verifiedAt != null)
|
||||||
|
Row(
|
||||||
|
spacing: 4,
|
||||||
|
children: [
|
||||||
|
if (method.isPrimary)
|
||||||
|
Text('accountContactMethodsPrimary').tr(),
|
||||||
|
if (method.isPublic)
|
||||||
|
Text('accountContactMethodsPublic').tr(),
|
||||||
|
if (method.verifiedAt != null)
|
||||||
|
Text('accountContactMethodsVerified').tr(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: Icon(
|
||||||
|
kContactMethodsIcons[method.type],
|
||||||
|
),
|
||||||
|
trailing: PopupMenuButton(
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.edit),
|
||||||
|
const Gap(16),
|
||||||
|
Text('edit').tr(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _ContactMethodEditor(
|
||||||
|
contact: method,
|
||||||
|
),
|
||||||
|
).then((value) {
|
||||||
|
if (value) {
|
||||||
|
_fetchContactMethods();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.delete),
|
||||||
|
const Gap(16),
|
||||||
|
Text('delete'.tr()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
_deleteContactMethod(method);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactMethodEditor extends StatefulWidget {
|
||||||
|
final SnAccountContact? contact;
|
||||||
|
const _ContactMethodEditor({this.contact});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ContactMethodEditor> createState() => _ContactMethodEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactMethodEditorState extends State<_ContactMethodEditor> {
|
||||||
|
int _type = 0;
|
||||||
|
bool _isPublic = false;
|
||||||
|
final TextEditingController _contentController = TextEditingController();
|
||||||
|
|
||||||
|
bool _isBusy = false;
|
||||||
|
|
||||||
|
Future<void> _saveContactMethod() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
await sn.client.request(
|
||||||
|
widget.contact == null
|
||||||
|
? '/cgi/id/users/me/contacts'
|
||||||
|
: '/cgi/id/users/me/contacts/${widget.contact!.id}',
|
||||||
|
data: {
|
||||||
|
'content': _contentController.text,
|
||||||
|
'type': _type,
|
||||||
|
'is_public': _isPublic,
|
||||||
|
},
|
||||||
|
options: Options(
|
||||||
|
method: widget.contact == null ? 'POST' : 'PUT',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.contact != null) {
|
||||||
|
_type = widget.contact!.type;
|
||||||
|
_isPublic = widget.contact!.isPublic;
|
||||||
|
_contentController.text = widget.contact!.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: widget.contact == null
|
||||||
|
? Text('accountContactMethodsAdd').tr()
|
||||||
|
: Text('accountContactMethodsEdit').tr(),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton2<int>(
|
||||||
|
value: _type,
|
||||||
|
items: kContactMethodsName
|
||||||
|
.mapIndexed((idx, ele) => DropdownMenuItem<int>(
|
||||||
|
value: idx,
|
||||||
|
child: Text('accountContactMethodsName$ele').tr(),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
buttonStyleData: ButtonStyleData(
|
||||||
|
height: 48,
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.only(left: 14, right: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
menuItemStyleData: const MenuItemStyleData(
|
||||||
|
height: 48,
|
||||||
|
padding: EdgeInsets.only(left: 14, right: 14),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _type = value ?? 0);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
TextField(
|
||||||
|
controller: _contentController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: 'fieldContactContent'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: CheckboxListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(
|
||||||
|
Radius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text('accountContactMethodsPublic').tr(),
|
||||||
|
subtitle: Text('accountContactMethodsPublicHint').tr(),
|
||||||
|
secondary: const Icon(Symbols.globe),
|
||||||
|
value: _isPublic,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _isPublic = value ?? false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isBusy
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text('dialogDismiss').tr(),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isBusy
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
_saveContactMethod();
|
||||||
|
},
|
||||||
|
child: Text('dialogConfirm').tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,11 @@ final Map<int, (String, String, IconData)> kFactorTypes = {
|
|||||||
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
||||||
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
|
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
|
||||||
2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
|
2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
|
||||||
3: ('authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active),
|
3: (
|
||||||
|
'authFactorInAppNotify',
|
||||||
|
'authFactorInAppNotifyDescription',
|
||||||
|
Symbols.notifications_active
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
class FactorSettingsScreen extends StatefulWidget {
|
class FactorSettingsScreen extends StatefulWidget {
|
||||||
@@ -36,7 +40,10 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
|
|||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/id/users/me/factors');
|
final resp = await sn.client.get('/cgi/id/users/me/factors');
|
||||||
_factors = List<SnAuthFactor>.from(
|
_factors = List<SnAuthFactor>.from(
|
||||||
resp.data?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>)).toList() ?? [],
|
resp.data
|
||||||
|
?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -55,6 +62,7 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: PageBackButton(),
|
leading: PageBackButton(),
|
||||||
title: Text('screenFactorSettings').tr(),
|
title: Text('screenFactorSettings').tr(),
|
||||||
@@ -96,7 +104,8 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(kFactorTypes[ele.type]!.$1).tr(),
|
title: Text(kFactorTypes[ele.type]!.$1).tr(),
|
||||||
subtitle: Text(kFactorTypes[ele.type]!.$2).tr(),
|
subtitle: Text(kFactorTypes[ele.type]!.$2).tr(),
|
||||||
contentPadding: const EdgeInsets.only(left: 24, right: 12),
|
contentPadding:
|
||||||
|
const EdgeInsets.only(left: 24, right: 12),
|
||||||
leading: Icon(kFactorTypes[ele.type]!.$3),
|
leading: Icon(kFactorTypes[ele.type]!.$3),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(Symbols.close),
|
icon: const Icon(Symbols.close),
|
||||||
@@ -105,14 +114,17 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
|
|||||||
context
|
context
|
||||||
.showConfirmDialog(
|
.showConfirmDialog(
|
||||||
'authFactorDelete'.tr(),
|
'authFactorDelete'.tr(),
|
||||||
'authFactorDeleteDescription'.tr(args: [kFactorTypes[ele.type]!.$1.tr()]),
|
'authFactorDeleteDescription'.tr(
|
||||||
|
args: [kFactorTypes[ele.type]!.$1.tr()]),
|
||||||
)
|
)
|
||||||
.then((val) async {
|
.then((val) async {
|
||||||
if (!val) return;
|
if (!val) return;
|
||||||
try {
|
try {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn =
|
||||||
await sn.client.delete('/cgi/id/users/me/factors/${ele.id}');
|
context.read<SnNetworkProvider>();
|
||||||
|
await sn.client.delete(
|
||||||
|
'/cgi/id/users/me/factors/${ele.id}');
|
||||||
_fetchFactors();
|
_fetchFactors();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -191,7 +203,9 @@ class _FactorNewDialogState extends State<_FactorNewDialog> {
|
|||||||
value: _factorType,
|
value: _factorType,
|
||||||
items: kFactorTypes.entries.map(
|
items: kFactorTypes.entries.map(
|
||||||
(ele) {
|
(ele) {
|
||||||
final contains = widget.currentlyHave.map((ele) => ele.type).contains(ele.key);
|
final contains = widget.currentlyHave
|
||||||
|
.map((ele) => ele.type)
|
||||||
|
.contains(ele.key);
|
||||||
return DropdownMenuItem<int>(
|
return DropdownMenuItem<int>(
|
||||||
enabled: !contains,
|
enabled: !contains,
|
||||||
value: ele.key,
|
value: ele.key,
|
||||||
|
|||||||
107
lib/screens/account/keypairs.dart
Normal file
107
lib/screens/account/keypairs.dart
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/providers/keypair.dart';
|
||||||
|
import 'package:surface/types/keypair.dart';
|
||||||
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
|
||||||
|
class KeyPairScreen extends StatefulWidget {
|
||||||
|
const KeyPairScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<KeyPairScreen> createState() => _KeyPairScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _KeyPairScreenState extends State<KeyPairScreen> {
|
||||||
|
bool _isBusy = false;
|
||||||
|
List<SnKeyPair>? _keyPairs;
|
||||||
|
|
||||||
|
Future<void> _loadKeyPairs() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
final kps = await context.read<KeyPairProvider>().listKeyPair();
|
||||||
|
setState(() {
|
||||||
|
_keyPairs = kps;
|
||||||
|
_isBusy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadKeyPairs();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('screenKeyPairs').tr(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Symbols.add),
|
||||||
|
title: Text('enrollNewKeyPair').tr(),
|
||||||
|
subtitle: Text('enrollNewKeyPairDescription').tr(),
|
||||||
|
onTap: () async {
|
||||||
|
await context.read<KeyPairProvider>().enrollNew();
|
||||||
|
_loadKeyPairs();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
if (_keyPairs != null)
|
||||||
|
Expanded(
|
||||||
|
child: MediaQuery.removePadding(
|
||||||
|
context: context,
|
||||||
|
removeTop: true,
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _loadKeyPairs,
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _keyPairs!.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final kp = _keyPairs![index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(kp.id.toUpperCase()),
|
||||||
|
subtitle: Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
if (kp.privateKey != null)
|
||||||
|
Text(
|
||||||
|
'keyPairHasPrivateKey'.tr(),
|
||||||
|
),
|
||||||
|
if (kp.privateKey != null) Text('·'),
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: Text(
|
||||||
|
'UID #${kp.accountId.toString().padLeft(8, '0')}',
|
||||||
|
style: GoogleFonts.robotoMono(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Symbols.check),
|
||||||
|
onPressed: kp.isActive == true
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final k = context.read<KeyPairProvider>();
|
||||||
|
await k.activeKeyPair(kp.id);
|
||||||
|
_loadKeyPairs();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
lib/screens/account/prefs/notify.dart
Normal file
123
lib/screens/account/prefs/notify.dart
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
|
||||||
|
final Map<String, String> kNotifyTopicMap = {
|
||||||
|
'interactive.reply': 'notificationTopicPostReply'.tr(),
|
||||||
|
'interactive.feedback': 'notificationTopicPostFeedback'.tr(),
|
||||||
|
'interactive.subscription': 'notificationTopicPostSubscription'.tr(),
|
||||||
|
'messaging.message': 'notificationTopicMessaging'.tr(),
|
||||||
|
'messaging.call': 'notificationTopicMessagingCall'.tr(),
|
||||||
|
'general': 'notificationTopicGeneral'.tr(),
|
||||||
|
};
|
||||||
|
|
||||||
|
class AccountNotifyPrefsScreen extends StatefulWidget {
|
||||||
|
const AccountNotifyPrefsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountNotifyPrefsScreen> createState() =>
|
||||||
|
_AccountNotifyPrefsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountNotifyPrefsScreenState extends State<AccountNotifyPrefsScreen> {
|
||||||
|
bool _isBusy = true;
|
||||||
|
|
||||||
|
Map<String, bool> _config = {};
|
||||||
|
|
||||||
|
Future<void> _getPreferences() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resp = await sn.client.get('/cgi/id/preferences/notifications');
|
||||||
|
_config = resp.data['config']
|
||||||
|
.map((k, v) => MapEntry(k, v as bool))
|
||||||
|
.cast<String, bool>();
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _savePreferences() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sn.client.put(
|
||||||
|
'/cgi/id/preferences/notifications',
|
||||||
|
data: {
|
||||||
|
'config': _config,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showSnackbar('accountSettingsApplied'.tr());
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_getPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: const PageBackButton(),
|
||||||
|
title: Text('accountSettingsNotify').tr(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
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(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: kNotifyTopicMap.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final element = kNotifyTopicMap.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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
148
lib/screens/account/prefs/security.dart
Normal file
148
lib/screens/account/prefs/security.dart
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
|
||||||
|
class AccountSecurityPrefsScreen extends StatefulWidget {
|
||||||
|
const AccountSecurityPrefsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountSecurityPrefsScreen> createState() =>
|
||||||
|
_AccountSecurityPrefsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountSecurityPrefsScreenState
|
||||||
|
extends State<AccountSecurityPrefsScreen> {
|
||||||
|
bool _isBusy = true;
|
||||||
|
|
||||||
|
Map<String, dynamic> _config = {
|
||||||
|
'maximum_auth_steps': 2,
|
||||||
|
'always_risky': false,
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<void> _getPreferences() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resp = await sn.client.get('/cgi/id/preferences/auth');
|
||||||
|
_config = resp.data['config']
|
||||||
|
.map((k, v) => MapEntry(k, v as bool))
|
||||||
|
.cast<String, bool>();
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _savePreferences() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sn.client.put(
|
||||||
|
'/cgi/id/preferences/auth',
|
||||||
|
data: {
|
||||||
|
'config': _config,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showSnackbar('accountSettingsApplied'.tr());
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_getPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: const PageBackButton(),
|
||||||
|
title: Text('accountSettingsSecurity').tr(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
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(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
title: Text('authMaximumAuthSteps').tr(),
|
||||||
|
subtitle: Text('authMaximumAuthStepsDescription')
|
||||||
|
.plural(_config['maximum_auth_steps'] ?? 2),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
|
icon: const Icon(Symbols.remove),
|
||||||
|
onPressed: () {
|
||||||
|
if (_config['maximum_auth_steps'] > 1) {
|
||||||
|
setState(() => _config['maximum_auth_steps']--);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
|
icon: const Icon(Symbols.add),
|
||||||
|
onPressed: () {
|
||||||
|
if (_config['maximum_auth_steps'] < 99) {
|
||||||
|
setState(() => _config['maximum_auth_steps']++);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: Text('authAlwaysRisky').tr(),
|
||||||
|
subtitle: Text('authAlwaysRiskyDescription').tr(),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
value: _config['always_risky'] ?? false,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _config['always_risky'] = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
@@ -36,11 +37,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
final _firstNameController = TextEditingController();
|
final _firstNameController = TextEditingController();
|
||||||
final _lastNameController = TextEditingController();
|
final _lastNameController = TextEditingController();
|
||||||
final _descriptionController = TextEditingController();
|
final _descriptionController = TextEditingController();
|
||||||
|
final _timezoneController = TextEditingController();
|
||||||
|
final _genderController = TextEditingController();
|
||||||
|
final _pronounsController = TextEditingController();
|
||||||
|
final _locationController = TextEditingController();
|
||||||
final _birthdayController = TextEditingController();
|
final _birthdayController = TextEditingController();
|
||||||
|
|
||||||
String? _avatar;
|
String? _avatar;
|
||||||
String? _banner;
|
String? _banner;
|
||||||
DateTime? _birthday;
|
DateTime? _birthday;
|
||||||
|
List<(String, String)>? _links;
|
||||||
|
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
|
|
||||||
@@ -51,15 +57,21 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
final prof = ua.user!;
|
final prof = ua.user!;
|
||||||
_usernameController.text = prof.name;
|
_usernameController.text = prof.name;
|
||||||
_nicknameController.text = prof.nick;
|
_nicknameController.text = prof.nick;
|
||||||
_descriptionController.text = prof.description;
|
_descriptionController.text = prof.profile!.description;
|
||||||
_firstNameController.text = prof.profile!.firstName;
|
_firstNameController.text = prof.profile!.firstName;
|
||||||
_lastNameController.text = prof.profile!.lastName;
|
_lastNameController.text = prof.profile!.lastName;
|
||||||
|
_timezoneController.text = prof.profile!.timeZone;
|
||||||
|
_genderController.text = prof.profile!.gender;
|
||||||
|
_pronounsController.text = prof.profile!.pronouns;
|
||||||
|
_locationController.text = prof.profile!.location;
|
||||||
_avatar = prof.avatar;
|
_avatar = prof.avatar;
|
||||||
_banner = prof.banner;
|
_banner = prof.banner;
|
||||||
if (prof.profile!.birthday != null) {
|
_links =
|
||||||
_birthdayController.text = DateFormat(_kDateFormat).format(
|
prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList();
|
||||||
prof.profile!.birthday!.toLocal(),
|
_birthday = prof.profile!.birthday?.toLocal();
|
||||||
);
|
if (_birthday != null) {
|
||||||
|
_birthdayController.text =
|
||||||
|
DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +81,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
builder: (BuildContext context) => Container(
|
builder: (BuildContext context) => Container(
|
||||||
height: 216,
|
height: 216,
|
||||||
padding: const EdgeInsets.only(top: 6.0),
|
padding: const EdgeInsets.only(top: 6.0),
|
||||||
margin: EdgeInsets.only(
|
margin:
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
),
|
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
@@ -82,7 +93,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
onDateTimeChanged: (DateTime newDate) {
|
onDateTimeChanged: (DateTime newDate) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_birthday = newDate;
|
_birthday = newDate;
|
||||||
_birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
|
_birthdayController.text =
|
||||||
|
DateFormat(_kDateFormat).format(_birthday!);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -96,32 +108,45 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
if (image == null) return;
|
if (image == null) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
final skipCrop = image.path.endsWith('.gif');
|
||||||
final aspectRatios =
|
|
||||||
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
|
|
||||||
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
|
||||||
? await showCupertinoImageCropper(
|
|
||||||
// ignore: use_build_context_synchronously
|
|
||||||
context,
|
|
||||||
allowedAspectRatios: aspectRatios,
|
|
||||||
imageProvider: imageProvider,
|
|
||||||
)
|
|
||||||
: await showMaterialImageCropper(
|
|
||||||
// ignore: use_build_context_synchronously
|
|
||||||
context,
|
|
||||||
allowedAspectRatios: aspectRatios,
|
|
||||||
imageProvider: imageProvider,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result == null) return;
|
Uint8List? rawBytes;
|
||||||
|
if (!skipCrop) {
|
||||||
|
final ImageProvider imageProvider =
|
||||||
|
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||||
|
final aspectRatios = place == 'banner'
|
||||||
|
? [CropAspectRatio(width: 16, height: 7)]
|
||||||
|
: [CropAspectRatio(width: 1, height: 1)];
|
||||||
|
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||||
|
? await showCupertinoImageCropper(
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
context,
|
||||||
|
allowedAspectRatios: aspectRatios,
|
||||||
|
imageProvider: imageProvider,
|
||||||
|
)
|
||||||
|
: await showMaterialImageCropper(
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
context,
|
||||||
|
allowedAspectRatios: aspectRatios,
|
||||||
|
imageProvider: imageProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null) return;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!
|
||||||
|
.buffer
|
||||||
|
.asUint8List();
|
||||||
|
} else {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
rawBytes = await image.readAsBytes();
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final attach = context.read<SnAttachmentProvider>();
|
final attach = context.read<SnAttachmentProvider>();
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
|
||||||
|
|
||||||
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final attachment = await attach.directUploadOne(
|
final attachment = await attach.directUploadOne(
|
||||||
rawBytes,
|
rawBytes,
|
||||||
@@ -133,10 +158,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
await sn.client.put(
|
await sn.client
|
||||||
'/cgi/id/users/me/$place',
|
.put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid});
|
||||||
data: {'attachment': attachment.rid},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final ua = context.read<UserProvider>();
|
final ua = context.read<UserProvider>();
|
||||||
@@ -166,7 +189,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
'description': _descriptionController.value.text,
|
'description': _descriptionController.value.text,
|
||||||
'first_name': _firstNameController.value.text,
|
'first_name': _firstNameController.value.text,
|
||||||
'last_name': _lastNameController.value.text,
|
'last_name': _lastNameController.value.text,
|
||||||
|
'time_zone': _timezoneController.value.text,
|
||||||
|
'gender': _genderController.value.text,
|
||||||
|
'pronouns': _pronounsController.value.text,
|
||||||
|
'location': _locationController.value.text,
|
||||||
'birthday': _birthday?.toUtc().toIso8601String(),
|
'birthday': _birthday?.toUtc().toIso8601String(),
|
||||||
|
'links': {
|
||||||
|
for (final link in _links!
|
||||||
|
.where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty))
|
||||||
|
link.$1: link.$2,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -197,6 +229,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
_firstNameController.dispose();
|
_firstNameController.dispose();
|
||||||
_lastNameController.dispose();
|
_lastNameController.dispose();
|
||||||
_descriptionController.dispose();
|
_descriptionController.dispose();
|
||||||
|
_timezoneController.dispose();
|
||||||
|
_genderController.dispose();
|
||||||
|
_pronounsController.dispose();
|
||||||
|
_locationController.dispose();
|
||||||
_birthdayController.dispose();
|
_birthdayController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -208,10 +244,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: const PageBackButton(),
|
leading: const PageBackButton(),
|
||||||
title: Text('screenAccountProfileEdit').tr(),
|
title: Text('screenAccountProfileEdit').tr()),
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -229,12 +265,13 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHigh,
|
||||||
child: _banner != null
|
child: _banner != null
|
||||||
? AutoResizeUniversalImage(
|
? AutoResizeUniversalImage(
|
||||||
sn.getAttachmentUrl(_banner!),
|
sn.getAttachmentUrl(_banner!),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover)
|
||||||
)
|
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -262,6 +299,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
).padding(horizontal: padding),
|
).padding(horizontal: padding),
|
||||||
const Gap(8 + 28),
|
const Gap(8 + 28),
|
||||||
Column(
|
Column(
|
||||||
|
spacing: 4,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
@@ -271,16 +309,17 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
labelText: 'fieldUsername'.tr(),
|
labelText: 'fieldUsername'.tr(),
|
||||||
helperText: 'fieldUsernameCannotEditHint'.tr(),
|
helperText: 'fieldUsernameCannotEditHint'.tr(),
|
||||||
),
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const Gap(4),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _nicknameController,
|
controller: _nicknameController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
labelText: 'fieldNickname'.tr(),
|
labelText: 'fieldNickname'.tr()),
|
||||||
),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const Gap(4),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
@@ -291,6 +330,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
labelText: 'fieldFirstName'.tr(),
|
labelText: 'fieldFirstName'.tr(),
|
||||||
),
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
@@ -302,31 +343,189 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
labelText: 'fieldLastName'.tr(),
|
labelText: 'fieldLastName'.tr(),
|
||||||
),
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: TextField(
|
||||||
|
controller: _genderController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'fieldGender'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: TextField(
|
||||||
|
controller: _pronounsController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'fieldPronouns'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Gap(4),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _descriptionController,
|
controller: _descriptionController,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
minLines: 3,
|
minLines: 3,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
labelText: 'fieldDescription'.tr(),
|
labelText: 'fieldDescription'.tr()),
|
||||||
),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _timezoneController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'fieldTimeZone'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
StyledWidget(
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.calendar_month),
|
||||||
|
visualDensity:
|
||||||
|
VisualDensity(horizontal: -4, vertical: -4),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
onPressed: () async {
|
||||||
|
_timezoneController.text =
|
||||||
|
await FlutterTimezone.getLocalTimezone();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).padding(top: 6),
|
||||||
|
const Gap(4),
|
||||||
|
StyledWidget(
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.clear),
|
||||||
|
visualDensity:
|
||||||
|
VisualDensity(horizontal: -4, vertical: -4),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
onPressed: () {
|
||||||
|
_timezoneController.clear();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).padding(top: 6),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: _locationController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'fieldLocation'.tr()),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const Gap(4),
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _birthdayController,
|
controller: _birthdayController,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
labelText: 'fieldBirthday'.tr(),
|
labelText: 'fieldBirthday'.tr()),
|
||||||
),
|
|
||||||
onTap: () => _selectBirthday(),
|
onTap: () => _selectBirthday(),
|
||||||
),
|
),
|
||||||
|
if (_links != null)
|
||||||
|
Card(
|
||||||
|
margin: const EdgeInsets.only(top: 16, bottom: 4),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'fieldLinks'.tr(),
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium!
|
||||||
|
.copyWith(fontSize: 17),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
visualDensity:
|
||||||
|
VisualDensity(horizontal: -4, vertical: -4),
|
||||||
|
icon: const Icon(Symbols.add),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _links!.add(('', '')));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
for (var idx = 0; idx < _links!.length; idx++)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: _links![idx].$1,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: 'fieldLinkName'.tr(),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
_links![idx] = (value, _links![idx].$2);
|
||||||
|
},
|
||||||
|
onTapOutside: (_) => FocusManager
|
||||||
|
.instance.primaryFocus
|
||||||
|
?.unfocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: _links![idx].$2,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: 'fieldLinkUrl'.tr(),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
_links![idx] = (_links![idx].$1, value);
|
||||||
|
},
|
||||||
|
onTapOutside: (_) => FocusManager
|
||||||
|
.instance.primaryFocus
|
||||||
|
?.unfocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
).padding(horizontal: padding + 8),
|
).padding(horizontal: padding + 8),
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
@@ -340,6 +539,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
).padding(horizontal: padding),
|
).padding(horizontal: padding),
|
||||||
|
Gap(MediaQuery.of(context).padding.bottom),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
@@ -18,10 +19,13 @@ import 'package:surface/types/account.dart';
|
|||||||
import 'package:surface/types/check_in.dart';
|
import 'package:surface/types/check_in.dart';
|
||||||
import 'package:surface/types/post.dart';
|
import 'package:surface/types/post.dart';
|
||||||
import 'package:surface/widgets/account/account_image.dart';
|
import 'package:surface/widgets/account/account_image.dart';
|
||||||
|
import 'package:surface/widgets/account/badge.dart';
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
import 'package:surface/widgets/universal_image.dart';
|
import 'package:surface/widgets/universal_image.dart';
|
||||||
|
import 'package:surface/theme.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
const Map<String, (String, IconData, Color)> kBadgesMeta = {
|
final Map<String, (String, IconData, Color)> kBadgesMeta = {
|
||||||
'company.staff': (
|
'company.staff': (
|
||||||
'badgeCompanyStaff',
|
'badgeCompanyStaff',
|
||||||
Symbols.tools_wrench,
|
Symbols.tools_wrench,
|
||||||
@@ -32,6 +36,31 @@ const Map<String, (String, IconData, Color)> kBadgesMeta = {
|
|||||||
Symbols.flag,
|
Symbols.flag,
|
||||||
Colors.orange,
|
Colors.orange,
|
||||||
),
|
),
|
||||||
|
'site.anniversary': (
|
||||||
|
'badgeSiteAnniversary',
|
||||||
|
Symbols.celebration,
|
||||||
|
Colors.orangeAccent,
|
||||||
|
),
|
||||||
|
'user.birthday': (
|
||||||
|
'badgeUserBirthday',
|
||||||
|
Symbols.cake,
|
||||||
|
Colors.red[400]!,
|
||||||
|
),
|
||||||
|
'community.survey': (
|
||||||
|
'badgeCommunitySurvey',
|
||||||
|
Symbols.star,
|
||||||
|
Colors.yellow[700]!,
|
||||||
|
),
|
||||||
|
'community.verified': (
|
||||||
|
'badgeCommunityVerified',
|
||||||
|
Symbols.verified,
|
||||||
|
Colors.blue,
|
||||||
|
),
|
||||||
|
'community.contributor': (
|
||||||
|
'badgeCommunityContributor',
|
||||||
|
Symbols.thumb_up,
|
||||||
|
Colors.lightGreen,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
class UserScreen extends StatefulWidget {
|
class UserScreen extends StatefulWidget {
|
||||||
@@ -43,7 +72,8 @@ class UserScreen extends StatefulWidget {
|
|||||||
State<UserScreen> createState() => _UserScreenState();
|
State<UserScreen> createState() => _UserScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
|
class _UserScreenState extends State<UserScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
late final ScrollController _scrollController = ScrollController();
|
late final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
SnAccount? _account;
|
SnAccount? _account;
|
||||||
@@ -64,13 +94,18 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<SnCheckInRecord>> _getCheckInRecords() async {
|
List<SnCheckInRecord>? _records;
|
||||||
|
|
||||||
|
Future<void> _getCheckInRecords() async {
|
||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
|
final resp =
|
||||||
return List.from(
|
await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
|
||||||
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
|
setState(() {
|
||||||
);
|
_records = List.from(
|
||||||
|
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
|
||||||
|
);
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (mounted) context.showErrorDialog(err);
|
if (mounted) context.showErrorDialog(err);
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -98,7 +133,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
Future<void> _fetchPublishers() async {
|
Future<void> _fetchPublishers() async {
|
||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/co/publishers?user=${widget.name}');
|
final resp =
|
||||||
|
await sn.client.get('/cgi/co/publishers?user=${widget.name}');
|
||||||
_publishers = List<SnPublisher>.from(
|
_publishers = List<SnPublisher>.from(
|
||||||
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
|
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
|
||||||
);
|
);
|
||||||
@@ -144,7 +180,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
'related': _account!.name,
|
'related': _account!.name,
|
||||||
});
|
});
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
|
context.showSnackbar(
|
||||||
|
'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -160,9 +197,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final rel = context.read<SnRelationshipProvider>();
|
final rel = context.read<SnRelationshipProvider>();
|
||||||
await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
|
await rel.updateRelationship(
|
||||||
|
_account!.id, 1, _accountRelationship?.permNodes ?? {});
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
|
context.showSnackbar(
|
||||||
|
'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -188,12 +227,14 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
double _appBarBlur = 0.0;
|
double _appBarBlur = 0.0;
|
||||||
|
|
||||||
late final _appBarWidth = MediaQuery.of(context).size.width;
|
late final _appBarWidth = MediaQuery.of(context).size.width;
|
||||||
late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
|
late final _appBarHeight =
|
||||||
|
math.min((_appBarWidth * kBannerAspectRatio), 360).roundToDouble();
|
||||||
|
|
||||||
void _updateAppBarBlur() {
|
void _updateAppBarBlur() {
|
||||||
if (_scrollController.offset > _appBarHeight) return;
|
if (_scrollController.offset > _appBarHeight) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
|
_appBarBlur =
|
||||||
|
(_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +246,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
|
|
||||||
_fetchStatus();
|
_fetchStatus();
|
||||||
_fetchPublishers();
|
_fetchPublishers();
|
||||||
|
_getCheckInRecords();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final rel = context.read<SnRelationshipProvider>();
|
final rel = context.read<SnRelationshipProvider>();
|
||||||
@@ -260,18 +302,20 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
text: TextSpan(children: [
|
text: TextSpan(children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: _account!.nick,
|
text: _account!.nick,
|
||||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
style:
|
||||||
color: Colors.white,
|
Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||||
shadows: labelShadows,
|
color: Colors.white,
|
||||||
),
|
shadows: labelShadows,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const TextSpan(text: '\n'),
|
const TextSpan(text: '\n'),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '@${_account!.name}',
|
text: '@${_account!.name}',
|
||||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
style:
|
||||||
color: Colors.white,
|
Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||||
shadows: labelShadows,
|
color: Colors.white,
|
||||||
),
|
shadows: labelShadows,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
@@ -280,14 +324,21 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
? Stack(
|
? Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
UniversalImage(
|
if (_account!.banner.isNotEmpty)
|
||||||
sn.getAttachmentUrl(_account!.banner),
|
UniversalImage(
|
||||||
fit: BoxFit.cover,
|
sn.getAttachmentUrl(_account!.banner),
|
||||||
height: imageHeight,
|
fit: BoxFit.cover,
|
||||||
width: _appBarWidth,
|
height: imageHeight,
|
||||||
cacheHeight: imageHeight,
|
width: _appBarWidth,
|
||||||
cacheWidth: _appBarWidth,
|
cacheHeight: imageHeight,
|
||||||
),
|
cacheWidth: _appBarWidth,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHigh,
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -339,7 +390,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
PopupMenuButton(
|
PopupMenuButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
style: ButtonStyle(
|
style: ButtonStyle(
|
||||||
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
visualDensity:
|
||||||
|
VisualDensity(horizontal: -4, vertical: -4),
|
||||||
),
|
),
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
@@ -389,27 +441,41 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
).padding(right: 8),
|
).padding(right: 8),
|
||||||
const Gap(12),
|
if (_account!.profile!.description.isNotEmpty)
|
||||||
Text(_account!.description).padding(horizontal: 8),
|
const Gap(12)
|
||||||
|
else
|
||||||
|
const Gap(8),
|
||||||
|
if (_account!.profile!.description.isNotEmpty)
|
||||||
|
Text(_account!.profile!.description).padding(horizontal: 8),
|
||||||
const Gap(4),
|
const Gap(4),
|
||||||
Card(
|
Card(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Symbols.circle,
|
(_status?.isDisturbable ?? true)
|
||||||
fill: 1,
|
? Symbols.circle
|
||||||
|
: Symbols.do_not_disturb_on,
|
||||||
|
fill: (_status?.isOnline ?? false) ? 1 : 0,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: (_status?.isOnline ?? false) ? Colors.green : Colors.grey,
|
color: (_status?.isOnline ?? false)
|
||||||
|
? (_status?.isDisturbable ?? true)
|
||||||
|
? Colors.green
|
||||||
|
: Colors.red
|
||||||
|
: Colors.grey,
|
||||||
).padding(all: 4),
|
).padding(all: 4),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Text(
|
Text(
|
||||||
_status != null
|
_status != null
|
||||||
? _status!.isOnline
|
? (_status!.status?.label.isNotEmpty ?? false)
|
||||||
? 'accountStatusOnline'.tr()
|
? _status!.status!.label
|
||||||
: 'accountStatusOffline'.tr()
|
: _status!.isOnline
|
||||||
|
? 'accountStatusOnline'.tr()
|
||||||
|
: 'accountStatusOffline'.tr()
|
||||||
: 'loading'.tr(),
|
: 'loading'.tr(),
|
||||||
),
|
),
|
||||||
if (_status != null && !_status!.isOnline && _status!.lastSeenAt != null)
|
if (_status != null &&
|
||||||
|
!_status!.isOnline &&
|
||||||
|
_status!.lastSeenAt != null)
|
||||||
Text(
|
Text(
|
||||||
'accountStatusLastSeen'.tr(args: [
|
'accountStatusLastSeen'.tr(args: [
|
||||||
_status!.lastSeenAt != null
|
_status!.lastSeenAt != null
|
||||||
@@ -424,30 +490,10 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Wrap(
|
Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
children: _account!.badges
|
children: _account!.badges
|
||||||
.map(
|
.map((ele) => AccountBadge(badge: ele))
|
||||||
(ele) => Tooltip(
|
|
||||||
richMessage: TextSpan(
|
|
||||||
children: [
|
|
||||||
TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
|
|
||||||
if (ele.metadata['title'] != null)
|
|
||||||
TextSpan(
|
|
||||||
text: '\n${ele.metadata['title']}',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
TextSpan(text: '\n'),
|
|
||||||
TextSpan(
|
|
||||||
text: DateFormat.yMEd().format(ele.createdAt),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
|
|
||||||
color: kBadgesMeta[ele.type]?.$3,
|
|
||||||
fill: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
.toList(),
|
||||||
).padding(horizontal: 8),
|
).padding(horizontal: 8),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
@@ -458,7 +504,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Symbols.calendar_add_on),
|
const Icon(Symbols.calendar_add_on),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Text('publisherJoinedAt').tr(args: [DateFormat('y/M/d').format(_account!.createdAt)]),
|
Text('publisherJoinedAt').tr(args: [
|
||||||
|
DateFormat('y/M/d').format(_account!.createdAt)
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
@@ -475,6 +523,44 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (_account!.profile!.gender.isNotEmpty ||
|
||||||
|
_account!.profile!.pronouns.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.wc),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
_account!.profile!.gender.isNotEmpty
|
||||||
|
? _account!.profile!.gender
|
||||||
|
: 'unknown'.tr(),
|
||||||
|
),
|
||||||
|
Text(' · ').padding(horizontal: 4),
|
||||||
|
Text(
|
||||||
|
_account!.profile!.pronouns.isNotEmpty
|
||||||
|
? _account!.profile!.pronouns
|
||||||
|
: 'unknown'.tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_account!.profile!.timeZone.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.schedule),
|
||||||
|
const Gap(8),
|
||||||
|
Text(_account!.profile!.timeZone),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_account!.profile!.location.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.location_on),
|
||||||
|
const Gap(8),
|
||||||
|
Text(_account!.profile!.location),
|
||||||
|
],
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -491,17 +577,24 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Symbols.star),
|
const Icon(Symbols.star),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
|
Text(
|
||||||
|
'Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Text(calcLevelUpProgressLevel(_account?.profile?.experience ?? 0)).fontSize(11).opacity(0.5),
|
Text(calcLevelUpProgressLevel(
|
||||||
|
_account?.profile?.experience ?? 0))
|
||||||
|
.fontSize(11)
|
||||||
|
.opacity(0.5),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
constraints: const BoxConstraints(maxWidth: 160),
|
constraints: const BoxConstraints(maxWidth: 160),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: calcLevelUpProgress(_account?.profile?.experience ?? 0),
|
value: calcLevelUpProgress(
|
||||||
|
_account?.profile?.experience ?? 0),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
backgroundColor: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainer,
|
||||||
).alignment(Alignment.centerLeft),
|
).alignment(Alignment.centerLeft),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -511,24 +604,46 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
],
|
],
|
||||||
).padding(all: 16),
|
).padding(all: 16),
|
||||||
),
|
),
|
||||||
|
if (_account?.profile?.links.isNotEmpty ?? false)
|
||||||
|
SliverToBoxAdapter(child: const Divider()),
|
||||||
|
if (_account?.profile?.links.isNotEmpty ?? false)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: _account!.profile!.links.entries.map((ele) {
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Symbols.link),
|
||||||
|
title: Text(ele.key),
|
||||||
|
subtitle: Text(ele.value),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString(ele.value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(child: const Divider()),
|
SliverToBoxAdapter(child: const Divider()),
|
||||||
const SliverGap(12),
|
const SliverGap(12),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: FutureBuilder<List<SnCheckInRecord>>(
|
child: Builder(
|
||||||
future: _getCheckInRecords(),
|
builder: (context) {
|
||||||
builder: (context, snapshot) {
|
if (_records == null) return const SizedBox.shrink();
|
||||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
if (_records!.length <= 1) {
|
||||||
if (snapshot.data!.length <= 1) {
|
|
||||||
return Text(
|
return Text(
|
||||||
'accountCheckInNoRecords',
|
'accountCheckInNoRecords',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
).tr().fontWeight(FontWeight.bold).center().padding(horizontal: 20, vertical: 8);
|
)
|
||||||
|
.tr()
|
||||||
|
.fontWeight(FontWeight.bold)
|
||||||
|
.center()
|
||||||
|
.padding(horizontal: 20, vertical: 8);
|
||||||
}
|
}
|
||||||
final records = snapshot.data!;
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 240,
|
height: 240,
|
||||||
child: CheckInRecordChart(records: records),
|
child: CheckInRecordChart(records: _records!),
|
||||||
).padding(
|
).padding(
|
||||||
right: 24,
|
right: 24,
|
||||||
left: 16,
|
left: 16,
|
||||||
@@ -540,45 +655,55 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
|||||||
const SliverGap(12),
|
const SliverGap(12),
|
||||||
SliverToBoxAdapter(child: const Divider()),
|
SliverToBoxAdapter(child: const Divider()),
|
||||||
const SliverGap(12),
|
const SliverGap(12),
|
||||||
SliverToBoxAdapter(
|
if (_account?.badges.isNotEmpty ?? false)
|
||||||
child: Column(
|
SliverToBoxAdapter(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
|
children: [
|
||||||
SizedBox(
|
Text('accountBadge')
|
||||||
height: 80,
|
.bold()
|
||||||
width: double.infinity,
|
.fontSize(17)
|
||||||
child: ListView(
|
.tr()
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
.padding(horizontal: 20, bottom: 4),
|
||||||
scrollDirection: Axis.horizontal,
|
SizedBox(
|
||||||
children: [
|
height: 80,
|
||||||
for (final badge in _account?.badges ?? [])
|
width: double.infinity,
|
||||||
SizedBox(
|
child: ListView(
|
||||||
width: 280,
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Card(
|
scrollDirection: Axis.horizontal,
|
||||||
child: ListTile(
|
children: [
|
||||||
leading: Icon(
|
for (final badge in _account?.badges ?? [])
|
||||||
kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
|
SizedBox(
|
||||||
color: kBadgesMeta[badge.type]?.$3,
|
width: 280,
|
||||||
fill: 1,
|
child: Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
kBadgesMeta[badge.type]?.$2 ??
|
||||||
|
Symbols.question_mark,
|
||||||
|
color: badge.metadata['color'] != null
|
||||||
|
? HexColor.fromHex(
|
||||||
|
badge.metadata['color']!)
|
||||||
|
: kBadgesMeta[badge.type]?.$3,
|
||||||
|
fill: 1,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
|
||||||
|
).tr(),
|
||||||
|
subtitle: badge.metadata['title'] != null
|
||||||
|
? Text(badge.metadata['title'])
|
||||||
|
: Text(
|
||||||
|
DateFormat('y/M/d')
|
||||||
|
.format(badge.createdAt),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
|
||||||
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
|
|
||||||
).tr(),
|
|
||||||
subtitle: badge.metadata['title'] != null
|
|
||||||
? Text(badge.metadata['title'])
|
|
||||||
: Text(
|
|
||||||
DateFormat('y/M/d').format(badge.createdAt),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SliverGap(8),
|
const SliverGap(8),
|
||||||
SliverToBoxAdapter(child: const Divider()),
|
SliverToBoxAdapter(child: const Divider()),
|
||||||
SliverList.builder(
|
SliverList.builder(
|
||||||
@@ -664,7 +789,8 @@ class CheckInRecordChart extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh,
|
getTooltipColor: (_) =>
|
||||||
|
Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
titlesData: FlTitlesData(
|
titlesData: FlTitlesData(
|
||||||
|
|||||||
291
lib/screens/account/programs.dart
Normal file
291
lib/screens/account/programs.dart
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/experience.dart';
|
||||||
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/types/account.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
|
import 'package:surface/widgets/markdown_content.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
|
||||||
|
class AccountProgramScreen extends StatefulWidget {
|
||||||
|
const AccountProgramScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountProgramScreen> createState() => _AccountProgramScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountProgramScreenState extends State<AccountProgramScreen> {
|
||||||
|
bool _isBusy = false;
|
||||||
|
final List<SnProgram> _programs = List.empty(growable: true);
|
||||||
|
final List<SnProgramMember> _programMembers = List.empty(growable: true);
|
||||||
|
|
||||||
|
Future<void> _fetchPrograms() async {
|
||||||
|
_programs.clear();
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp = await sn.client.get('/cgi/id/programs');
|
||||||
|
_programs.addAll(
|
||||||
|
resp.data.map((ele) => SnProgram.fromJson(ele)).cast<SnProgram>(),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchProgramMembers() async {
|
||||||
|
_programMembers.clear();
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp = await sn.client.get('/cgi/id/programs/members');
|
||||||
|
_programMembers.addAll(
|
||||||
|
resp.data
|
||||||
|
.map((ele) => SnProgramMember.fromJson(ele))
|
||||||
|
.cast<SnProgramMember>(),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchPrograms();
|
||||||
|
_fetchProgramMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('accountProgram').tr(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: _programs.length,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
final ele = _programs[idx];
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||||
|
onTap: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
isScrollControlled: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _ProgramJoinPopup(
|
||||||
|
program: ele,
|
||||||
|
isJoined:
|
||||||
|
_programMembers.any((e) => e.programId == ele.id),
|
||||||
|
),
|
||||||
|
).then((value) {
|
||||||
|
if (value == true) {
|
||||||
|
_fetchProgramMembers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (ele.appearance['banner'] != null)
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 16 / 5,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceVariant,
|
||||||
|
child: Image.network(
|
||||||
|
ele.appearance['banner'],
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
ele.name,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium,
|
||||||
|
).bold(),
|
||||||
|
Text(
|
||||||
|
ele.description,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (_programMembers
|
||||||
|
.any((e) => e.programId == ele.id))
|
||||||
|
Text('accountProgramAlreadyJoined'.tr())
|
||||||
|
.opacity(0.75),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).padding(horizontal: 8);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProgramJoinPopup extends StatefulWidget {
|
||||||
|
final SnProgram program;
|
||||||
|
final bool isJoined;
|
||||||
|
const _ProgramJoinPopup({required this.program, required this.isJoined});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ProgramJoinPopup> createState() => _ProgramJoinPopupState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProgramJoinPopupState extends State<_ProgramJoinPopup> {
|
||||||
|
bool _isBusy = false;
|
||||||
|
|
||||||
|
Future<void> _joinProgram() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
await sn.client.post('/cgi/id/programs/${widget.program.id}');
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
context.showSnackbar('accountProgramJoined'.tr());
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _leaveProgram() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
await sn.client.delete('/cgi/id/programs/${widget.program.id}');
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
context.showSnackbar('accountProgramLeft'.tr());
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.75,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.add, size: 24),
|
||||||
|
const Gap(16),
|
||||||
|
Text(
|
||||||
|
'accountProgramJoin',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
).tr(),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (widget.program.appearance['banner'] != null)
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 16 / 5,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
|
child: Image.network(
|
||||||
|
widget.program.appearance['banner'],
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).padding(bottom: 12),
|
||||||
|
Text(
|
||||||
|
widget.program.name,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
).bold(),
|
||||||
|
MarkdownTextContent(content: widget.program.description),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
'accountProgramJoinRequirements',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
).tr().bold(),
|
||||||
|
Text('≥EXP ${widget.program.expRequirement}'),
|
||||||
|
Text('≥Lv${getLevelFromExp(widget.program.expRequirement)}'),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
'accountProgramJoinPricing',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
).tr().bold(),
|
||||||
|
Text('walletCurrency${widget.program.price['currency'].toString().capitalize().replaceFirst('Normal', '')}')
|
||||||
|
.plural(widget.program.price['amount'].toDouble()),
|
||||||
|
Text('accountProgramJoinPricingHint').tr().opacity(0.75),
|
||||||
|
const Gap(8),
|
||||||
|
if (widget.isJoined)
|
||||||
|
Text('accountProgramLeaveHint')
|
||||||
|
.tr()
|
||||||
|
.opacity(0.75)
|
||||||
|
.padding(bottom: 8),
|
||||||
|
if (!widget.isJoined)
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isBusy ? null : _joinProgram,
|
||||||
|
child: Text('join').tr(),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isBusy ? null : _leaveProgram,
|
||||||
|
child: Text('leave').tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 24),
|
||||||
|
Gap(MediaQuery.of(context).padding.bottom),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,10 +27,12 @@ class AccountPublisherEditScreen extends StatefulWidget {
|
|||||||
const AccountPublisherEditScreen({super.key, required this.name});
|
const AccountPublisherEditScreen({super.key, required this.name});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AccountPublisherEditScreen> createState() => _AccountPublisherEditScreenState();
|
State<AccountPublisherEditScreen> createState() =>
|
||||||
|
_AccountPublisherEditScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> {
|
class _AccountPublisherEditScreenState
|
||||||
|
extends State<AccountPublisherEditScreen> {
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
|
|
||||||
SnPublisher? _publisher;
|
SnPublisher? _publisher;
|
||||||
@@ -68,16 +70,19 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
|||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sn.client.put('/cgi/co/publishers/${widget.name}', data: {
|
await sn.client.put(
|
||||||
'avatar': _avatar,
|
'/cgi/co/publishers/${widget.name}',
|
||||||
'banner': _banner,
|
data: {
|
||||||
'nick': _nickController.text,
|
'avatar': _avatar,
|
||||||
'name': _nameController.text,
|
'banner': _banner,
|
||||||
'description': _descriptionController.text,
|
'nick': _nickController.text,
|
||||||
});
|
'name': _nameController.text,
|
||||||
|
'description': _descriptionController.text,
|
||||||
|
},
|
||||||
|
);
|
||||||
if (mounted) Navigator.pop(context, true);
|
if (mounted) Navigator.pop(context, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if(mounted) context.showErrorDialog(err);
|
if (mounted) context.showErrorDialog(err);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isBusy = false);
|
setState(() => _isBusy = false);
|
||||||
}
|
}
|
||||||
@@ -97,7 +102,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
|||||||
_banner = ua.user!.banner;
|
_banner = ua.user!.banner;
|
||||||
_nickController.text = ua.user!.nick;
|
_nickController.text = ua.user!.nick;
|
||||||
_nameController.text = ua.user!.name;
|
_nameController.text = ua.user!.name;
|
||||||
_descriptionController.text = ua.user!.description;
|
_descriptionController.text = ua.user!.profile!.description;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,32 +113,45 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
|||||||
if (image == null) return;
|
if (image == null) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
final skipCrop = image.path.endsWith('.gif');
|
||||||
final aspectRatios =
|
|
||||||
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
|
|
||||||
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
|
||||||
? await showCupertinoImageCropper(
|
|
||||||
// ignore: use_build_context_synchronously
|
|
||||||
context,
|
|
||||||
allowedAspectRatios: aspectRatios,
|
|
||||||
imageProvider: imageProvider,
|
|
||||||
)
|
|
||||||
: await showMaterialImageCropper(
|
|
||||||
// ignore: use_build_context_synchronously
|
|
||||||
context,
|
|
||||||
allowedAspectRatios: aspectRatios,
|
|
||||||
imageProvider: imageProvider,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result == null) return;
|
Uint8List? rawBytes;
|
||||||
|
if (!skipCrop) {
|
||||||
|
final ImageProvider imageProvider =
|
||||||
|
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||||
|
final aspectRatios = place == 'banner'
|
||||||
|
? [CropAspectRatio(width: 16, height: 7)]
|
||||||
|
: [CropAspectRatio(width: 1, height: 1)];
|
||||||
|
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||||
|
? await showCupertinoImageCropper(
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
context,
|
||||||
|
allowedAspectRatios: aspectRatios,
|
||||||
|
imageProvider: imageProvider,
|
||||||
|
)
|
||||||
|
: await showMaterialImageCropper(
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
context,
|
||||||
|
allowedAspectRatios: aspectRatios,
|
||||||
|
imageProvider: imageProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null) return;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!
|
||||||
|
.buffer
|
||||||
|
.asUint8List();
|
||||||
|
} else {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
rawBytes = await image.readAsBytes();
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final attach = context.read<SnAttachmentProvider>();
|
final attach = context.read<SnAttachmentProvider>();
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
|
||||||
|
|
||||||
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final attachment = await attach.directUploadOne(
|
final attachment = await attach.directUploadOne(
|
||||||
rawBytes,
|
rawBytes,
|
||||||
@@ -178,10 +196,10 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
|||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: PageBackButton(),
|
leading: PageBackButton(),
|
||||||
title: Text('screenAccountPublisherEdit').tr(),
|
title: Text('screenAccountPublisherEdit').tr()),
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -198,12 +216,13 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
|||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHigh,
|
||||||
child: _banner != null
|
child: _banner != null
|
||||||
? AutoResizeUniversalImage(
|
? AutoResizeUniversalImage(
|
||||||
sn.getAttachmentUrl(_banner!),
|
sn.getAttachmentUrl(_banner!),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover)
|
||||||
)
|
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -237,25 +256,24 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
|||||||
labelText: 'fieldUsername'.tr(),
|
labelText: 'fieldUsername'.tr(),
|
||||||
helperText: 'fieldUsernameCannotEditHint'.tr(),
|
helperText: 'fieldUsernameCannotEditHint'.tr(),
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const Gap(4),
|
const Gap(4),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _nickController,
|
controller: _nickController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(labelText: 'fieldNickname'.tr()),
|
||||||
labelText: 'fieldNickname'.tr(),
|
onTapOutside: (_) =>
|
||||||
),
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
|
||||||
),
|
),
|
||||||
const Gap(4),
|
const Gap(4),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _descriptionController,
|
controller: _descriptionController,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
minLines: 3,
|
minLines: 3,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(labelText: 'fieldDescription'.tr()),
|
||||||
labelText: 'fieldDescription'.tr(),
|
onTapOutside: (_) =>
|
||||||
),
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
|
||||||
),
|
),
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
Row(
|
Row(
|
||||||
@@ -275,7 +293,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
|||||||
icon: const Icon(Symbols.save),
|
icon: const Icon(Symbols.save),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 24, vertical: 12),
|
).padding(horizontal: 24, vertical: 12),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: const PageBackButton(),
|
leading: const PageBackButton(),
|
||||||
title: Text('screenAccountPublisherNew').tr(),
|
title: Text('screenAccountPublisherNew').tr(),
|
||||||
@@ -109,7 +110,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
|
|||||||
|
|
||||||
_nameController.text = ua.user!.name;
|
_nameController.text = ua.user!.name;
|
||||||
_nickController.text = ua.user!.nick;
|
_nickController.text = ua.user!.nick;
|
||||||
_descriptionController.text = ua.user!.description;
|
_descriptionController.text = ua.user!.profile!.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ class _PublisherScreenState extends State<PublisherScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final resp = await sn.client.get('/cgi/co/publishers/me');
|
final resp = await sn.client.get('/cgi/co/publishers/me');
|
||||||
final List<SnPublisher> out = List<SnPublisher>.from(resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
|
final List<SnPublisher> out = List<SnPublisher>.from(
|
||||||
|
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: const PageBackButton(),
|
leading: const PageBackButton(),
|
||||||
title: Text('screenAccountPublishers').tr(),
|
title: Text('screenAccountPublishers').tr(),
|
||||||
@@ -93,7 +95,9 @@ class _PublisherScreenState extends State<PublisherScreen> {
|
|||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
leading: const Icon(Symbols.add_circle),
|
leading: const Icon(Symbols.add_circle),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
|
GoRouter.of(context)
|
||||||
|
.pushNamed('accountPublisherNew')
|
||||||
|
.then((value) {
|
||||||
if (value == true) {
|
if (value == true) {
|
||||||
_publishers.clear();
|
_publishers.clear();
|
||||||
_fetchPublishers();
|
_fetchPublishers();
|
||||||
@@ -119,7 +123,8 @@ class _PublisherScreenState extends State<PublisherScreen> {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(publisher.nick),
|
title: Text(publisher.nick),
|
||||||
subtitle: Text('@${publisher.name}'),
|
subtitle: Text('@${publisher.name}'),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16),
|
||||||
leading: AccountImage(content: publisher.avatar),
|
leading: AccountImage(content: publisher.avatar),
|
||||||
trailing: PopupMenuButton(
|
trailing: PopupMenuButton(
|
||||||
itemBuilder: (BuildContext context) => [
|
itemBuilder: (BuildContext context) => [
|
||||||
|
|||||||
187
lib/screens/account/punishments.dart
Normal file
187
lib/screens/account/punishments.dart
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/types/account.dart';
|
||||||
|
import 'package:surface/widgets/account/account_image.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
|
||||||
|
const kPunishmentIcons = [
|
||||||
|
Symbols.warning,
|
||||||
|
Symbols.emergency_home,
|
||||||
|
Symbols.dangerous,
|
||||||
|
];
|
||||||
|
|
||||||
|
class PunishmentsScreen extends StatefulWidget {
|
||||||
|
const PunishmentsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PunishmentsScreen> createState() => _PunishmentsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PunishmentsScreenState extends State<PunishmentsScreen> {
|
||||||
|
bool _isBusy = false;
|
||||||
|
List<SnPunishment>? _punishments;
|
||||||
|
|
||||||
|
Future<void> _fetchPunishments() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp = await sn.client.get('/cgi/id/punishments');
|
||||||
|
if (!mounted) return;
|
||||||
|
_punishments = List.from(
|
||||||
|
resp.data.map((ele) => SnPunishment.fromJson(ele)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchPunishments();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('accountPunishments').tr(),
|
||||||
|
leading: PageBackButton(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
Card(
|
||||||
|
margin: EdgeInsets.only(bottom: 8, left: 8, right: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.visibility, size: 20),
|
||||||
|
const Gap(6),
|
||||||
|
Expanded(
|
||||||
|
child: Text('punishmentOverall').tr().fontSize(16).bold(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
if (_punishments == null) return Text('loading').tr();
|
||||||
|
if (_punishments!.any((ele) => ele.type == 2)) {
|
||||||
|
return Text('punishmentStatusBanned').tr();
|
||||||
|
}
|
||||||
|
if (_punishments!.any(
|
||||||
|
(ele) => ele.type == 1 && ele.permNodes.isEmpty,
|
||||||
|
)) {
|
||||||
|
return Text('punishmentStatusLimitedFully').tr();
|
||||||
|
} else if (_punishments!.any((ele) => ele.type == 1)) {
|
||||||
|
return Text('punishmentStatusLimited').tr();
|
||||||
|
}
|
||||||
|
if (_punishments!.any((ele) => ele.type == 0)) {
|
||||||
|
return Text('punishmentStatusWarned').tr();
|
||||||
|
}
|
||||||
|
return Text('punishmentStatusNormal').tr();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 24, vertical: 16),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _fetchPunishments,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: _punishments?.length ?? 0,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final ele = _punishments![index];
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(kPunishmentIcons[ele.type], size: 20),
|
||||||
|
const Gap(6),
|
||||||
|
Expanded(
|
||||||
|
child: Text('punishmentType${ele.type}')
|
||||||
|
.tr()
|
||||||
|
.fontSize(16)
|
||||||
|
.bold(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(ele.reason),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
'punishmentCreatedAt'.tr(args: [
|
||||||
|
DateFormat().format(
|
||||||
|
ele.createdAt.toLocal(),
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
).opacity(0.8),
|
||||||
|
Text(
|
||||||
|
ele.expiredAt == null
|
||||||
|
? 'punishmentExpiredNever'.tr()
|
||||||
|
: 'punishmentExpiredAt'.tr(args: [
|
||||||
|
DateFormat().format(
|
||||||
|
ele.expiredAt!.toLocal(),
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
).opacity(0.8),
|
||||||
|
const Gap(8),
|
||||||
|
if (ele.moderator != null)
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('punishmentModerator').tr().opacity(0.75),
|
||||||
|
InkWell(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
AccountImage(
|
||||||
|
content: ele.moderator!.avatar,
|
||||||
|
radius: 8,
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
Text(ele.moderator?.nick ?? 'unknown'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
GoRouter.of(context).pushNamed(
|
||||||
|
'accountProfilePage',
|
||||||
|
pathParameters: {
|
||||||
|
'name': ele.moderator!.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text('punishmentMadeBySystem').tr().opacity(0.75),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 24, vertical: 16),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (_, __) => const Gap(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ class AccountSettingsScreen extends StatelessWidget {
|
|||||||
final ua = context.watch<UserProvider>();
|
final ua = context.watch<UserProvider>();
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: PageBackButton(),
|
leading: PageBackButton(),
|
||||||
title: Text('screenAccountSettings').tr(),
|
title: Text('screenAccountSettings').tr(),
|
||||||
@@ -87,6 +88,46 @@ class AccountSettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text('accountContactMethods').tr(),
|
||||||
|
subtitle: Text('accountContactMethodsDescription').tr(),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Symbols.contacts),
|
||||||
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
GoRouter.of(context).pushNamed('accountContactMethods');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text('accountSettingsNotify').tr(),
|
||||||
|
subtitle: Text('accountSettingsNotifyDescription').tr(),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Symbols.notifications),
|
||||||
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
GoRouter.of(context).pushNamed('accountSettingsNotify');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text('accountSettingsSecurity').tr(),
|
||||||
|
subtitle: Text('accountSettingsSecurityDescription').tr(),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Symbols.shield),
|
||||||
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
GoRouter.of(context).pushNamed('accountSettingsSecurity');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text('factorSettings').tr(),
|
||||||
|
subtitle: Text('factorSettingsSubtitle').tr(),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Symbols.lock),
|
||||||
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
GoRouter.of(context).pushNamed('factorSettings');
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('accountProfileEdit').tr(),
|
title: Text('accountProfileEdit').tr(),
|
||||||
subtitle: Text('accountProfileEditSubtitle').tr(),
|
subtitle: Text('accountProfileEditSubtitle').tr(),
|
||||||
@@ -10,7 +10,6 @@ import 'package:styled_widget/styled_widget.dart';
|
|||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/providers/user_directory.dart';
|
import 'package:surface/providers/user_directory.dart';
|
||||||
import 'package:surface/types/attachment.dart';
|
import 'package:surface/types/attachment.dart';
|
||||||
import 'package:surface/widgets/app_bar_leading.dart';
|
|
||||||
import 'package:surface/widgets/attachment/attachment_zoom.dart';
|
import 'package:surface/widgets/attachment/attachment_zoom.dart';
|
||||||
import 'package:surface/widgets/attachment/attachment_item.dart';
|
import 'package:surface/widgets/attachment/attachment_item.dart';
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
@@ -106,7 +105,7 @@ class _AlbumScreenState extends State<AlbumScreen> {
|
|||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
leading: AutoAppBarLeading(),
|
leading: PageBackButton(),
|
||||||
title: Text('screenAlbum').tr(),
|
title: Text('screenAlbum').tr(),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -119,7 +118,8 @@ class _AlbumScreenState extends State<AlbumScreen> {
|
|||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: _billing?.includedRatio ?? 0,
|
value: _billing?.includedRatio ?? 0,
|
||||||
strokeWidth: 8,
|
strokeWidth: 8,
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
|
backgroundColor:
|
||||||
|
Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
),
|
),
|
||||||
).padding(all: 12),
|
).padding(all: 12),
|
||||||
const Gap(24),
|
const Gap(24),
|
||||||
@@ -129,7 +129,8 @@ class _AlbumScreenState extends State<AlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text('attachmentBillingUploaded').tr().bold(),
|
Text('attachmentBillingUploaded').tr().bold(),
|
||||||
Text(
|
Text(
|
||||||
(_billing?.currentBytes ?? 0).formatBytes(decimals: 4),
|
(_billing?.currentBytes ?? 0)
|
||||||
|
.formatBytes(decimals: 4),
|
||||||
style: GoogleFonts.robotoMono(),
|
style: GoogleFonts.robotoMono(),
|
||||||
),
|
),
|
||||||
Text('attachmentBillingDiscount').tr().bold(),
|
Text('attachmentBillingDiscount').tr().bold(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/screens/captcha/captcha.dart';
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
@@ -33,10 +34,20 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
final username = _usernameController.value.text;
|
final username = _usernameController.value.text;
|
||||||
final nickname = _nicknameController.value.text;
|
final nickname = _nicknameController.value.text;
|
||||||
final password = _passwordController.value.text;
|
final password = _passwordController.value.text;
|
||||||
if (email.isEmpty || username.isEmpty || nickname.isEmpty || password.isEmpty) {
|
if (email.isEmpty ||
|
||||||
|
username.isEmpty ||
|
||||||
|
nickname.isEmpty ||
|
||||||
|
password.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final captchaTk = await Navigator.of(context, rootNavigator: true).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => CaptchaScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (captchaTk == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
await sn.client.post('/cgi/id/users', data: {
|
await sn.client.post('/cgi/id/users', data: {
|
||||||
@@ -45,6 +56,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
'email': email,
|
'email': email,
|
||||||
'password': password,
|
'password': password,
|
||||||
'language': EasyLocalization.of(context)!.currentLocale.toString(),
|
'language': EasyLocalization.of(context)!.currentLocale.toString(),
|
||||||
|
'captcha_token': captchaTk,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -91,8 +103,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.length < 4 || value.length > 32) {
|
if (value == null ||
|
||||||
return 'fieldUsernameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
|
value.length < 4 ||
|
||||||
|
value.length > 32) {
|
||||||
|
return 'fieldUsernameLengthLimit'
|
||||||
|
.tr(args: [4.toString(), 32.toString()]);
|
||||||
}
|
}
|
||||||
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
|
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
|
||||||
return 'fieldUsernameAlphanumOnly'.tr();
|
return 'fieldUsernameAlphanumOnly'.tr();
|
||||||
@@ -108,13 +123,17 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
labelText: 'fieldUsername'.tr(),
|
labelText: 'fieldUsername'.tr(),
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.length < 4 || value.length > 32) {
|
if (value == null ||
|
||||||
return 'fieldNicknameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
|
value.length < 4 ||
|
||||||
|
value.length > 32) {
|
||||||
|
return 'fieldNicknameLengthLimit'
|
||||||
|
.tr(args: [4.toString(), 32.toString()]);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -127,7 +146,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
labelText: 'fieldNickname'.tr(),
|
labelText: 'fieldNickname'.tr(),
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
@@ -149,7 +169,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
labelText: 'fieldEmail'.tr(),
|
labelText: 'fieldEmail'.tr(),
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
@@ -169,7 +190,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
labelText: 'fieldPassword'.tr(),
|
labelText: 'fieldPassword'.tr(),
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 7),
|
).padding(horizontal: 7),
|
||||||
@@ -186,9 +208,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
Text(
|
Text(
|
||||||
'termAcceptNextWithAgree'.tr(),
|
'termAcceptNextWithAgree'.tr(),
|
||||||
textAlign: TextAlign.end,
|
textAlign: TextAlign.end,
|
||||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
style:
|
||||||
color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
|
Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||||
),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withAlpha((255 * 0.75).round()),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Material(
|
Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
|
|||||||
3
lib/screens/captcha/captcha.dart
Normal file
3
lib/screens/captcha/captcha.dart
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
|
||||||
|
export 'captcha_native.dart' if (kIsWeb) 'captcha_web.dart';
|
||||||
37
lib/screens/captcha/captcha_native.dart
Normal file
37
lib/screens/captcha/captcha_native.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/providers/config.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
|
||||||
|
class CaptchaScreen extends StatefulWidget {
|
||||||
|
const CaptchaScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CaptchaScreen> createState() => _CaptchaScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CaptchaScreenState extends State<CaptchaScreen> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cfg = context.read<ConfigProvider>();
|
||||||
|
|
||||||
|
return AppScaffold(
|
||||||
|
appBar: AppBar(title: Text("reCaptcha").tr()),
|
||||||
|
body: InAppWebView(
|
||||||
|
initialUrlRequest: URLRequest(
|
||||||
|
url: WebUri('${cfg.serverUrl}/captcha?redirect_uri=solink://captcha'),
|
||||||
|
),
|
||||||
|
shouldOverrideUrlLoading: (controller, navigationAction) async {
|
||||||
|
Uri? url = navigationAction.request.url;
|
||||||
|
if (url != null && url.queryParameters.containsKey('captcha_tk')) {
|
||||||
|
Navigator.pop(context, url.queryParameters['captcha_tk']!);
|
||||||
|
return NavigationActionPolicy.CANCEL;
|
||||||
|
}
|
||||||
|
return NavigationActionPolicy.ALLOW;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
lib/screens/captcha/captcha_web.dart
Normal file
54
lib/screens/captcha/captcha_web.dart
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import 'dart:html' as html;
|
||||||
|
import 'dart:ui_web' as ui;
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:surface/providers/config.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
|
||||||
|
class CaptchaScreen extends StatefulWidget {
|
||||||
|
const CaptchaScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CaptchaScreen> createState() => _CaptchaScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CaptchaScreenState extends State<CaptchaScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_setupWebListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupWebListener() {
|
||||||
|
html.window.onMessage.listen((event) {
|
||||||
|
if (event.data != null && event.data is String) {
|
||||||
|
final message = event.data as String;
|
||||||
|
if (message.startsWith("captcha_tk=")) {
|
||||||
|
String token = message.replaceFirst("captcha_tk=", "");
|
||||||
|
Navigator.pop(context, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final iframe = html.IFrameElement()
|
||||||
|
..src = '${context.read<ConfigProvider>().serverUrl}/captcha?redirect_uri=web'
|
||||||
|
..style.border = 'none'
|
||||||
|
..width = '100%'
|
||||||
|
..height = '100%';
|
||||||
|
|
||||||
|
html.document.body!.append(iframe);
|
||||||
|
ui.platformViewRegistry.registerViewFactory(
|
||||||
|
'captcha-iframe',
|
||||||
|
(int viewId) => iframe,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
appBar: AppBar(title: Text("reCaptcha").tr()),
|
||||||
|
body: HtmlElementView(viewType: 'captcha-iframe'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,19 +6,16 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:responsive_framework/responsive_framework.dart';
|
|
||||||
import 'package:surface/providers/channel.dart';
|
import 'package:surface/providers/channel.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/providers/user_directory.dart';
|
import 'package:surface/providers/user_directory.dart';
|
||||||
import 'package:surface/providers/userinfo.dart';
|
import 'package:surface/providers/userinfo.dart';
|
||||||
import 'package:surface/screens/chat/room.dart';
|
|
||||||
import 'package:surface/types/chat.dart';
|
import 'package:surface/types/chat.dart';
|
||||||
import 'package:surface/widgets/account/account_image.dart';
|
import 'package:surface/widgets/account/account_image.dart';
|
||||||
import 'package:surface/widgets/account/account_select.dart';
|
import 'package:surface/widgets/account/account_select.dart';
|
||||||
import 'package:surface/widgets/app_bar_leading.dart';
|
import 'package:surface/widgets/app_bar_leading.dart';
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
import 'package:surface/widgets/loading_indicator.dart';
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
import 'package:surface/widgets/navigation/app_background.dart';
|
|
||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
import 'package:surface/widgets/unauthorized_hint.dart';
|
import 'package:surface/widgets/unauthorized_hint.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@@ -74,18 +71,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final ud = context.read<UserDirectoryProvider>();
|
final ud = context.read<UserDirectoryProvider>();
|
||||||
|
final idSet = <int>{};
|
||||||
for (final channel in channels) {
|
for (final channel in channels) {
|
||||||
if (channel.type == 1) {
|
if (channel.type == 1) {
|
||||||
await ud.listAccount(
|
idSet.addAll(
|
||||||
channel.members
|
channel.members
|
||||||
?.cast<SnChannelMember?>()
|
?.cast<SnChannelMember?>()
|
||||||
.map((ele) => ele?.accountId)
|
.map((ele) => ele?.accountId)
|
||||||
.where((ele) => ele != null)
|
.where((ele) => ele != null)
|
||||||
.toSet() ??
|
.cast<int>() ??
|
||||||
{},
|
[],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (idSet.isNotEmpty) await ud.listAccount(idSet);
|
||||||
|
|
||||||
if (mounted) setState(() => _channels = channels);
|
if (mounted) setState(() => _channels = channels);
|
||||||
})
|
})
|
||||||
@@ -128,8 +127,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SnChannel? _focusChannel;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -138,13 +135,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onTapChannel(SnChannel channel) {
|
void _onTapChannel(SnChannel channel) {
|
||||||
final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP);
|
setState(() => _unreadCounts?[channel.id] = 0);
|
||||||
|
GoRouter.of(context).pushReplacementNamed(
|
||||||
if (doExpand) {
|
|
||||||
setState(() => _focusChannel = channel);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
GoRouter.of(context).pushNamed(
|
|
||||||
'chatRoom',
|
'chatRoom',
|
||||||
pathParameters: {
|
pathParameters: {
|
||||||
'scope': channel.realm?.alias ?? 'global',
|
'scope': channel.realm?.alias ?? 'global',
|
||||||
@@ -152,7 +144,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
},
|
},
|
||||||
).then((value) {
|
).then((value) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_unreadCounts?[channel.id] = 0;
|
|
||||||
setState(() => _unreadCounts?[channel.id] = 0);
|
setState(() => _unreadCounts?[channel.id] = 0);
|
||||||
_refreshChannels(noRemote: true);
|
_refreshChannels(noRemote: true);
|
||||||
}
|
}
|
||||||
@@ -161,7 +152,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final ud = context.read<UserDirectoryProvider>();
|
|
||||||
final ua = context.read<UserProvider>();
|
final ua = context.read<UserProvider>();
|
||||||
|
|
||||||
if (!ua.isAuthorized) {
|
if (!ua.isAuthorized) {
|
||||||
@@ -176,10 +166,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP);
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
final chatList = AppScaffold(
|
|
||||||
noBackground: doExpand,
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: AutoAppBarLeading(),
|
leading: AutoAppBarLeading(),
|
||||||
title: Text('screenChat').tr(),
|
title: Text('screenChat').tr(),
|
||||||
@@ -203,7 +191,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||||
shape: const CircleBorder(),
|
|
||||||
),
|
),
|
||||||
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
|
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
|
||||||
child: const Icon(Symbols.close, size: 28),
|
child: const Icon(Symbols.close, size: 28),
|
||||||
@@ -212,7 +199,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||||
shape: const CircleBorder(),
|
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
@@ -264,123 +250,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final channel = _channels![idx];
|
final channel = _channels![idx];
|
||||||
final lastMessage = _lastMessages?[channel.id];
|
final lastMessage = _lastMessages?[channel.id];
|
||||||
|
|
||||||
if (channel.type == 1) {
|
return _ChatChannelEntry(
|
||||||
final otherMember =
|
channel: channel,
|
||||||
channel.members?.cast<SnChannelMember?>().firstWhere(
|
lastMessage: lastMessage,
|
||||||
(ele) => ele?.accountId != ua.user?.id,
|
unreadCount: _unreadCounts?[channel.id],
|
||||||
orElse: () => null,
|
|
||||||
);
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(ud
|
|
||||||
.getAccountFromCache(
|
|
||||||
otherMember?.accountId)
|
|
||||||
?.nick ??
|
|
||||||
channel.name),
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
if (_unreadCounts?[channel.id] != null &&
|
|
||||||
_unreadCounts![channel.id]! > 0)
|
|
||||||
Badge(
|
|
||||||
label: Text('${_unreadCounts![channel.id]}'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: lastMessage != null
|
|
||||||
? Text(
|
|
||||||
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
channel.description,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
leading: AccountImage(
|
|
||||||
content: ud
|
|
||||||
.getAccountFromCache(otherMember?.accountId)
|
|
||||||
?.avatar,
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
_onTapChannel(channel);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: Text(channel.name)),
|
|
||||||
const Gap(8),
|
|
||||||
if (_unreadCounts?[channel.id] != null &&
|
|
||||||
_unreadCounts![channel.id]! > 0)
|
|
||||||
Badge(
|
|
||||||
label: Text('${_unreadCounts![channel.id]}'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: lastMessage != null
|
|
||||||
? Row(
|
|
||||||
children: [
|
|
||||||
Badge(
|
|
||||||
label: Text(ud
|
|
||||||
.getAccountFromCache(
|
|
||||||
lastMessage.sender.accountId)
|
|
||||||
?.nick ??
|
|
||||||
'unknown'.tr()),
|
|
||||||
backgroundColor:
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const Gap(6),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
lastMessage.body['text'] ??
|
|
||||||
'Unable preview',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
DateFormat(
|
|
||||||
lastMessage.createdAt.toLocal().day ==
|
|
||||||
DateTime.now().day
|
|
||||||
? 'HH:mm'
|
|
||||||
: lastMessage.createdAt
|
|
||||||
.toLocal()
|
|
||||||
.year ==
|
|
||||||
DateTime.now().year
|
|
||||||
? 'MM/dd'
|
|
||||||
: 'yy/MM/dd',
|
|
||||||
).format(lastMessage.createdAt.toLocal()),
|
|
||||||
style: GoogleFonts.robotoMono(
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
channel.description,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
leading: AccountImage(
|
|
||||||
content: channel.realm?.avatar,
|
|
||||||
fallbackWidget: const Icon(Symbols.chat, size: 20),
|
|
||||||
),
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (doExpand) {
|
|
||||||
_unreadCounts?[channel.id] = 0;
|
|
||||||
setState(() => _focusChannel = channel);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_onTapChannel(channel);
|
_onTapChannel(channel);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -392,27 +266,102 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if (doExpand) {
|
}
|
||||||
return AppBackground(
|
|
||||||
isRoot: true,
|
class _ChatChannelEntry extends StatelessWidget {
|
||||||
child: Row(
|
final SnChannel channel;
|
||||||
children: [
|
final int? unreadCount;
|
||||||
SizedBox(width: 340, child: chatList),
|
final SnChatMessage? lastMessage;
|
||||||
const VerticalDivider(width: 1),
|
final Function? onTap;
|
||||||
if (_focusChannel != null)
|
const _ChatChannelEntry({
|
||||||
Expanded(
|
required this.channel,
|
||||||
child: ChatRoomScreen(
|
this.unreadCount,
|
||||||
key: ValueKey(_focusChannel!.id),
|
this.lastMessage,
|
||||||
scope: _focusChannel!.realm?.alias ?? 'global',
|
this.onTap,
|
||||||
alias: _focusChannel!.alias,
|
});
|
||||||
),
|
|
||||||
),
|
@override
|
||||||
],
|
Widget build(BuildContext context) {
|
||||||
),
|
final ud = context.read<UserDirectoryProvider>();
|
||||||
);
|
final ua = context.read<UserProvider>();
|
||||||
}
|
|
||||||
|
final otherMember = channel.type == 1
|
||||||
return chatList;
|
? channel.members?.cast<SnChannelMember?>().firstWhere(
|
||||||
|
(ele) => ele?.accountId != ua.user?.id,
|
||||||
|
orElse: () => null,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final title = otherMember != null
|
||||||
|
? ud.getFromCache(otherMember.accountId)?.nick ?? channel.name
|
||||||
|
: channel.name;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(title)),
|
||||||
|
const Gap(8),
|
||||||
|
if (unreadCount != null && unreadCount! > 0)
|
||||||
|
Badge(
|
||||||
|
label: Text(unreadCount.toString()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: lastMessage != null
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
Badge(
|
||||||
|
label: Text(
|
||||||
|
ud.getFromCache(lastMessage!.sender.accountId)?.nick ??
|
||||||
|
'unknown'.tr()),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
const Gap(6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
lastMessage!.body['algorithm'] == 'plain'
|
||||||
|
? lastMessage!.body['text'] ??
|
||||||
|
'messageUnablePreview'.tr()
|
||||||
|
: 'messageUnablePreviewEncrypted'.tr(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: lastMessage!.body['algorithm'] != 'plain' ||
|
||||||
|
lastMessage!.body['text'] == null
|
||||||
|
? TextStyle(fontStyle: FontStyle.italic)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
DateFormat(
|
||||||
|
lastMessage!.createdAt.toLocal().day == DateTime.now().day
|
||||||
|
? 'HH:mm'
|
||||||
|
: lastMessage!.createdAt.toLocal().year ==
|
||||||
|
DateTime.now().year
|
||||||
|
? 'MM/dd'
|
||||||
|
: 'yy/MM/dd',
|
||||||
|
).format(lastMessage!.createdAt.toLocal()),
|
||||||
|
style: GoogleFonts.robotoMono(
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
channel.description,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
leading: AccountImage(
|
||||||
|
content: otherMember != null
|
||||||
|
? ud.getFromCache(otherMember.accountId)?.avatar
|
||||||
|
: channel.realm?.avatar,
|
||||||
|
fallbackWidget: const Icon(Symbols.chat, size: 20),
|
||||||
|
),
|
||||||
|
onTap: () => onTap?.call(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
|||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
|
color:
|
||||||
|
Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
|
||||||
child: call.focusTrack != null
|
child: call.focusTrack != null
|
||||||
? InteractiveParticipantWidget(
|
? InteractiveParticipantWidget(
|
||||||
isFixedAvatar: false,
|
isFixedAvatar: false,
|
||||||
@@ -72,7 +73,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
|||||||
color: Theme.of(context).cardColor,
|
color: Theme.of(context).cardColor,
|
||||||
participant: track,
|
participant: track,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (track.participant.sid != call.focusTrack?.participant.sid) {
|
if (track.participant.sid !=
|
||||||
|
call.focusTrack?.participant.sid) {
|
||||||
call.setFocusTrack(track);
|
call.setFocusTrack(track);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -114,10 +116,14 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
child: InteractiveParticipantWidget(
|
child: InteractiveParticipantWidget(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh.withOpacity(0.75),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHigh
|
||||||
|
.withOpacity(0.75),
|
||||||
participant: track,
|
participant: track,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (track.participant.sid != call.focusTrack?.participant.sid) {
|
if (track.participant.sid !=
|
||||||
|
call.focusTrack?.participant.sid) {
|
||||||
call.setFocusTrack(track);
|
call.setFocusTrack(track);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -149,6 +155,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
|||||||
listenable: call,
|
listenable: call,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: RichText(
|
title: RichText(
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -183,7 +190,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
|||||||
Builder(builder: (context) {
|
Builder(builder: (context) {
|
||||||
final call = context.read<ChatCallProvider>();
|
final call = context.read<ChatCallProvider>();
|
||||||
final connectionQuality =
|
final connectionQuality =
|
||||||
call.room.localParticipant?.connectionQuality ?? livekit.ConnectionQuality.unknown;
|
call.room.localParticipant?.connectionQuality ??
|
||||||
|
livekit.ConnectionQuality.unknown;
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -205,24 +213,35 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
{
|
{
|
||||||
livekit.ConnectionState.disconnected: 'callStatusDisconnected'.tr(),
|
livekit.ConnectionState.disconnected:
|
||||||
livekit.ConnectionState.connected: 'callStatusConnected'.tr(),
|
'callStatusDisconnected'.tr(),
|
||||||
livekit.ConnectionState.connecting: 'callStatusConnecting'.tr(),
|
livekit.ConnectionState.connected:
|
||||||
livekit.ConnectionState.reconnecting: 'callStatusReconnecting'.tr(),
|
'callStatusConnected'.tr(),
|
||||||
|
livekit.ConnectionState.connecting:
|
||||||
|
'callStatusConnecting'.tr(),
|
||||||
|
livekit.ConnectionState.reconnecting:
|
||||||
|
'callStatusReconnecting'.tr(),
|
||||||
}[call.room.connectionState]!,
|
}[call.room.connectionState]!,
|
||||||
),
|
),
|
||||||
const Gap(6),
|
const Gap(6),
|
||||||
if (connectionQuality != livekit.ConnectionQuality.unknown)
|
if (connectionQuality !=
|
||||||
|
livekit.ConnectionQuality.unknown)
|
||||||
Icon(
|
Icon(
|
||||||
{
|
{
|
||||||
livekit.ConnectionQuality.excellent: Icons.signal_cellular_alt,
|
livekit.ConnectionQuality.excellent:
|
||||||
livekit.ConnectionQuality.good: Icons.signal_cellular_alt_2_bar,
|
Icons.signal_cellular_alt,
|
||||||
livekit.ConnectionQuality.poor: Icons.signal_cellular_alt_1_bar,
|
livekit.ConnectionQuality.good:
|
||||||
|
Icons.signal_cellular_alt_2_bar,
|
||||||
|
livekit.ConnectionQuality.poor:
|
||||||
|
Icons.signal_cellular_alt_1_bar,
|
||||||
}[connectionQuality],
|
}[connectionQuality],
|
||||||
color: {
|
color: {
|
||||||
livekit.ConnectionQuality.excellent: Colors.green,
|
livekit.ConnectionQuality.excellent:
|
||||||
livekit.ConnectionQuality.good: Colors.orange,
|
Colors.green,
|
||||||
livekit.ConnectionQuality.poor: Colors.red,
|
livekit.ConnectionQuality.good:
|
||||||
|
Colors.orange,
|
||||||
|
livekit.ConnectionQuality.poor:
|
||||||
|
Colors.red,
|
||||||
}[connectionQuality],
|
}[connectionQuality],
|
||||||
size: 16,
|
size: 16,
|
||||||
)
|
)
|
||||||
@@ -244,7 +263,9 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: _layoutMode == 0 ? const Icon(Icons.view_list) : const Icon(Icons.grid_view),
|
icon: _layoutMode == 0
|
||||||
|
? const Icon(Icons.view_list)
|
||||||
|
: const Icon(Icons.grid_view),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_switchLayout();
|
_switchLayout();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,11 +57,10 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
|||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final ct = context.read<ChatChannelProvider>();
|
||||||
final resp =
|
final resp = await ct.getChannelProfile(_channel!);
|
||||||
await sn.client.get('/cgi/im/channels/${_channel!.keyPath}/me');
|
_profile = resp;
|
||||||
_profile = SnChannelMember.fromJson(resp.data);
|
_notifyLevel = resp.notify;
|
||||||
_notifyLevel = _profile!.notify;
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final ud = context.read<UserDirectoryProvider>();
|
final ud = context.read<UserDirectoryProvider>();
|
||||||
await ud.getAccount(_profile!.accountId);
|
await ud.getAccount(_profile!.accountId);
|
||||||
@@ -103,10 +102,12 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final ct = context.read<ChatChannelProvider>();
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
await sn.client.delete(
|
await sn.client.delete(
|
||||||
'/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.alias}/me',
|
'/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.alias}/me',
|
||||||
);
|
);
|
||||||
|
await ct.removeLocalChannel(_channel!);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.pop(context, false);
|
Navigator.pop(context, false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -130,12 +131,15 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
|||||||
setState(() => _isUpdatingNotifyLevel = true);
|
setState(() => _isUpdatingNotifyLevel = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final ct = context.read<ChatChannelProvider>();
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
await sn.client.put(
|
final resp = await sn.client.put(
|
||||||
'/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
|
'/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
|
||||||
data: {'notify_level': value},
|
data: {'notify_level': value},
|
||||||
);
|
);
|
||||||
|
_profile = SnChannelMember.fromJson(resp.data);
|
||||||
_notifyLevel = value;
|
_notifyLevel = value;
|
||||||
|
await ct.updateChannelProfile(_profile!);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showSnackbar('channelNotifyLevelApplied'.tr());
|
context.showSnackbar('channelNotifyLevelApplied'.tr());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -216,6 +220,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
|||||||
final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id;
|
final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id;
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
|
title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
|
||||||
),
|
),
|
||||||
@@ -289,15 +294,14 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: AccountImage(
|
leading: AccountImage(
|
||||||
content:
|
content: ud.getFromCache(_profile!.accountId)?.avatar,
|
||||||
ud.getAccountFromCache(_profile!.accountId)?.avatar,
|
|
||||||
radius: 18,
|
radius: 18,
|
||||||
),
|
),
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
title: Text('channelEditProfile').tr(),
|
title: Text('channelEditProfile').tr(),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
(_profile?.nick?.isEmpty ?? true)
|
(_profile?.nick?.isEmpty ?? true)
|
||||||
? ud.getAccountFromCache(_profile!.accountId)!.nick
|
? ud.getFromCache(_profile!.accountId)!.nick
|
||||||
: _profile!.nick!,
|
: _profile!.nick!,
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.only(left: 20, right: 20),
|
contentPadding: const EdgeInsets.only(left: 20, right: 20),
|
||||||
@@ -408,11 +412,14 @@ class _ChannelProfileDetailDialogState
|
|||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final ct = context.read<ChatChannelProvider>();
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
await sn.client.put(
|
final resp = await sn.client.put(
|
||||||
'/cgi/im/channels/${widget.channel.keyPath}/members/me',
|
'/cgi/im/channels/${widget.channel.keyPath}/members/me',
|
||||||
data: {'nick': _nickController.text},
|
data: {'nick': _nickController.text},
|
||||||
);
|
);
|
||||||
|
final out = SnChannelMember.fromJson(resp.data);
|
||||||
|
await ct.updateChannelProfile(out);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.pop(context, true);
|
Navigator.pop(context, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -575,11 +582,10 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.only(right: 24, left: 16),
|
contentPadding: const EdgeInsets.only(right: 24, left: 16),
|
||||||
leading: AccountImage(
|
leading: AccountImage(
|
||||||
content: ud.getAccountFromCache(member.accountId)?.avatar,
|
content: ud.getFromCache(member.accountId)?.avatar,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
ud.getAccountFromCache(member.accountId)?.name ??
|
ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
|
||||||
'unknown'.tr(),
|
|
||||||
),
|
),
|
||||||
subtitle: Text(member.nick ?? 'unknown'.tr()),
|
subtitle: Text(member.nick ?? 'unknown'.tr()),
|
||||||
trailing: SizedBox(
|
trailing: SizedBox(
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
|
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
|
||||||
);
|
);
|
||||||
if (_editingChannel != null) {
|
if (_editingChannel != null) {
|
||||||
_belongToRealm = _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
|
_belongToRealm =
|
||||||
|
_realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (mounted) context.showErrorDialog(err);
|
if (mounted) context.showErrorDialog(err);
|
||||||
@@ -97,7 +98,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
'is_community': _isCommunity,
|
'is_community': _isCommunity,
|
||||||
if (_editingChannel != null && _belongToRealm == null)
|
if (_editingChannel != null && _belongToRealm == null)
|
||||||
'new_belongs_realm': 'global'
|
'new_belongs_realm': 'global'
|
||||||
else if (_editingChannel != null && _belongToRealm?.id != _editingChannel?.realm?.id)
|
else if (_editingChannel != null &&
|
||||||
|
_belongToRealm?.id != _editingChannel?.realm?.id)
|
||||||
'new_belongs_realm': _belongToRealm!.alias,
|
'new_belongs_realm': _belongToRealm!.alias,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,8 +141,11 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: widget.editingChannelAlias != null ? Text('screenChatManage').tr() : Text('screenChatNew').tr(),
|
title: widget.editingChannelAlias != null
|
||||||
|
? Text('screenChatManage').tr()
|
||||||
|
: Text('screenChatNew').tr(),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -152,7 +157,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
|
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
|
||||||
dividerColor: Colors.transparent,
|
dividerColor: Colors.transparent,
|
||||||
content: Text(
|
content: Text(
|
||||||
'channelEditingNotice'.tr(args: ['#${_editingChannel!.alias}']),
|
'channelEditingNotice'
|
||||||
|
.tr(args: ['#${_editingChannel!.alias}']),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -192,12 +198,15 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(item.name).textStyle(Theme.of(context).textTheme.bodyMedium!),
|
Text(item.name).textStyle(Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium!),
|
||||||
Text(
|
Text(
|
||||||
item.description,
|
item.description,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
).textStyle(Theme.of(context).textTheme.bodySmall!),
|
).textStyle(
|
||||||
|
Theme.of(context).textTheme.bodySmall!),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -213,7 +222,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 16,
|
radius: 16,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
foregroundColor:
|
||||||
|
Theme.of(context).colorScheme.onSurface,
|
||||||
child: const Icon(Symbols.clear),
|
child: const Icon(Symbols.clear),
|
||||||
),
|
),
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
@@ -222,7 +232,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('fieldChatBelongToRealmUnset').tr().textStyle(
|
Text('fieldChatBelongToRealmUnset')
|
||||||
|
.tr()
|
||||||
|
.textStyle(
|
||||||
Theme.of(context).textTheme.bodyMedium!,
|
Theme.of(context).textTheme.bodyMedium!,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -257,7 +269,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
helperText: 'fieldChatAliasHint'.tr(),
|
helperText: 'fieldChatAliasHint'.tr(),
|
||||||
helperMaxLines: 2,
|
helperMaxLines: 2,
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const Gap(4),
|
const Gap(4),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -266,7 +279,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
labelText: 'fieldChatName'.tr(),
|
labelText: 'fieldChatName'.tr(),
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const Gap(4),
|
const Gap(4),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -277,7 +291,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
|||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
labelText: 'fieldChatDescription'.tr(),
|
labelText: 'fieldChatDescription'.tr(),
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
@@ -19,6 +20,7 @@ import 'package:surface/providers/user_directory.dart';
|
|||||||
import 'package:surface/providers/userinfo.dart';
|
import 'package:surface/providers/userinfo.dart';
|
||||||
import 'package:surface/providers/websocket.dart';
|
import 'package:surface/providers/websocket.dart';
|
||||||
import 'package:surface/types/chat.dart';
|
import 'package:surface/types/chat.dart';
|
||||||
|
import 'package:surface/types/websocket.dart';
|
||||||
import 'package:surface/widgets/chat/call/call_prejoin.dart';
|
import 'package:surface/widgets/chat/call/call_prejoin.dart';
|
||||||
import 'package:surface/widgets/chat/chat_message.dart';
|
import 'package:surface/widgets/chat/chat_message.dart';
|
||||||
import 'package:surface/widgets/chat/chat_message_input.dart';
|
import 'package:surface/widgets/chat/chat_message_input.dart';
|
||||||
@@ -50,17 +52,41 @@ class ChatRoomScreen extends StatefulWidget {
|
|||||||
class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
bool _isCalling = false;
|
bool _isCalling = false;
|
||||||
|
bool _isJoining = false;
|
||||||
|
|
||||||
SnChannel? _channel;
|
SnChannel? _channel;
|
||||||
|
SnChannelMember? _currentMember;
|
||||||
SnChannelMember? _otherMember;
|
SnChannelMember? _otherMember;
|
||||||
SnChatCall? _ongoingCall;
|
SnChatCall? _ongoingCall;
|
||||||
|
|
||||||
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
|
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
|
||||||
late final ChatMessageController _messageController;
|
late final ChatMessageController _messageController;
|
||||||
|
|
||||||
|
late final NotificationProvider _nty = context.read<NotificationProvider>();
|
||||||
|
late final WebSocketProvider _ws = context.read<WebSocketProvider>();
|
||||||
|
|
||||||
|
bool _isEncrypted = false;
|
||||||
|
|
||||||
StreamSubscription? _wsSubscription;
|
StreamSubscription? _wsSubscription;
|
||||||
|
|
||||||
// TODO fetch user identity and ask them to join the channel or not
|
Future<void> _joinChannel() async {
|
||||||
|
try {
|
||||||
|
setState(() => _isJoining = true);
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final ua = context.read<UserProvider>();
|
||||||
|
await sn.client
|
||||||
|
.post('/cgi/im/channels/${_channel!.keyPath}/members', data: {
|
||||||
|
'related': ua.user?.name,
|
||||||
|
});
|
||||||
|
_initializeChat();
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isJoining = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _fetchChannel() async {
|
Future<void> _fetchChannel() async {
|
||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
@@ -69,6 +95,12 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
_channel = await chan.getChannel('${widget.scope}:${widget.alias}');
|
_channel = await chan.getChannel('${widget.scope}:${widget.alias}');
|
||||||
|
|
||||||
if (!mounted || _channel == null) return;
|
if (!mounted || _channel == null) return;
|
||||||
|
final ct = context.read<ChatChannelProvider>();
|
||||||
|
try {
|
||||||
|
_currentMember = await ct.getChannelProfile(_channel!);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
if (!mounted || _currentMember == null) return;
|
||||||
final ud = context.read<UserDirectoryProvider>();
|
final ud = context.read<UserDirectoryProvider>();
|
||||||
final ua = context.read<UserProvider>();
|
final ua = context.read<UserProvider>();
|
||||||
if (_channel!.type == 1) {
|
if (_channel!.type == 1) {
|
||||||
@@ -87,8 +119,18 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final nty = context.read<NotificationProvider>();
|
_nty.skippableNotifyChannel = _channel!.id;
|
||||||
nty.skippableNotifyChannel = _channel!.id;
|
final ws = context.read<WebSocketProvider>();
|
||||||
|
if (_channel != null) {
|
||||||
|
ws.conn?.sink.add(
|
||||||
|
jsonEncode(WebSocketPackage(
|
||||||
|
method: 'events.subscribe',
|
||||||
|
endpoint: 'im',
|
||||||
|
payload: {
|
||||||
|
'channel_id': _channel!.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -187,11 +229,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
|
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<void> _initializeChat() async {
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_messageController = ChatMessageController(context);
|
|
||||||
_fetchChannel().then((_) async {
|
_fetchChannel().then((_) async {
|
||||||
|
if (_currentMember == null) return;
|
||||||
await _messageController.initialize(_channel!);
|
await _messageController.initialize(_channel!);
|
||||||
|
|
||||||
if (widget.extra != null) {
|
if (widget.extra != null) {
|
||||||
@@ -213,9 +253,15 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
_fetchOngoingCall(),
|
_fetchOngoingCall(),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
final ws = context.read<WebSocketProvider>();
|
@override
|
||||||
_wsSubscription = ws.pk.stream.listen((event) {
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_messageController = ChatMessageController(context);
|
||||||
|
_initializeChat();
|
||||||
|
|
||||||
|
_wsSubscription = _ws.pk.stream.listen((event) {
|
||||||
switch (event.method) {
|
switch (event.method) {
|
||||||
case 'calls.new':
|
case 'calls.new':
|
||||||
final payload = SnChatCall.fromJson(event.payload!);
|
final payload = SnChatCall.fromJson(event.payload!);
|
||||||
@@ -237,8 +283,18 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_wsSubscription?.cancel();
|
_wsSubscription?.cancel();
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
final nty = context.read<NotificationProvider>();
|
_nty.skippableNotifyChannel = null;
|
||||||
nty.skippableNotifyChannel = null;
|
if (_channel != null) {
|
||||||
|
_ws.conn?.sink.add(
|
||||||
|
jsonEncode(WebSocketPackage(
|
||||||
|
method: 'events.unsubscribe',
|
||||||
|
endpoint: 'im',
|
||||||
|
payload: {
|
||||||
|
'channel_id': _channel!.id,
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,24 +304,35 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
final ud = context.read<UserDirectoryProvider>();
|
final ud = context.read<UserDirectoryProvider>();
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
_channel?.type == 1
|
_channel?.type == 1
|
||||||
? ud.getAccountFromCache(_otherMember?.accountId)?.nick ??
|
? ud.getFromCache(_otherMember?.accountId)?.nick ?? _channel!.name
|
||||||
_channel!.name
|
|
||||||
: _channel?.name ?? 'loading'.tr(),
|
: _channel?.name ?? 'loading'.tr(),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
if (_currentMember != null)
|
||||||
icon: _ongoingCall == null
|
IconButton(
|
||||||
? const Icon(Symbols.call)
|
onPressed: () {
|
||||||
: const Icon(Symbols.call_end),
|
setState(() => _isEncrypted = !_isEncrypted);
|
||||||
onPressed: _isCalling
|
_inputGlobalKey.currentState?.setEncrypted(_isEncrypted);
|
||||||
? null
|
},
|
||||||
: _ongoingCall == null
|
icon: _isEncrypted
|
||||||
? _makeCall
|
? const Icon(Symbols.lock)
|
||||||
: _endCall,
|
: const Icon(Symbols.no_encryption),
|
||||||
),
|
),
|
||||||
|
if (_currentMember != null)
|
||||||
|
IconButton(
|
||||||
|
icon: _ongoingCall == null
|
||||||
|
? const Icon(Symbols.call)
|
||||||
|
: const Icon(Symbols.call_end),
|
||||||
|
onPressed: _isCalling
|
||||||
|
? null
|
||||||
|
: _ongoingCall == null
|
||||||
|
? _makeCall
|
||||||
|
: _endCall,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.more_vert),
|
icon: const Icon(Symbols.more_vert),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -289,7 +356,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
LoadingIndicator(isActive: _isBusy),
|
LoadingIndicator(
|
||||||
|
isActive: _isBusy || _messageController.isAggressiveLoading,
|
||||||
|
),
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
child: MaterialBanner(
|
child: MaterialBanner(
|
||||||
@@ -312,11 +381,45 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
).height(_ongoingCall != null ? 54 : 0, animate: true).animate(
|
).height(_ongoingCall != null ? 54 : 0, animate: true).animate(
|
||||||
const Duration(milliseconds: 300),
|
const Duration(milliseconds: 300),
|
||||||
Curves.fastLinearToSlowEaseIn),
|
Curves.fastLinearToSlowEaseIn),
|
||||||
if (_messageController.isPending)
|
if (_currentMember == null && !_isBusy)
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 280),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.person_remove, size: 40, fill: 1),
|
||||||
|
const Gap(8),
|
||||||
|
Text('chatUnjoined'.tr(), textAlign: TextAlign.center)
|
||||||
|
.fontSize(16)
|
||||||
|
.bold(),
|
||||||
|
Text('chatUnjoinedDescription'.tr(),
|
||||||
|
textAlign: TextAlign.center)
|
||||||
|
.fontSize(13),
|
||||||
|
if (_channel!.isPublic)
|
||||||
|
Text('chatUnjoinedPublicDescription'.tr(),
|
||||||
|
textAlign: TextAlign.center)
|
||||||
|
.fontSize(13)
|
||||||
|
.padding(top: 8),
|
||||||
|
if (_channel!.isPublic)
|
||||||
|
TextButton(
|
||||||
|
style: ButtonStyle(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
onPressed: _isJoining ? null : _joinChannel,
|
||||||
|
child: Text('chatJoin').tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_messageController.isPending)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: const CircularProgressIndicator().center(),
|
child: const CircularProgressIndicator().center(),
|
||||||
),
|
)
|
||||||
if (!_messageController.isPending)
|
else
|
||||||
Expanded(
|
Expanded(
|
||||||
child: InfiniteList(
|
child: InfiniteList(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
@@ -367,7 +470,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!_messageController.isPending)
|
if (!_messageController.isPending && _currentMember != null)
|
||||||
Material(
|
Material(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
|
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
|
||||||
@@ -6,19 +5,28 @@ import 'package:gap/gap.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:responsive_framework/responsive_framework.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/config.dart';
|
||||||
import 'package:surface/providers/post.dart';
|
import 'package:surface/providers/post.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/providers/sn_realm.dart';
|
import 'package:surface/providers/sn_realm.dart';
|
||||||
|
import 'package:surface/providers/userinfo.dart';
|
||||||
import 'package:surface/types/post.dart';
|
import 'package:surface/types/post.dart';
|
||||||
import 'package:surface/types/realm.dart';
|
import 'package:surface/types/realm.dart';
|
||||||
import 'package:surface/widgets/account/account_image.dart';
|
import 'package:surface/widgets/account/account_image.dart';
|
||||||
import 'package:surface/widgets/app_bar_leading.dart';
|
import 'package:surface/widgets/app_bar_leading.dart';
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/feed/feed_news.dart';
|
||||||
|
import 'package:surface/widgets/feed/feed_unknown.dart';
|
||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
import 'package:surface/widgets/post/fediverse_post_item.dart';
|
||||||
import 'package:surface/widgets/post/post_item.dart';
|
import 'package:surface/widgets/post/post_item.dart';
|
||||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||||
|
|
||||||
|
const kPostChannels = ['Global', 'Friends', 'Following'];
|
||||||
|
const kPostChannelIcons = [Symbols.globe, Symbols.group, Symbols.subscriptions];
|
||||||
|
|
||||||
const Map<String, IconData> kCategoryIcons = {
|
const Map<String, IconData> kCategoryIcons = {
|
||||||
'technology': Symbols.tools_wrench,
|
'technology': Symbols.tools_wrench,
|
||||||
'gaming': Symbols.gamepad,
|
'gaming': Symbols.gamepad,
|
||||||
@@ -39,17 +47,17 @@ class ExploreScreen extends StatefulWidget {
|
|||||||
State<ExploreScreen> createState() => _ExploreScreenState();
|
State<ExploreScreen> createState() => _ExploreScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// You know what? I'm not going to make this a global variable.
|
|
||||||
// Cuz the global key make the selected category not update to child widget when the category is changed.
|
|
||||||
SnPostCategory? _selectedCategory;
|
|
||||||
|
|
||||||
class _ExploreScreenState extends State<ExploreScreen>
|
class _ExploreScreenState extends State<ExploreScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
late final TabController _tabController =
|
late TabController _tabController = TabController(
|
||||||
TabController(length: 4, vsync: this);
|
length: kPostChannels.length,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
final _fabKey = GlobalKey<ExpandableFabState>();
|
final _fabKey = GlobalKey<ExpandableFabState>();
|
||||||
final _listKeys = List.generate(4, (_) => GlobalKey<_PostListWidgetState>());
|
final _listKey = GlobalKey<_PostListWidgetState>();
|
||||||
|
|
||||||
|
bool _showCategories = false;
|
||||||
|
|
||||||
final List<SnPostCategory> _categories = List.empty(growable: true);
|
final List<SnPostCategory> _categories = List.empty(growable: true);
|
||||||
|
|
||||||
@@ -69,14 +77,70 @@ class _ExploreScreenState extends State<ExploreScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearFilter() {
|
final List<SnRealm> _realms = List.empty(growable: true);
|
||||||
_selectedCategory = null;
|
|
||||||
|
Future<void> _fetchRealms() async {
|
||||||
|
try {
|
||||||
|
final ua = context.read<UserProvider>();
|
||||||
|
if (!ua.isAuthorized) return;
|
||||||
|
final rels = context.read<SnRealmProvider>();
|
||||||
|
final out = await rels.listAvailableRealms();
|
||||||
|
setState(() {
|
||||||
|
_realms.addAll(out);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleShowCategories() {
|
||||||
|
_showCategories = !_showCategories;
|
||||||
|
if (_showCategories) {
|
||||||
|
_tabController = TabController(length: _categories.length, vsync: this);
|
||||||
|
_listKey.currentState?.setCategory(_categories[_tabController.index]);
|
||||||
|
_listKey.currentState?.refreshPosts();
|
||||||
|
} else {
|
||||||
|
_tabController = TabController(length: kPostChannels.length, vsync: this);
|
||||||
|
_listKey.currentState?.setCategory(null);
|
||||||
|
_listKey.currentState?.refreshPosts();
|
||||||
|
}
|
||||||
|
_tabListen();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tabListen() {
|
||||||
|
_tabController.addListener(() {
|
||||||
|
if (_tabController.indexIsChanging) {
|
||||||
|
if (_showCategories) {
|
||||||
|
_listKey.currentState?.setCategory(_categories[_tabController.index]);
|
||||||
|
_listKey.currentState?.refreshPosts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (_tabController.index) {
|
||||||
|
case 0:
|
||||||
|
case 3:
|
||||||
|
_listKey.currentState?.setChannel(null);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
_listKey.currentState?.setChannel('friends');
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
_listKey.currentState?.setChannel('following');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_listKey.currentState?.refreshPosts();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
_fetchCategories();
|
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_tabListen();
|
||||||
|
_fetchCategories();
|
||||||
|
_fetchRealms();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -86,12 +150,14 @@ class _ExploreScreenState extends State<ExploreScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshPosts() async {
|
Future<void> refreshPosts() async {
|
||||||
await _listKeys[_tabController.index].currentState?.refreshPosts();
|
await _listKey.currentState?.refreshPosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cfg = context.watch<ConfigProvider>();
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
floatingActionButtonLocation: ExpandableFab.location,
|
floatingActionButtonLocation: ExpandableFab.location,
|
||||||
floatingActionButton: ExpandableFab(
|
floatingActionButton: ExpandableFab(
|
||||||
key: _fabKey,
|
key: _fabKey,
|
||||||
@@ -111,7 +177,6 @@ class _ExploreScreenState extends State<ExploreScreen>
|
|||||||
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||||
shape: const CircleBorder(),
|
|
||||||
),
|
),
|
||||||
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
|
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
|
||||||
child: const Icon(Symbols.close, size: 28),
|
child: const Icon(Symbols.close, size: 28),
|
||||||
@@ -120,90 +185,39 @@ class _ExploreScreenState extends State<ExploreScreen>
|
|||||||
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||||
shape: const CircleBorder(),
|
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text('writePostTypeStory').tr(),
|
Text('writePost').tr(),
|
||||||
const Gap(20),
|
const Gap(20),
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
heroTag: null,
|
heroTag: null,
|
||||||
tooltip: 'writePostTypeStory'.tr(),
|
tooltip: 'writePost'.tr(),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
|
GoRouter.of(context).pushNamed('postEditor').then((value) {
|
||||||
'mode': 'stories',
|
|
||||||
}).then((value) {
|
|
||||||
if (value == true) {
|
if (value == true) {
|
||||||
refreshPosts();
|
refreshPosts();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_fabKey.currentState!.toggle();
|
_fabKey.currentState!.toggle();
|
||||||
},
|
},
|
||||||
child: const Icon(Symbols.post_rounded),
|
child: const Icon(Symbols.edit),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text('writePostTypeArticle').tr(),
|
Text('postDraftBox').tr(),
|
||||||
const Gap(20),
|
const Gap(20),
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
heroTag: null,
|
heroTag: null,
|
||||||
tooltip: 'writePostTypeArticle'.tr(),
|
tooltip: 'postDraftBox'.tr(),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
|
GoRouter.of(context).pushNamed('postDraftBox');
|
||||||
'mode': 'articles',
|
|
||||||
}).then((value) {
|
|
||||||
if (value == true) {
|
|
||||||
refreshPosts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_fabKey.currentState!.toggle();
|
_fabKey.currentState!.toggle();
|
||||||
},
|
},
|
||||||
child: const Icon(Symbols.news),
|
child: const Icon(Symbols.box_edit),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text('writePostTypeQuestion').tr(),
|
|
||||||
const Gap(20),
|
|
||||||
FloatingActionButton(
|
|
||||||
heroTag: null,
|
|
||||||
tooltip: 'writePostTypeQuestion'.tr(),
|
|
||||||
onPressed: () {
|
|
||||||
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
|
|
||||||
'mode': 'questions',
|
|
||||||
}).then((value) {
|
|
||||||
if (value == true) {
|
|
||||||
refreshPosts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_fabKey.currentState!.toggle();
|
|
||||||
},
|
|
||||||
child: const Icon(Symbols.question_answer),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text('writePostTypeVideo').tr(),
|
|
||||||
const Gap(20),
|
|
||||||
FloatingActionButton(
|
|
||||||
heroTag: null,
|
|
||||||
tooltip: 'writePostTypeVideo'.tr(),
|
|
||||||
onPressed: () {
|
|
||||||
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
|
|
||||||
'mode': 'videos',
|
|
||||||
}).then((value) {
|
|
||||||
if (value == true) {
|
|
||||||
refreshPosts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_fabKey.currentState!.toggle();
|
|
||||||
},
|
|
||||||
child: const Icon(Symbols.video_call),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -215,27 +229,92 @@ class _ExploreScreenState extends State<ExploreScreen>
|
|||||||
SliverOverlapAbsorber(
|
SliverOverlapAbsorber(
|
||||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||||
sliver: SliverAppBar(
|
sliver: SliverAppBar(
|
||||||
leading: AutoAppBarLeading(),
|
leading:
|
||||||
title: Text('screenExplore').tr(),
|
ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
|
||||||
|
? AutoAppBarLeading()
|
||||||
|
: null,
|
||||||
|
titleSpacing: 0,
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
|
||||||
|
const Gap(8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.shuffle),
|
||||||
|
onPressed: () {
|
||||||
|
GoRouter.of(context).pushNamed('postShuffle');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Gap(48),
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: _listKey.currentState?.realm != null
|
||||||
|
? AccountImage(
|
||||||
|
content: _listKey.currentState!.realm!.avatar,
|
||||||
|
radius: 14,
|
||||||
|
)
|
||||||
|
: Image.asset(
|
||||||
|
'assets/icon/icon-dark.png',
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.appBarTheme
|
||||||
|
.foregroundColor,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _PostListRealmPopup(
|
||||||
|
realms: _realms,
|
||||||
|
onUpdate: (realm) {
|
||||||
|
_listKey.currentState?.setRealm(realm);
|
||||||
|
_listKey.currentState?.refreshPosts();
|
||||||
|
Future.delayed(
|
||||||
|
const Duration(milliseconds: 100), () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onMixedFeedChanged: (flag) {
|
||||||
|
_listKey.currentState?.setRealm(null);
|
||||||
|
_listKey.currentState?.setCategory(null);
|
||||||
|
if (_showCategories && flag) {
|
||||||
|
_toggleShowCategories();
|
||||||
|
}
|
||||||
|
_listKey.currentState?.refreshPosts();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
floating: true,
|
floating: true,
|
||||||
snap: true,
|
snap: true,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.category),
|
icon: const Icon(Symbols.category),
|
||||||
onPressed: () {
|
style: _showCategories
|
||||||
showModalBottomSheet(
|
? ButtonStyle(
|
||||||
context: context,
|
foregroundColor: WidgetStateProperty.all(
|
||||||
builder: (context) => _PostCategoryPickerPopup(
|
Theme.of(context).colorScheme.primary,
|
||||||
categories: _categories,
|
),
|
||||||
selected: _selectedCategory,
|
backgroundColor: MaterialStateProperty.all(
|
||||||
),
|
Theme.of(context).colorScheme.secondaryContainer,
|
||||||
).then((value) {
|
),
|
||||||
if (value != null && context.mounted) {
|
)
|
||||||
_selectedCategory = value == false ? null : value;
|
: null,
|
||||||
refreshPosts();
|
onPressed: cfg.mixedFeed
|
||||||
}
|
? null
|
||||||
});
|
: () {
|
||||||
},
|
_toggleShowCategories();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.search),
|
icon: const Icon(Symbols.search),
|
||||||
@@ -245,123 +324,84 @@ class _ExploreScreenState extends State<ExploreScreen>
|
|||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
],
|
],
|
||||||
bottom: TabBar(
|
bottom: cfg.mixedFeed
|
||||||
controller: _tabController,
|
? null
|
||||||
tabs: [
|
: TabBar(
|
||||||
Tab(
|
isScrollable: _showCategories,
|
||||||
child: Row(
|
controller: _tabController,
|
||||||
mainAxisSize: MainAxisSize.min,
|
tabs: _showCategories
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
? [
|
||||||
children: [
|
for (final category in _categories)
|
||||||
Icon(Symbols.globe,
|
Tab(
|
||||||
size: 20,
|
child: Row(
|
||||||
color: Theme.of(context)
|
mainAxisSize: MainAxisSize.min,
|
||||||
.appBarTheme
|
crossAxisAlignment:
|
||||||
.foregroundColor),
|
CrossAxisAlignment.center,
|
||||||
const Gap(8),
|
children: [
|
||||||
Flexible(
|
Icon(
|
||||||
child: Text(
|
kCategoryIcons[category.alias] ??
|
||||||
'postChannelGlobal',
|
Symbols.question_mark,
|
||||||
maxLines: 1,
|
color: Theme.of(context)
|
||||||
).tr().textColor(
|
.appBarTheme
|
||||||
Theme.of(context).appBarTheme.foregroundColor),
|
.foregroundColor!,
|
||||||
),
|
),
|
||||||
],
|
const Gap(8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'postCategory${category.alias.capitalize()}'
|
||||||
|
.trExists()
|
||||||
|
? 'postCategory${category.alias.capitalize()}'
|
||||||
|
.tr()
|
||||||
|
: category.name,
|
||||||
|
maxLines: 1,
|
||||||
|
).textColor(
|
||||||
|
Theme.of(context)
|
||||||
|
.appBarTheme
|
||||||
|
.foregroundColor!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
for (final channel in kPostChannels)
|
||||||
|
Tab(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
kPostChannelIcons[
|
||||||
|
kPostChannels.indexOf(channel)],
|
||||||
|
size: 20,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.appBarTheme
|
||||||
|
.foregroundColor,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'postChannel$channel',
|
||||||
|
maxLines: 1,
|
||||||
|
).tr().textColor(
|
||||||
|
Theme.of(context)
|
||||||
|
.appBarTheme
|
||||||
|
.foregroundColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Tab(
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Symbols.group,
|
|
||||||
size: 20,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.appBarTheme
|
|
||||||
.foregroundColor),
|
|
||||||
const Gap(8),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
'postChannelFriends',
|
|
||||||
maxLines: 1,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
).tr().textColor(
|
|
||||||
Theme.of(context).appBarTheme.foregroundColor),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Tab(
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Symbols.subscriptions,
|
|
||||||
size: 20,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.appBarTheme
|
|
||||||
.foregroundColor),
|
|
||||||
const Gap(8),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
'postChannelFollowing',
|
|
||||||
maxLines: 1,
|
|
||||||
).tr().textColor(
|
|
||||||
Theme.of(context).appBarTheme.foregroundColor),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Tab(
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Symbols.workspaces,
|
|
||||||
size: 20,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.appBarTheme
|
|
||||||
.foregroundColor),
|
|
||||||
const Gap(8),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
'postChannelRealm',
|
|
||||||
maxLines: 1,
|
|
||||||
).tr().textColor(
|
|
||||||
Theme.of(context).appBarTheme.foregroundColor),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
body: TabBarView(
|
body: _PostListWidget(
|
||||||
controller: _tabController,
|
key: _listKey,
|
||||||
children: [
|
|
||||||
_PostListWidget(
|
|
||||||
key: _listKeys[0],
|
|
||||||
onClearFilter: _clearFilter,
|
|
||||||
),
|
|
||||||
_PostListWidget(
|
|
||||||
key: _listKeys[1],
|
|
||||||
channel: 'friends',
|
|
||||||
onClearFilter: _clearFilter,
|
|
||||||
),
|
|
||||||
_PostListWidget(
|
|
||||||
key: _listKeys[2],
|
|
||||||
channel: 'following',
|
|
||||||
onClearFilter: _clearFilter,
|
|
||||||
),
|
|
||||||
_PostListWidget(
|
|
||||||
key: _listKeys[3],
|
|
||||||
withRealm: true,
|
|
||||||
onClearFilter: _clearFilter,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -369,15 +409,7 @@ class _ExploreScreenState extends State<ExploreScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PostListWidget extends StatefulWidget {
|
class _PostListWidget extends StatefulWidget {
|
||||||
final String? channel;
|
const _PostListWidget({super.key});
|
||||||
final bool withRealm;
|
|
||||||
final Function onClearFilter;
|
|
||||||
|
|
||||||
const _PostListWidget(
|
|
||||||
{super.key,
|
|
||||||
this.channel,
|
|
||||||
this.withRealm = false,
|
|
||||||
required this.onClearFilter});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_PostListWidget> createState() => _PostListWidgetState();
|
State<_PostListWidget> createState() => _PostListWidgetState();
|
||||||
@@ -386,62 +418,98 @@ class _PostListWidget extends StatefulWidget {
|
|||||||
class _PostListWidgetState extends State<_PostListWidget> {
|
class _PostListWidgetState extends State<_PostListWidget> {
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
|
|
||||||
final List<SnPost> _posts = List.empty(growable: true);
|
SnRealm? get realm => _selectedRealm;
|
||||||
final List<SnRealm> _realms = List.empty(growable: true);
|
|
||||||
|
final List<SnFeedEntry> _feed = List.empty(growable: true);
|
||||||
SnRealm? _selectedRealm;
|
SnRealm? _selectedRealm;
|
||||||
int? _postCount;
|
String? _selectedChannel;
|
||||||
|
SnPostCategory? _selectedCategory;
|
||||||
Future<void> _fetchRealms() async {
|
bool _hasLoadedAll = false;
|
||||||
try {
|
|
||||||
final rels = context.read<SnRealmProvider>();
|
|
||||||
final out = await rels.listAvailableRealms();
|
|
||||||
setState(() {
|
|
||||||
_realms.addAll(out);
|
|
||||||
_selectedRealm = out.firstOrNull;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (!mounted) return;
|
|
||||||
context.showErrorDialog(err);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Called when using regular feed
|
||||||
Future<void> _fetchPosts() async {
|
Future<void> _fetchPosts() async {
|
||||||
if (_postCount != null && _posts.length >= _postCount!) return;
|
if (_hasLoadedAll) return;
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
final pt = context.read<SnPostContentProvider>();
|
final pt = context.read<SnPostContentProvider>();
|
||||||
final result = await pt.listPosts(
|
final result = await pt.listPosts(
|
||||||
take: 10,
|
take: 10,
|
||||||
offset: _posts.length,
|
offset: _feed.length,
|
||||||
categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
|
categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
|
||||||
channel: widget.channel,
|
channel: _selectedChannel,
|
||||||
realm: _selectedRealm?.alias,
|
realm: _selectedRealm?.alias,
|
||||||
);
|
);
|
||||||
final out = result.$1;
|
final out = result.$1;
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
_postCount = result.$2;
|
final postCount = result.$2;
|
||||||
_posts.addAll(out);
|
_feed.addAll(
|
||||||
|
out.map((ele) => SnFeedEntry(
|
||||||
|
type: 'interactive.post',
|
||||||
|
data: ele.toJson(),
|
||||||
|
createdAt: ele.createdAt)),
|
||||||
|
);
|
||||||
|
_hasLoadedAll = _feed.length >= postCount;
|
||||||
|
|
||||||
if (mounted) setState(() => _isBusy = false);
|
if (mounted) setState(() => _isBusy = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called when mixed feed is enabled
|
||||||
|
Future<void> _fetchFeed() async {
|
||||||
|
if (_hasLoadedAll) return;
|
||||||
|
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
|
final pt = context.read<SnPostContentProvider>();
|
||||||
|
final result = await pt.getFeed(
|
||||||
|
cursor: _feed
|
||||||
|
.where((ele) => !['reader.news'].contains(ele.type))
|
||||||
|
.lastOrNull
|
||||||
|
?.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
_feed.addAll(result);
|
||||||
|
_hasLoadedAll = result.isEmpty;
|
||||||
|
|
||||||
|
if (mounted) setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setChannel(String? channel) {
|
||||||
|
_selectedChannel = channel;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void setRealm(SnRealm? realm) {
|
||||||
|
_selectedRealm = realm;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCategory(SnPostCategory? category) {
|
||||||
|
_selectedCategory = category;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> refreshPosts() {
|
Future<void> refreshPosts() {
|
||||||
_postCount = null;
|
_hasLoadedAll = false;
|
||||||
_posts.clear();
|
_feed.clear();
|
||||||
return _fetchPosts();
|
final cfg = context.read<ConfigProvider>();
|
||||||
|
if (cfg.mixedFeed) {
|
||||||
|
return _fetchFeed();
|
||||||
|
} else {
|
||||||
|
return _fetchPosts();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.withRealm) {
|
final cfg = context.read<ConfigProvider>();
|
||||||
_fetchRealms().then((_) {
|
if (cfg.mixedFeed) {
|
||||||
_fetchPosts();
|
_fetchFeed();
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
_fetchPosts();
|
_fetchPosts();
|
||||||
}
|
}
|
||||||
@@ -449,178 +517,131 @@ class _PostListWidgetState extends State<_PostListWidget> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
final cfg = context.watch<ConfigProvider>();
|
||||||
children: [
|
return MediaQuery.removePadding(
|
||||||
if (_selectedCategory != null)
|
context: context,
|
||||||
MaterialBanner(
|
removeTop: true,
|
||||||
content: Text(
|
child: RefreshIndicator(
|
||||||
'postFilterWithCategory'.tr(args: [
|
displacement: 40 + MediaQuery.of(context).padding.top,
|
||||||
'postCategory${_selectedCategory!.alias.capitalize()}'.trExists()
|
onRefresh: () => refreshPosts(),
|
||||||
? 'postCategory${_selectedCategory!.alias.capitalize()}'
|
child: InfiniteList(
|
||||||
.tr()
|
padding: EdgeInsets.only(top: 8),
|
||||||
: _selectedCategory!.name,
|
itemCount: _feed.length,
|
||||||
]),
|
isLoading: _isBusy,
|
||||||
),
|
centerLoading: true,
|
||||||
leading: Icon(kCategoryIcons[_selectedCategory!.alias] ??
|
hasReachedMax: _hasLoadedAll,
|
||||||
Symbols.question_mark),
|
onFetchData: cfg.mixedFeed ? _fetchFeed : _fetchPosts,
|
||||||
actions: [
|
itemBuilder: (context, idx) {
|
||||||
IconButton(
|
final ele = _feed[idx];
|
||||||
icon: const Icon(Symbols.clear),
|
switch (ele.type) {
|
||||||
onPressed: () {
|
case 'interactive.post':
|
||||||
widget.onClearFilter.call();
|
return OpenablePostItem(
|
||||||
refreshPosts();
|
useReplace: true,
|
||||||
},
|
data: SnPost.fromJson(ele.data),
|
||||||
),
|
maxWidth: 640,
|
||||||
],
|
onChanged: (data) {
|
||||||
padding: const EdgeInsets.only(left: 20, right: 4),
|
setState(() {
|
||||||
),
|
_feed[idx] = _feed[idx].copyWith(data: data.toJson());
|
||||||
if (widget.withRealm)
|
});
|
||||||
DropdownButtonHideUnderline(
|
},
|
||||||
child: DropdownButton2<SnRealm>(
|
onDeleted: () {
|
||||||
isExpanded: true,
|
refreshPosts();
|
||||||
items: _realms
|
},
|
||||||
.map(
|
);
|
||||||
(ele) => DropdownMenuItem<SnRealm>(
|
case 'fediverse.post':
|
||||||
value: ele,
|
return FediversePostWidget(
|
||||||
child: Row(
|
data: SnFediversePost.fromJson(ele.data),
|
||||||
children: [
|
maxWidth: 640,
|
||||||
AccountImage(
|
);
|
||||||
content: ele.avatar,
|
case 'reader.news':
|
||||||
fallbackWidget: const Icon(Symbols.group, size: 16),
|
return Center(
|
||||||
radius: 14,
|
child: Container(
|
||||||
),
|
constraints: BoxConstraints(maxWidth: 640),
|
||||||
const Gap(8),
|
child: NewsFeedEntry(data: ele),
|
||||||
Text(
|
),
|
||||||
ele.name,
|
);
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
default:
|
||||||
),
|
return Container(
|
||||||
],
|
constraints: BoxConstraints(maxWidth: 640),
|
||||||
),
|
child: FeedUnknownEntry(data: ele),
|
||||||
),
|
);
|
||||||
)
|
}
|
||||||
.toList(),
|
},
|
||||||
value: _selectedRealm,
|
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
|
||||||
onChanged: (SnRealm? value) {
|
|
||||||
setState(() => _selectedRealm = value);
|
|
||||||
refreshPosts();
|
|
||||||
},
|
|
||||||
buttonStyleData: const ButtonStyleData(
|
|
||||||
padding: EdgeInsets.only(left: 4, right: 12),
|
|
||||||
),
|
|
||||||
menuItemStyleData: const MenuItemStyleData(
|
|
||||||
height: 48,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.withRealm) const Divider(height: 1),
|
|
||||||
Expanded(
|
|
||||||
child: MediaQuery.removePadding(
|
|
||||||
context: context,
|
|
||||||
removeTop: true,
|
|
||||||
child: RefreshIndicator(
|
|
||||||
displacement: 40 + MediaQuery.of(context).padding.top,
|
|
||||||
onRefresh: () => refreshPosts(),
|
|
||||||
child: InfiniteList(
|
|
||||||
itemCount: _posts.length,
|
|
||||||
isLoading: _isBusy,
|
|
||||||
centerLoading: true,
|
|
||||||
hasReachedMax:
|
|
||||||
_postCount != null && _posts.length >= _postCount!,
|
|
||||||
onFetchData: _fetchPosts,
|
|
||||||
itemBuilder: (context, idx) {
|
|
||||||
return OpenablePostItem(
|
|
||||||
data: _posts[idx],
|
|
||||||
maxWidth: 640,
|
|
||||||
onChanged: (data) {
|
|
||||||
setState(() => _posts[idx] = data);
|
|
||||||
},
|
|
||||||
onDeleted: () {
|
|
||||||
refreshPosts();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
separatorBuilder: (_, __) => const Gap(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).padding(top: 8),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PostCategoryPickerPopup extends StatelessWidget {
|
class _PostListRealmPopup extends StatelessWidget {
|
||||||
final List<SnPostCategory> categories;
|
final List<SnRealm>? realms;
|
||||||
final SnPostCategory? selected;
|
final Function(SnRealm?) onUpdate;
|
||||||
|
final Function(bool) onMixedFeedChanged;
|
||||||
|
|
||||||
const _PostCategoryPickerPopup({required this.categories, this.selected});
|
const _PostListRealmPopup({
|
||||||
|
required this.realms,
|
||||||
|
required this.onUpdate,
|
||||||
|
required this.onMixedFeedChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cfg = context.watch<ConfigProvider>();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Symbols.category, size: 24),
|
const Icon(Symbols.tune, size: 24),
|
||||||
const Gap(16),
|
const Gap(16),
|
||||||
Text('postCategory')
|
Text('filterFeed', style: Theme.of(context).textTheme.titleLarge)
|
||||||
.tr()
|
.tr(),
|
||||||
.textStyle(Theme.of(context).textTheme.titleLarge!),
|
|
||||||
],
|
],
|
||||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||||
ListTile(
|
SwitchListTile(
|
||||||
leading: const Icon(Symbols.clear),
|
secondary: const Icon(Symbols.merge_type),
|
||||||
title: Text('postFilterReset').tr(),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
subtitle: Text('postFilterResetDescription').tr(),
|
title: Text('mixedFeed').tr(),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
subtitle: Text('mixedFeedDescription').tr(),
|
||||||
onTap: () {
|
value: cfg.mixedFeed,
|
||||||
Navigator.pop(context, false);
|
onChanged: (value) {
|
||||||
|
cfg.mixedFeed = value;
|
||||||
|
onMixedFeedChanged.call(value);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
if (!cfg.mixedFeed)
|
||||||
Expanded(
|
ListTile(
|
||||||
child: GridView.count(
|
leading: const Icon(Symbols.close),
|
||||||
crossAxisCount: 4,
|
title: Text('postInGlobal').tr(),
|
||||||
shrinkWrap: true,
|
subtitle: Text('postViewInGlobalDescription').tr(),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
childAspectRatio: 1,
|
onTap: () {
|
||||||
children: categories
|
onUpdate.call(null);
|
||||||
.map(
|
Navigator.pop(context);
|
||||||
(ele) => InkWell(
|
},
|
||||||
onTap: () {
|
),
|
||||||
_selectedCategory = ele;
|
if (!cfg.mixedFeed) const Divider(height: 1),
|
||||||
Navigator.pop(context, ele);
|
if (!cfg.mixedFeed)
|
||||||
},
|
Expanded(
|
||||||
child: Column(
|
child: ListView.builder(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
itemCount: realms?.length ?? 0,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
itemBuilder: (context, idx) {
|
||||||
mainAxisSize: MainAxisSize.min,
|
final realm = realms![idx];
|
||||||
children: [
|
return ListTile(
|
||||||
Icon(
|
title: Text(realm.name),
|
||||||
kCategoryIcons[ele.alias] ?? Symbols.question_mark,
|
subtitle: Text('@${realm.alias}'),
|
||||||
color: selected == ele
|
leading: AccountImage(content: realm.avatar, radius: 18),
|
||||||
? Theme.of(context).colorScheme.primary
|
onTap: () {
|
||||||
: null,
|
onUpdate.call(realm);
|
||||||
),
|
Navigator.pop(context);
|
||||||
const Gap(4),
|
},
|
||||||
Text(
|
);
|
||||||
'postCategory${ele.alias.capitalize()}'.trExists()
|
},
|
||||||
? 'postCategory${ele.alias.capitalize()}'.tr()
|
),
|
||||||
: ele.name,
|
|
||||||
)
|
|
||||||
.textStyle(Theme.of(context).textTheme.titleMedium!)
|
|
||||||
.textColor(selected == ele
|
|
||||||
? Theme.of(context).colorScheme.primary
|
|
||||||
: null),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import 'package:surface/providers/userinfo.dart';
|
|||||||
import 'package:surface/types/account.dart';
|
import 'package:surface/types/account.dart';
|
||||||
import 'package:surface/widgets/account/account_image.dart';
|
import 'package:surface/widgets/account/account_image.dart';
|
||||||
import 'package:surface/widgets/account/account_select.dart';
|
import 'package:surface/widgets/account/account_select.dart';
|
||||||
import 'package:surface/widgets/app_bar_leading.dart';
|
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
import 'package:surface/widgets/loading_indicator.dart';
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
@@ -47,8 +46,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/id/users/me/relations?status=1');
|
final resp = await sn.client.get('/cgi/id/users/me/relations?status=1');
|
||||||
_relations = List<SnRelationship>.from(
|
_relations = List<SnRelationship>.from(
|
||||||
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
|
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -67,8 +65,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3');
|
final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3');
|
||||||
_requests = List<SnRelationship>.from(
|
_requests = List<SnRelationship>.from(
|
||||||
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
|
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -87,8 +84,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/id/users/me/relations?status=2');
|
final resp = await sn.client.get('/cgi/id/users/me/relations?status=2');
|
||||||
_blocks = List<SnRelationship>.from(
|
_blocks = List<SnRelationship>.from(
|
||||||
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
|
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -105,10 +101,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
try {
|
try {
|
||||||
final rel = context.read<SnRelationshipProvider>();
|
final rel = context.read<SnRelationshipProvider>();
|
||||||
await rel.updateRelationship(
|
await rel.updateRelationship(
|
||||||
relation.relatedId,
|
relation.relatedId, dstStatus, relation.permNodes);
|
||||||
dstStatus,
|
|
||||||
relation.permNodes,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_fetchRelations();
|
_fetchRelations();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -122,9 +115,8 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
Future<void> _deleteRelation(SnRelationship relation) async {
|
Future<void> _deleteRelation(SnRelationship relation) async {
|
||||||
final confirm = await context.showConfirmDialog(
|
final confirm = await context.showConfirmDialog(
|
||||||
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
|
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
|
||||||
'friendDeleteDescription'.tr(args: [
|
'friendDeleteDescription'
|
||||||
relation.related?.nick ?? 'unknown'.tr(),
|
.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
if (!confirm) return;
|
if (!confirm) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -146,9 +138,11 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
|
|
||||||
void _showRequests() {
|
void _showRequests() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => _FriendshipListWidget(relations: _requests),
|
builder: (context) => _FriendshipListWidget(relations: _requests))
|
||||||
).then((value) {
|
.then((
|
||||||
|
value,
|
||||||
|
) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
_fetchRequests();
|
_fetchRequests();
|
||||||
_fetchRelations();
|
_fetchRelations();
|
||||||
@@ -158,9 +152,10 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
|
|
||||||
void _showBlocks() {
|
void _showBlocks() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => _FriendshipListWidget(relations: _blocks),
|
builder: (context) => _FriendshipListWidget(relations: _blocks)).then((
|
||||||
).then((value) {
|
value,
|
||||||
|
) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
_fetchBlocks();
|
_fetchBlocks();
|
||||||
_fetchRelations();
|
_fetchRelations();
|
||||||
@@ -173,9 +168,8 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
await sn.client.post('/cgi/id/users/me/relations', data: {
|
await sn.client
|
||||||
'related': user.name,
|
.post('/cgi/id/users/me/relations', data: {'related': user.name});
|
||||||
});
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showSnackbar('friendRequestSent'.tr());
|
context.showSnackbar('friendRequestSent'.tr());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -201,18 +195,16 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
if (!ua.isAuthorized) {
|
if (!ua.isAuthorized) {
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: AutoAppBarLeading(),
|
leading: PageBackButton(),
|
||||||
title: Text('screenFriend').tr(),
|
title: Text('screenFriend').tr(),
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(child: UnauthorizedHint()),
|
||||||
child: UnauthorizedHint(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: AutoAppBarLeading(),
|
leading: PageBackButton(),
|
||||||
title: Text('screenFriend').tr(),
|
title: Text('screenFriend').tr(),
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
@@ -220,9 +212,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final user = await showModalBottomSheet<SnAccount?>(
|
final user = await showModalBottomSheet<SnAccount?>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AccountSelect(
|
builder: (context) => AccountSelect(title: 'friendNew'.tr()),
|
||||||
title: 'friendNew'.tr(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (user == null) return;
|
if (user == null) return;
|
||||||
@@ -235,9 +225,8 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
if (_requests.isNotEmpty)
|
if (_requests.isNotEmpty)
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('friendRequests').tr(),
|
title: Text('friendRequests').tr(),
|
||||||
subtitle: Text(
|
subtitle:
|
||||||
'friendRequestsDescription',
|
Text('friendRequestsDescription').plural(_requests.length),
|
||||||
).plural(_requests.length),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
leading: const Icon(Symbols.group_add),
|
leading: const Icon(Symbols.group_add),
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
@@ -246,31 +235,30 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
if (_blocks.isNotEmpty)
|
if (_blocks.isNotEmpty)
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('friendBlocklist').tr(),
|
title: Text('friendBlocklist').tr(),
|
||||||
subtitle: Text(
|
subtitle:
|
||||||
'friendBlocklistDescription',
|
Text('friendBlocklistDescription').plural(_blocks.length),
|
||||||
).plural(_blocks.length),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
leading: const Icon(Symbols.block),
|
leading: const Icon(Symbols.block),
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
onTap: _showBlocks,
|
onTap: _showBlocks,
|
||||||
),
|
),
|
||||||
if (_requests.isNotEmpty || _blocks.isNotEmpty) const Divider(height: 1),
|
if (_requests.isNotEmpty || _blocks.isNotEmpty)
|
||||||
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MediaQuery.removePadding(
|
child: MediaQuery.removePadding(
|
||||||
context: context,
|
context: context,
|
||||||
removeTop: true,
|
removeTop: true,
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () => Future.wait([
|
onRefresh: () =>
|
||||||
_fetchRelations(),
|
Future.wait([_fetchRelations(), _fetchRequests()]),
|
||||||
_fetchRequests(),
|
|
||||||
]),
|
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: _relations.length,
|
itemCount: _relations.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final relation = _relations[index];
|
final relation = _relations[index];
|
||||||
final other = relation.related;
|
final other = relation.related;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.only(right: 24, left: 16),
|
contentPadding:
|
||||||
|
const EdgeInsets.only(right: 24, left: 16),
|
||||||
leading: AccountImage(content: other?.avatar),
|
leading: AccountImage(content: other?.avatar),
|
||||||
title: Text(other?.nick ?? 'unknown'),
|
title: Text(other?.nick ?? 'unknown'),
|
||||||
subtitle: Text(other?.nick ?? 'unknown'),
|
subtitle: Text(other?.nick ?? 'unknown'),
|
||||||
@@ -286,12 +274,16 @@ class _FriendScreenState extends State<FriendScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: _isUpdating ? null : () => _changeRelation(relation, 2),
|
onTap: _isUpdating
|
||||||
|
? null
|
||||||
|
: () => _changeRelation(relation, 2),
|
||||||
child: Text('friendBlock').tr(),
|
child: Text('friendBlock').tr(),
|
||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: _isUpdating ? null : () => _deleteRelation(relation),
|
onTap: _isUpdating
|
||||||
|
? null
|
||||||
|
: () => _deleteRelation(relation),
|
||||||
child: Text('friendDeleteAction').tr(),
|
child: Text('friendDeleteAction').tr(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -361,10 +353,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
|
|||||||
try {
|
try {
|
||||||
final rel = context.read<SnRelationshipProvider>();
|
final rel = context.read<SnRelationshipProvider>();
|
||||||
await rel.updateRelationship(
|
await rel.updateRelationship(
|
||||||
relation.relatedId,
|
relation.relatedId, dstStatus, relation.permNodes);
|
||||||
dstStatus,
|
|
||||||
relation.permNodes,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.pop(context, true);
|
Navigator.pop(context, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -378,9 +367,8 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
|
|||||||
Future<void> _deleteRelation(SnRelationship relation) async {
|
Future<void> _deleteRelation(SnRelationship relation) async {
|
||||||
final confirm = await context.showConfirmDialog(
|
final confirm = await context.showConfirmDialog(
|
||||||
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
|
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
|
||||||
'friendDeleteDescription'.tr(args: [
|
'friendDeleteDescription'
|
||||||
relation.related?.nick ?? 'unknown'.tr(),
|
.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
if (!confirm) return;
|
if (!confirm) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -420,7 +408,9 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Text(kFriendStatus[relation.status] ?? 'unknown').tr().opacity(0.75),
|
Text(kFriendStatus[relation.status] ?? 'unknown')
|
||||||
|
.tr()
|
||||||
|
.opacity(0.75),
|
||||||
if (relation.status == 0)
|
if (relation.status == 0)
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
@@ -441,7 +431,8 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: _isBusy ? null : () => _changeRelation(relation, 1),
|
onTap:
|
||||||
|
_isBusy ? null : () => _changeRelation(relation, 1),
|
||||||
child: Text('friendUnblock').tr(),
|
child: Text('friendUnblock').tr(),
|
||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
|||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:html/parser.dart';
|
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:relative_time/relative_time.dart';
|
import 'package:relative_time/relative_time.dart';
|
||||||
@@ -19,14 +18,16 @@ import 'package:surface/providers/sn_network.dart';
|
|||||||
import 'package:surface/providers/special_day.dart';
|
import 'package:surface/providers/special_day.dart';
|
||||||
import 'package:surface/providers/userinfo.dart';
|
import 'package:surface/providers/userinfo.dart';
|
||||||
import 'package:surface/providers/widget.dart';
|
import 'package:surface/providers/widget.dart';
|
||||||
|
import 'package:surface/screens/captcha/captcha.dart';
|
||||||
import 'package:surface/types/check_in.dart';
|
import 'package:surface/types/check_in.dart';
|
||||||
import 'package:surface/types/news.dart';
|
|
||||||
import 'package:surface/types/post.dart';
|
import 'package:surface/types/post.dart';
|
||||||
import 'package:surface/widgets/app_bar_leading.dart';
|
import 'package:surface/widgets/app_bar_leading.dart';
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
import 'package:surface/widgets/post/post_item.dart';
|
import 'package:surface/widgets/post/post_item.dart';
|
||||||
import 'package:surface/widgets/updater.dart';
|
import 'package:surface/widgets/updater.dart';
|
||||||
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class HomeScreenDashEntry {
|
class HomeScreenDashEntry {
|
||||||
final String name;
|
final String name;
|
||||||
@@ -66,7 +67,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
HomeScreenDashEntry(
|
HomeScreenDashEntry(
|
||||||
name: 'dashEntryTodayNews',
|
name: 'dashEntryTodayNews',
|
||||||
child: _HomeDashTodayNews(),
|
child: _HomeDashServiceStatus(),
|
||||||
cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
|
cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -99,6 +100,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
right: 8,
|
right: 8,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_HomeDashUnconfirmedWidget().padding(horizontal: 8),
|
||||||
_HomeDashSpecialDayWidget().padding(horizontal: 8),
|
_HomeDashSpecialDayWidget().padding(horizontal: 8),
|
||||||
StaggeredGrid.extent(
|
StaggeredGrid.extent(
|
||||||
maxCrossAxisExtent: 280,
|
maxCrossAxisExtent: 280,
|
||||||
@@ -123,6 +125,64 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _HomeDashUnconfirmedWidget extends StatelessWidget {
|
||||||
|
const _HomeDashUnconfirmedWidget();
|
||||||
|
|
||||||
|
Future<void> _resendConfirmationEmail(BuildContext context) async {
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
await sn.client.patch('/cgi/id/users/me/confirm');
|
||||||
|
if (!context.mounted) return;
|
||||||
|
context.showSnackbar('accountUnconfirmedResendSuccessful'.tr());
|
||||||
|
} catch (err) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final ua = context.watch<UserProvider>();
|
||||||
|
if (ua.user == null || ua.user?.confirmedAt != null) {
|
||||||
|
return SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Symbols.shield),
|
||||||
|
title: Text('accountUnconfirmedTitle').tr(),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('accountUnconfirmedSubtitle').tr(),
|
||||||
|
const Gap(4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('accountUnconfirmedUnreceived').tr(),
|
||||||
|
const Gap(4),
|
||||||
|
InkWell(
|
||||||
|
child: Text(
|
||||||
|
'accountUnconfirmedResend',
|
||||||
|
style: TextStyle(
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
onTap: () {
|
||||||
|
_resendConfirmationEmail(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
),
|
||||||
|
).padding(bottom: 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _HomeDashUpdateWidget extends StatelessWidget {
|
class _HomeDashUpdateWidget extends StatelessWidget {
|
||||||
final EdgeInsets? padding;
|
final EdgeInsets? padding;
|
||||||
|
|
||||||
@@ -131,7 +191,6 @@ class _HomeDashUpdateWidget extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final config = context.watch<ConfigProvider>();
|
final config = context.watch<ConfigProvider>();
|
||||||
|
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: config,
|
listenable: config,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
@@ -245,21 +304,31 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeDashTodayNews extends StatefulWidget {
|
class _HomeDashServiceStatus extends StatefulWidget {
|
||||||
const _HomeDashTodayNews();
|
const _HomeDashServiceStatus();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState();
|
State<_HomeDashServiceStatus> createState() => _HomeDashServiceStatusState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
|
class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
|
||||||
SnNewsArticle? _article;
|
Map<String, dynamic>? _statuses;
|
||||||
|
ServiceStatus? _serviceStatus;
|
||||||
|
|
||||||
Future<void> _fetchArticle() async {
|
Future<void> _fetchStatuses() async {
|
||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/re/news/today');
|
final resp = await sn.client.get('/directory/status');
|
||||||
_article = SnNewsArticle.fromJson(resp.data['data']);
|
_statuses = resp.data;
|
||||||
|
if (_statuses!.values.contains(false)) {
|
||||||
|
if (_statuses!.values.contains(true)) {
|
||||||
|
_serviceStatus = ServiceStatus.downgraded;
|
||||||
|
} else {
|
||||||
|
_serviceStatus = ServiceStatus.failed;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_serviceStatus = ServiceStatus.operational;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -272,7 +341,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
|
|||||||
@override
|
@override
|
||||||
initState() {
|
initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_fetchArticle();
|
_fetchStatuses();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -284,73 +353,136 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Symbols.newspaper),
|
const Icon(Symbols.flare),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Text(
|
Expanded(
|
||||||
'newsToday',
|
child: Text(
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
'serviceStatus',
|
||||||
).tr()
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
],
|
).tr(),
|
||||||
).padding(horizontal: 18, top: 12, bottom: 8),
|
),
|
||||||
if (_article != null)
|
IconButton(
|
||||||
Expanded(
|
icon: const Icon(Symbols.launch, size: 20),
|
||||||
child: InkWell(
|
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
||||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
constraints: const BoxConstraints(),
|
||||||
child: Column(
|
padding: EdgeInsets.zero,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onPressed: () {
|
||||||
spacing: 4,
|
launchUrlString('https://status.solsynth.dev');
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_article!.title,
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.titleMedium!
|
|
||||||
.copyWith(fontSize: 18),
|
|
||||||
maxLines:
|
|
||||||
MediaQuery.of(context).size.width >= 640 ? 2 : 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
parse(_article!.description)
|
|
||||||
.children
|
|
||||||
.map((e) => e.text.trim())
|
|
||||||
.join(),
|
|
||||||
maxLines: 3,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
Builder(builder: (context) {
|
|
||||||
final date = _article!.publishedAt ?? _article!.createdAt;
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
spacing: 2,
|
|
||||||
children: [
|
|
||||||
Text(DateFormat().format(date)).textStyle(
|
|
||||||
Theme.of(context).textTheme.bodySmall!),
|
|
||||||
Text(' · ')
|
|
||||||
.textStyle(Theme.of(context).textTheme.bodySmall!)
|
|
||||||
.bold(),
|
|
||||||
Text(RelativeTime(context).format(date)).textStyle(
|
|
||||||
Theme.of(context).textTheme.bodySmall!),
|
|
||||||
],
|
|
||||||
).opacity(0.75);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
).padding(horizontal: 16),
|
|
||||||
onTap: () {
|
|
||||||
GoRouter.of(context).pushNamed(
|
|
||||||
'newsDetail',
|
|
||||||
pathParameters: {'hash': _article!.hash},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
],
|
||||||
else
|
).padding(horizontal: 18, top: 12, bottom: 8),
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 6),
|
||||||
|
width: double.infinity,
|
||||||
|
color: _serviceStatus == null
|
||||||
|
? Theme.of(context).colorScheme.surfaceContainerHigh
|
||||||
|
: switch (_serviceStatus) {
|
||||||
|
ServiceStatus.operational => Colors.green[300],
|
||||||
|
ServiceStatus.failed => Colors.red[300],
|
||||||
|
_ => Colors.orange[300],
|
||||||
|
},
|
||||||
|
child: _serviceStatus == null
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Symbols.more_horiz,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const Gap(10),
|
||||||
|
Text('loading').tr(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: switch (_serviceStatus) {
|
||||||
|
ServiceStatus.operational => Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.check,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.green[900],
|
||||||
|
),
|
||||||
|
const Gap(10),
|
||||||
|
Text('serviceStatusOperational')
|
||||||
|
.tr()
|
||||||
|
.textColor(Colors.green[900]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ServiceStatus.failed => Tooltip(
|
||||||
|
message: 'serviceStatusFailedDescription'.tr(),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.dangerous,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.red[900],
|
||||||
|
),
|
||||||
|
const Gap(10),
|
||||||
|
Text('serviceStatusFailed')
|
||||||
|
.tr()
|
||||||
|
.textColor(Colors.red[900]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.error,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.orange[900],
|
||||||
|
),
|
||||||
|
const Gap(10),
|
||||||
|
Text('serviceStatusDowngraded')
|
||||||
|
.tr()
|
||||||
|
.textColor(Colors.orange[900]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_statuses != null)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: SingleChildScrollView(
|
||||||
child: CircularProgressIndicator(),
|
padding: EdgeInsets.only(top: 6),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final entry in _statuses!.entries)
|
||||||
|
Tooltip(
|
||||||
|
message: kServicesName[entry.key] != null
|
||||||
|
? 'serviceName${kServicesName[entry.key]}'.tr()
|
||||||
|
: 'unknown'.tr(),
|
||||||
|
child: Chip(
|
||||||
|
visualDensity:
|
||||||
|
VisualDensity(horizontal: -4, vertical: -4),
|
||||||
|
avatar: entry.value
|
||||||
|
? const Icon(
|
||||||
|
Symbols.circle,
|
||||||
|
color: Colors.green,
|
||||||
|
fill: 1,
|
||||||
|
size: 16,
|
||||||
|
)
|
||||||
|
: AnimateWidgetExtensions(const Icon(
|
||||||
|
Symbols.error,
|
||||||
|
color: Colors.red,
|
||||||
|
fill: 1,
|
||||||
|
size: 16,
|
||||||
|
))
|
||||||
|
.animate(onPlay: (e) => e.repeat())
|
||||||
|
.fadeIn(
|
||||||
|
duration: 500.ms, curve: Curves.easeOut)
|
||||||
|
.then()
|
||||||
|
.fadeOut(
|
||||||
|
duration: 500.ms,
|
||||||
|
delay: 1000.ms,
|
||||||
|
curve: Curves.easeIn,
|
||||||
|
),
|
||||||
|
label: Text(kServicesName[entry.key] ?? entry.key),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 12),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -386,11 +518,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _doCheckIn() async {
|
Future<void> _doCheckIn() async {
|
||||||
|
final captchaTk = await Navigator.of(context, rootNavigator: true).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => CaptchaScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (captchaTk == null) return;
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final home = context.read<HomeWidgetProvider>();
|
final home = context.read<HomeWidgetProvider>();
|
||||||
final resp = await sn.client.post('/cgi/id/check-in');
|
final resp = await sn.client.post('/cgi/id/check-in', data: {
|
||||||
|
'captcha_token': captchaTk,
|
||||||
|
});
|
||||||
_todayRecord = SnCheckInRecord.fromJson(resp.data);
|
_todayRecord = SnCheckInRecord.fromJson(resp.data);
|
||||||
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
|
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -546,11 +687,26 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
|
|||||||
'+${_todayRecord!.resultExperience} EXP',
|
'+${_todayRecord!.resultExperience} EXP',
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
if (_todayRecord!.resultCoin >= 0)
|
if (_todayRecord!.resultCoin > 0)
|
||||||
Text(
|
Text(
|
||||||
'+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
|
'+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
)
|
),
|
||||||
|
if (_todayRecord!.currentStreak > 0)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Symbols.local_fire_department,
|
||||||
|
size: 14,
|
||||||
|
).padding(bottom: 2),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
'checkInStreak'
|
||||||
|
.plural(_todayRecord!.currentStreak),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(top: 4),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -659,7 +815,7 @@ class _HomeDashNotificationWidgetState
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Symbols.arrow_right_alt),
|
icon: const Icon(Symbols.arrow_right_alt),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
GoRouter.of(context).goNamed('notification');
|
GoRouter.of(context).pushNamed('notification');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -743,8 +899,10 @@ class _HomeDashRecommendationPostWidgetState
|
|||||||
).tr(),
|
).tr(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Text('${_currentPage + 1}/${_posts?.length ?? 0}',
|
Text(
|
||||||
style: GoogleFonts.robotoMono())
|
'${_currentPage + 1}/${_posts?.length ?? 0}',
|
||||||
|
style: GoogleFonts.robotoMono(),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
).padding(horizontal: 18, top: 12, bottom: 8),
|
).padding(horizontal: 18, top: 12, bottom: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -762,6 +920,7 @@ class _HomeDashRecommendationPostWidgetState
|
|||||||
child: PostItem(
|
child: PostItem(
|
||||||
data: _posts![index],
|
data: _posts![index],
|
||||||
showMenu: false,
|
showMenu: false,
|
||||||
|
showFullPost: true,
|
||||||
).padding(bottom: 8),
|
).padding(bottom: 8),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context)
|
GoRouter.of(context)
|
||||||
|
|||||||
167
lib/screens/logging.dart
Normal file
167
lib/screens/logging.dart
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/logger.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
import 'package:talker_dio_logger/dio_logs.dart';
|
||||||
|
import 'package:talker_flutter/talker_flutter.dart';
|
||||||
|
|
||||||
|
final Map<LogLevel, IconData> kLogLevelIcons = {
|
||||||
|
LogLevel.error: Symbols.error,
|
||||||
|
LogLevel.critical: Symbols.error,
|
||||||
|
LogLevel.warning: Symbols.warning,
|
||||||
|
LogLevel.info: Symbols.info,
|
||||||
|
LogLevel.debug: Symbols.info_i,
|
||||||
|
LogLevel.verbose: Symbols.info_i,
|
||||||
|
};
|
||||||
|
|
||||||
|
final Map<LogLevel, bool> kLogLevelFilled = {
|
||||||
|
LogLevel.error: false,
|
||||||
|
LogLevel.critical: true,
|
||||||
|
LogLevel.warning: true,
|
||||||
|
LogLevel.info: true,
|
||||||
|
LogLevel.debug: false,
|
||||||
|
LogLevel.verbose: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
class DebugLoggingScreen extends StatelessWidget {
|
||||||
|
const DebugLoggingScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final talkerTheme = TalkerScreenTheme.fromTheme(Theme.of(context));
|
||||||
|
|
||||||
|
return AppScaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: const PageBackButton(),
|
||||||
|
title: Text('debugLogging').tr(),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
logging.cleanHistory();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
icon: const Icon(Symbols.delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView.builder(
|
||||||
|
reverse: true,
|
||||||
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
|
||||||
|
itemCount: logging.history.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final log = logging.history[index];
|
||||||
|
final color = log.getFlutterColor(talkerTheme);
|
||||||
|
return ListTile(
|
||||||
|
minTileHeight: 0,
|
||||||
|
tileColor: color.withOpacity(0.2),
|
||||||
|
leading: Icon(
|
||||||
|
kLogLevelIcons[log.logLevel ?? LogLevel.debug] ?? Symbols.help,
|
||||||
|
color: color,
|
||||||
|
fill: (kLogLevelFilled[log.logLevel ?? LogLevel.debug] ?? false)
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
),
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (log is DioRequestLog)
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${log.requestOptions.method} ${log.displayMessage}',
|
||||||
|
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||||
|
),
|
||||||
|
if (log.requestOptions.data != null)
|
||||||
|
Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
dividerColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: ExpansionTile(
|
||||||
|
title: Text('Payload').fontSize(13),
|
||||||
|
minTileHeight: 0,
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
expandedCrossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
log.requestOptions.data.toString(),
|
||||||
|
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else if (log is DioResponseLog)
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${log.response.statusCode} ${log.displayMessage}',
|
||||||
|
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||||
|
),
|
||||||
|
if (log.response.data != null)
|
||||||
|
Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
dividerColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: ExpansionTile(
|
||||||
|
title: Text('Payload').fontSize(13),
|
||||||
|
minTileHeight: 0,
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
expandedCrossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
log.response.data.toString(),
|
||||||
|
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
log.displayMessage,
|
||||||
|
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||||
|
),
|
||||||
|
if (log.exception != null)
|
||||||
|
Text(
|
||||||
|
log.displayException,
|
||||||
|
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||||
|
).bold(),
|
||||||
|
if (log.error != null)
|
||||||
|
Text(
|
||||||
|
log.displayException,
|
||||||
|
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||||
|
).bold(),
|
||||||
|
if (log.stackTrace != null)
|
||||||
|
Text(
|
||||||
|
log.displayStackTrace,
|
||||||
|
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||||
|
).padding(top: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${(log.title?.replaceAll('-', ' ') ?? 'default').capitalizeEachWord()} · ${log.displayTime()}',
|
||||||
|
).fontSize(11),
|
||||||
|
onTap: () {
|
||||||
|
Clipboard.setData(
|
||||||
|
ClipboardData(
|
||||||
|
text: log.generateTextMessage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:html/dom.dart' as dom;
|
|
||||||
import 'package:html/parser.dart';
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:html2md/html2md.dart' as html2md;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:relative_time/relative_time.dart';
|
import 'package:relative_time/relative_time.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
import 'package:surface/types/news.dart';
|
import 'package:surface/types/news.dart';
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/markdown_content.dart';
|
||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
import 'package:surface/widgets/universal_image.dart';
|
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class NewsDetailScreen extends StatefulWidget {
|
class NewsDetailScreen extends StatefulWidget {
|
||||||
@@ -26,14 +25,12 @@ class NewsDetailScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _NewsDetailScreenState extends State<NewsDetailScreen> {
|
class _NewsDetailScreenState extends State<NewsDetailScreen> {
|
||||||
SnNewsArticle? _article;
|
SnNewsArticle? _article;
|
||||||
dom.Document? _articleFragment;
|
|
||||||
|
|
||||||
Future<void> _fetchArticle() async {
|
Future<void> _fetchArticle() async {
|
||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/re/news/${widget.hash}');
|
final resp = await sn.client.get('/cgi/re/news/${widget.hash}');
|
||||||
_article = SnNewsArticle.fromJson(resp.data);
|
_article = SnNewsArticle.fromJson(resp.data);
|
||||||
_articleFragment = parse(_article!.content);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err).then((_) {
|
context.showErrorDialog(err).then((_) {
|
||||||
@@ -45,104 +42,6 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _parseHtmlToWidgets(Iterable<dom.Element>? elements) {
|
|
||||||
if (elements == null) return [];
|
|
||||||
|
|
||||||
final List<Widget> widgets = [];
|
|
||||||
|
|
||||||
for (final node in elements) {
|
|
||||||
switch (node.localName) {
|
|
||||||
case 'h1':
|
|
||||||
case 'h2':
|
|
||||||
case 'h3':
|
|
||||||
case 'h4':
|
|
||||||
case 'h5':
|
|
||||||
case 'h6':
|
|
||||||
widgets.add(Text(node.text.trim(), style: Theme.of(context).textTheme.titleMedium));
|
|
||||||
break;
|
|
||||||
case 'p':
|
|
||||||
if (node.text.trim().isEmpty) continue;
|
|
||||||
widgets.add(
|
|
||||||
Text.rich(
|
|
||||||
TextSpan(
|
|
||||||
text: node.text.trim(),
|
|
||||||
children: [
|
|
||||||
for (final child in node.children)
|
|
||||||
switch (child.localName) {
|
|
||||||
'a' => TextSpan(
|
|
||||||
text: child.text.trim(),
|
|
||||||
style: const TextStyle(decoration: TextDecoration.underline),
|
|
||||||
recognizer: TapGestureRecognizer()
|
|
||||||
..onTap = () {
|
|
||||||
launchUrlString(child.attributes['href']!);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_ => TextSpan(text: child.text.trim()),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'a':
|
|
||||||
// drop single link
|
|
||||||
break;
|
|
||||||
case 'div':
|
|
||||||
// ignore div text, normally it is not meaningful
|
|
||||||
widgets.addAll(_parseHtmlToWidgets(node.children));
|
|
||||||
break;
|
|
||||||
case 'hr':
|
|
||||||
widgets.add(const Divider());
|
|
||||||
break;
|
|
||||||
case 'img':
|
|
||||||
var src = node.attributes['src'];
|
|
||||||
if (src == null) break;
|
|
||||||
final width = double.tryParse(node.attributes['width'] ?? 'null');
|
|
||||||
final height = double.tryParse(node.attributes['height'] ?? 'null');
|
|
||||||
final ratio = width != null && height != null ? width / height : 1.0;
|
|
||||||
if (src.startsWith('//')) {
|
|
||||||
src = 'https:$src';
|
|
||||||
} else if (!src.startsWith('http')) {
|
|
||||||
final baseUri = Uri.parse(_article!.url);
|
|
||||||
final baseUrl = '${baseUri.scheme}://${baseUri.host}';
|
|
||||||
src = '$baseUrl/$src';
|
|
||||||
}
|
|
||||||
widgets.add(
|
|
||||||
AspectRatio(
|
|
||||||
aspectRatio: ratio,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
||||||
border: Border.all(
|
|
||||||
color: Theme.of(context).dividerColor,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
height: height ?? double.infinity,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
||||||
child: Container(
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
|
||||||
child: AutoResizeUniversalImage(
|
|
||||||
src,
|
|
||||||
fit: width != null && height != null ? BoxFit.cover : BoxFit.contain,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
widgets.addAll(_parseHtmlToWidgets(node.children));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return widgets;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -163,7 +62,9 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
|
|||||||
MaterialBanner(
|
MaterialBanner(
|
||||||
dividerColor: Colors.transparent,
|
dividerColor: Colors.transparent,
|
||||||
leading: const Icon(Icons.info),
|
leading: const Icon(Icons.info),
|
||||||
content: Text(_isReadingFromReader ? 'newsReadingFromReader'.tr() : 'newsReadingFromOriginal'.tr()),
|
content: Text(_isReadingFromReader
|
||||||
|
? 'newsReadingFromReader'.tr()
|
||||||
|
: 'newsReadingFromOriginal'.tr()),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: Text('newsReadingProviderSwap').tr(),
|
child: Text('newsReadingProviderSwap').tr(),
|
||||||
@@ -173,7 +74,7 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (_articleFragment != null && _isReadingFromReader)
|
if (_article != null && _isReadingFromReader)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: BoxConstraints(maxWidth: 640),
|
constraints: BoxConstraints(maxWidth: 640),
|
||||||
@@ -182,28 +83,43 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Text(_article!.title, style: Theme.of(context).textTheme.titleLarge),
|
Text(_article!.title,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge),
|
||||||
Builder(builder: (context) {
|
Builder(builder: (context) {
|
||||||
final htmlDescription = parse(_article!.description);
|
final htmlDescription = parse(_article!.description);
|
||||||
return Text(
|
return Text(
|
||||||
htmlDescription.children.map((ele) => ele.text.trim()).join(),
|
htmlDescription.children
|
||||||
|
.map((ele) => ele.text.trim())
|
||||||
|
.join(),
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
Builder(builder: (context) {
|
Builder(builder: (context) {
|
||||||
final date = _article!.publishedAt ?? _article!.createdAt;
|
final date =
|
||||||
|
_article!.publishedAt ?? _article!.createdAt;
|
||||||
return Row(
|
return Row(
|
||||||
spacing: 2,
|
spacing: 2,
|
||||||
children: [
|
children: [
|
||||||
Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
|
Text(DateFormat().format(date)).textStyle(
|
||||||
Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
|
Theme.of(context).textTheme.bodySmall!),
|
||||||
Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
|
Text(' · ')
|
||||||
|
.textStyle(
|
||||||
|
Theme.of(context).textTheme.bodySmall!)
|
||||||
|
.bold(),
|
||||||
|
Text(RelativeTime(context).format(date)).textStyle(
|
||||||
|
Theme.of(context).textTheme.bodySmall!),
|
||||||
],
|
],
|
||||||
).opacity(0.75);
|
).opacity(0.75);
|
||||||
}),
|
}),
|
||||||
Text('newsDisclaimer').tr().textStyle(Theme.of(context).textTheme.bodySmall!).opacity(0.75),
|
Text('newsDisclaimer')
|
||||||
|
.tr()
|
||||||
|
.textStyle(Theme.of(context).textTheme.bodySmall!)
|
||||||
|
.opacity(0.75),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
..._parseHtmlToWidgets(_articleFragment!.children),
|
MarkdownTextContent(
|
||||||
|
textScaler: TextScaler.linear(1.2),
|
||||||
|
content: html2md.convert(_article!.content),
|
||||||
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
InkWell(
|
InkWell(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -211,7 +127,8 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Reference from original website',
|
'Reference from original website',
|
||||||
style: TextStyle(decoration: TextDecoration.underline),
|
style: TextStyle(
|
||||||
|
decoration: TextDecoration.underline),
|
||||||
),
|
),
|
||||||
const Gap(4),
|
const Gap(4),
|
||||||
Icon(Icons.launch, size: 16),
|
Icon(Icons.launch, size: 16),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const Map<String, IconData> kNotificationTopicIcons = {
|
|||||||
'passport.security.otp': Symbols.password,
|
'passport.security.otp': Symbols.password,
|
||||||
'interactive.subscription': Symbols.subscriptions,
|
'interactive.subscription': Symbols.subscriptions,
|
||||||
'interactive.feedback': Symbols.add_reaction,
|
'interactive.feedback': Symbols.add_reaction,
|
||||||
|
'interactive.reply': Symbols.reply,
|
||||||
'messaging.callStart': Symbols.call_received,
|
'messaging.callStart': Symbols.call_received,
|
||||||
'wallet.transaction.new': Symbols.receipt,
|
'wallet.transaction.new': Symbols.receipt,
|
||||||
};
|
};
|
||||||
@@ -57,11 +58,15 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final nty = context.read<NotificationProvider>();
|
final nty = context.read<NotificationProvider>();
|
||||||
final resp = await sn.client.get('/cgi/id/notifications?take=10');
|
final resp = await sn.client.get(
|
||||||
_totalCount = resp.data['count'];
|
'/cgi/id/notifications',
|
||||||
_notifications.addAll(
|
queryParameters: {'take': 10, 'offset': _notifications.length},
|
||||||
resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? [],
|
|
||||||
);
|
);
|
||||||
|
_totalCount = resp.data['count'];
|
||||||
|
_notifications.addAll(resp.data['data']
|
||||||
|
?.map((e) => SnNotification.fromJson(e))
|
||||||
|
.cast<SnNotification>() ??
|
||||||
|
[]);
|
||||||
nty.updateTray();
|
nty.updateTray();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -97,8 +102,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showSnackbar(
|
context.showSnackbar(
|
||||||
'notificationMarkAllReadPrompt'.plural(resp.data['count']),
|
'notificationMarkAllReadPrompt'.plural(resp.data['count']));
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -123,8 +127,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showSnackbar(
|
context.showSnackbar(
|
||||||
'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
|
'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']));
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -146,12 +149,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
if (!ua.isAuthorized) {
|
if (!ua.isAuthorized) {
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: AutoAppBarLeading(),
|
leading: PageBackButton(),
|
||||||
title: Text('screenNotification').tr(),
|
title: Text('screenNotification').tr(),
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(child: UnauthorizedHint()),
|
||||||
child: UnauthorizedHint(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,9 +162,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
title: Text('screenNotification').tr(),
|
title: Text('screenNotification').tr(),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.checklist),
|
icon: const Icon(Symbols.checklist),
|
||||||
onPressed: _isSubmitting ? null : _markAllAsRead,
|
onPressed: _isSubmitting ? null : _markAllAsRead),
|
||||||
),
|
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -178,15 +178,16 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
},
|
},
|
||||||
child: InfiniteList(
|
child: InfiniteList(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
top: 16,
|
top: 16,
|
||||||
bottom: math.max(MediaQuery.of(context).padding.bottom, 16),
|
bottom:
|
||||||
),
|
math.max(MediaQuery.of(context).padding.bottom, 16)),
|
||||||
itemCount: _notifications.length,
|
itemCount: _notifications.length,
|
||||||
onFetchData: () {
|
onFetchData: () {
|
||||||
_fetchNotifications();
|
_fetchNotifications();
|
||||||
},
|
},
|
||||||
isLoading: _isBusy,
|
isLoading: _isBusy,
|
||||||
hasReachedMax: _totalCount != null && _notifications.length >= _totalCount!,
|
hasReachedMax: _totalCount != null &&
|
||||||
|
_notifications.length >= _totalCount!,
|
||||||
itemBuilder: (context, idx) {
|
itemBuilder: (context, idx) {
|
||||||
final nty = _notifications[idx];
|
final nty = _notifications[idx];
|
||||||
return Row(
|
return Row(
|
||||||
@@ -200,50 +201,48 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
children: [
|
children: [
|
||||||
if (nty.readAt == null)
|
if (nty.readAt == null)
|
||||||
StyledWidget(Badge(
|
StyledWidget(Badge(
|
||||||
label: Text('notificationUnread').tr(),
|
label: Text('notificationUnread').tr()))
|
||||||
)).padding(bottom: 4),
|
.padding(bottom: 4),
|
||||||
Text(
|
Text(nty.title,
|
||||||
nty.title,
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
if (nty.subtitle != null)
|
if (nty.subtitle != null)
|
||||||
Text(
|
Text(nty.subtitle!,
|
||||||
nty.subtitle!,
|
style:
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
Theme.of(context).textTheme.titleSmall),
|
||||||
),
|
|
||||||
if (nty.subtitle != null) const Gap(4),
|
if (nty.subtitle != null) const Gap(4),
|
||||||
SelectionArea(
|
SelectionArea(
|
||||||
child: MarkdownTextContent(
|
child: MarkdownTextContent(
|
||||||
content: nty.body,
|
content: nty.body, isAutoWarp: true)),
|
||||||
isAutoWarp: true,
|
if ([
|
||||||
),
|
'interactive.reply',
|
||||||
),
|
'interactive.feedback',
|
||||||
if (['interactive.reply', 'interactive.feedback', 'interactive.subscription']
|
'interactive.subscription',
|
||||||
.contains(nty.topic) &&
|
].contains(nty.topic) &&
|
||||||
nty.metadata['related_post'] != null)
|
nty.metadata['related_post'] != null)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(8)),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Theme.of(context).dividerColor,
|
color: Theme.of(context).dividerColor,
|
||||||
width: 1,
|
width: 1),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: PostItem(
|
child: PostItem(
|
||||||
data: SnPost.fromJson(
|
data: SnPost.fromJson(
|
||||||
nty.metadata['related_post']!,
|
nty.metadata['related_post']!),
|
||||||
),
|
|
||||||
showComments: false,
|
showComments: false,
|
||||||
showReactions: false,
|
showReactions: false,
|
||||||
showMenu: false,
|
showMenu: false,
|
||||||
),
|
).padding(vertical: 4),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed(
|
GoRouter.of(context).pushNamed(
|
||||||
'postDetail',
|
'postDetail',
|
||||||
pathParameters: {
|
pathParameters: {
|
||||||
'slug': nty.metadata['related_post']!['id'].toString(),
|
'slug': nty
|
||||||
|
.metadata['related_post']!['id']
|
||||||
|
.toString()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -251,18 +250,15 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
const Gap(8),
|
const Gap(8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(DateFormat('yy/MM/dd')
|
||||||
DateFormat('yy/MM/dd').format(nty.createdAt),
|
.format(nty.createdAt))
|
||||||
).fontSize(12),
|
.fontSize(12),
|
||||||
const Gap(4),
|
const Gap(4),
|
||||||
Text(
|
Text('·', style: TextStyle(fontSize: 12)),
|
||||||
'·',
|
|
||||||
style: TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
const Gap(4),
|
const Gap(4),
|
||||||
Text(
|
Text(RelativeTime(context)
|
||||||
RelativeTime(context).format(nty.createdAt),
|
.format(nty.createdAt))
|
||||||
).fontSize(12),
|
.fontSize(12),
|
||||||
],
|
],
|
||||||
).opacity(0.75),
|
).opacity(0.75),
|
||||||
],
|
],
|
||||||
@@ -272,8 +268,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.check),
|
icon: const Icon(Symbols.check),
|
||||||
padding: EdgeInsets.all(0),
|
padding: EdgeInsets.all(0),
|
||||||
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
|
visualDensity:
|
||||||
onPressed: _isSubmitting ? null : () => _markOneAsRead(nty),
|
const VisualDensity(horizontal: -4, vertical: -4),
|
||||||
|
onPressed:
|
||||||
|
_isSubmitting ? null : () => _markOneAsRead(nty),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 16);
|
).padding(horizontal: 16);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import 'package:surface/providers/userinfo.dart';
|
|||||||
import 'package:surface/types/post.dart';
|
import 'package:surface/types/post.dart';
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
import 'package:surface/widgets/loading_indicator.dart';
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
import 'package:surface/widgets/navigation/app_background.dart';
|
|
||||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
import 'package:surface/widgets/post/post_comment_list.dart';
|
import 'package:surface/widgets/post/post_comment_list.dart';
|
||||||
import 'package:surface/widgets/post/post_item.dart';
|
import 'package:surface/widgets/post/post_item.dart';
|
||||||
@@ -22,7 +21,8 @@ class PostDetailScreen extends StatefulWidget {
|
|||||||
final SnPost? preload;
|
final SnPost? preload;
|
||||||
final Function? onBack;
|
final Function? onBack;
|
||||||
|
|
||||||
const PostDetailScreen({super.key, required this.slug, this.preload, this.onBack});
|
const PostDetailScreen(
|
||||||
|
{super.key, required this.slug, this.preload, this.onBack});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PostDetailScreen> createState() => _PostDetailScreenState();
|
State<PostDetailScreen> createState() => _PostDetailScreenState();
|
||||||
@@ -65,108 +65,111 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
|
|||||||
|
|
||||||
final double maxWidth = _data?.type == 'video' ? double.infinity : 640;
|
final double maxWidth = _data?.type == 'video' ? double.infinity : 640;
|
||||||
|
|
||||||
return AppBackground(
|
return AppScaffold(
|
||||||
isRoot: widget.onBack != null,
|
noBackground: true,
|
||||||
child: AppScaffold(
|
appBar: AppBar(
|
||||||
appBar: AppBar(
|
leading: BackButton(
|
||||||
leading: BackButton(
|
onPressed: () {
|
||||||
onPressed: () {
|
if (widget.onBack != null) {
|
||||||
if (widget.onBack != null) {
|
widget.onBack!.call();
|
||||||
widget.onBack!.call();
|
}
|
||||||
}
|
if (GoRouter.of(context).canPop()) {
|
||||||
if (GoRouter.of(context).canPop()) {
|
GoRouter.of(context).pop(context);
|
||||||
GoRouter.of(context).pop(context);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
GoRouter.of(context).replaceNamed('explore');
|
||||||
GoRouter.of(context).replaceNamed('explore');
|
},
|
||||||
},
|
|
||||||
),
|
|
||||||
title: _data?.body['title'] != null
|
|
||||||
? RichText(
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
text: TextSpan(children: [
|
|
||||||
TextSpan(
|
|
||||||
text: _data?.body['title'] ?? 'postNoun'.tr(),
|
|
||||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
|
||||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const TextSpan(text: '\n'),
|
|
||||||
TextSpan(
|
|
||||||
text: 'postDetail'.tr(),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
|
||||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
)
|
|
||||||
: Text('postDetail').tr(),
|
|
||||||
),
|
),
|
||||||
body: CustomScrollView(
|
title: _data?.body['title'] != null
|
||||||
slivers: [
|
? RichText(
|
||||||
SliverToBoxAdapter(
|
textAlign: TextAlign.center,
|
||||||
child: LoadingIndicator(isActive: _isBusy),
|
text: TextSpan(children: [
|
||||||
),
|
TextSpan(
|
||||||
if (_data != null)
|
text: _data?.body['title'] ?? 'postNoun'.tr(),
|
||||||
SliverToBoxAdapter(
|
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||||
child: PostItem(
|
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||||
data: _data!,
|
|
||||||
maxWidth: maxWidth,
|
|
||||||
showComments: false,
|
|
||||||
showFullPost: true,
|
|
||||||
onChanged: (data) {
|
|
||||||
setState(() => _data = data);
|
|
||||||
},
|
|
||||||
onDeleted: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_data != null && _data!.type != 'video') const SliverToBoxAdapter(child: Divider(height: 1)),
|
|
||||||
if (_data != null && _data!.type != 'video')
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Container(
|
|
||||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Symbols.comment, size: 24),
|
|
||||||
const Gap(16),
|
|
||||||
Text('postCommentsDetailed')
|
|
||||||
.plural(_data!.metric.replyCount)
|
|
||||||
.textStyle(Theme.of(context).textTheme.titleLarge!),
|
|
||||||
],
|
|
||||||
).padding(horizontal: 20, vertical: 12).center(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_data != null && ua.isAuthorized && _data!.type != 'video')
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: PostCommentQuickAction(
|
|
||||||
parentPost: _data!,
|
|
||||||
maxWidth: maxWidth,
|
|
||||||
onPosted: () {
|
|
||||||
setState(() {
|
|
||||||
_data = _data!.copyWith(
|
|
||||||
metric: _data!.metric.copyWith(
|
|
||||||
replyCount: _data!.metric.replyCount + 1,
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
});
|
const TextSpan(text: '\n'),
|
||||||
_childListKey.currentState!.refresh();
|
TextSpan(
|
||||||
},
|
text: 'postDetail'.tr(),
|
||||||
),
|
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||||
|
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
)
|
||||||
|
: Text('postDetail').tr(),
|
||||||
|
),
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: LoadingIndicator(isActive: _isBusy),
|
||||||
|
),
|
||||||
|
if (_data != null)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: PostItem(
|
||||||
|
data: _data!,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
showComments: false,
|
||||||
|
showFullPost: true,
|
||||||
|
onChanged: (data) {
|
||||||
|
setState(() => _data = data);
|
||||||
|
},
|
||||||
|
onDeleted: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
if (_data != null && _data!.type != 'video')
|
),
|
||||||
PostCommentSliverList(
|
if (_data != null)
|
||||||
key: _childListKey,
|
SliverToBoxAdapter(
|
||||||
|
child: Divider(height: 1).padding(top: 8),
|
||||||
|
),
|
||||||
|
if (_data != null)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.comment, size: 24),
|
||||||
|
const Gap(16),
|
||||||
|
Text('postCommentsDetailed')
|
||||||
|
.plural(_data!.metric.replyCount)
|
||||||
|
.textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 20, vertical: 12).center(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_data != null && ua.isAuthorized)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: PostCommentQuickAction(
|
||||||
parentPost: _data!,
|
parentPost: _data!,
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
|
onPosted: () {
|
||||||
|
setState(() {
|
||||||
|
_data = _data!.copyWith(
|
||||||
|
metric: _data!.metric.copyWith(
|
||||||
|
replyCount: _data!.metric.replyCount + 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
_childListKey.currentState!.refresh();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
if (_data != null && _data!.type == 'video') SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
|
),
|
||||||
],
|
if (_data != null) SliverGap(8),
|
||||||
),
|
if (_data != null)
|
||||||
|
PostCommentSliverList(
|
||||||
|
key: _childListKey,
|
||||||
|
parentPost: _data!,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
),
|
||||||
|
if (_data != null)
|
||||||
|
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
89
lib/screens/post/post_draft.dart
Normal file
89
lib/screens/post/post_draft.dart
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/post.dart';
|
||||||
|
import 'package:surface/types/post.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
import 'package:surface/widgets/post/post_item.dart';
|
||||||
|
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||||
|
|
||||||
|
class PostDraftBox extends StatefulWidget {
|
||||||
|
const PostDraftBox({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PostDraftBox> createState() => _PostDraftBoxState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PostDraftBoxState extends State<PostDraftBox> {
|
||||||
|
bool _isBusy = false;
|
||||||
|
final List<SnPost> _posts = List.empty(growable: true);
|
||||||
|
int? _totalCount;
|
||||||
|
|
||||||
|
Future<void> _fetchPosts() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final pt = context.read<SnPostContentProvider>();
|
||||||
|
final resp = await pt.listPosts(
|
||||||
|
take: 10,
|
||||||
|
offset: _posts.length,
|
||||||
|
isDraft: true,
|
||||||
|
);
|
||||||
|
final out = resp.$1;
|
||||||
|
_totalCount = resp.$2;
|
||||||
|
if (!mounted) return;
|
||||||
|
_posts.addAll(out);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('postDraftBox').tr(),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: () {
|
||||||
|
_posts.clear();
|
||||||
|
return _fetchPosts();
|
||||||
|
},
|
||||||
|
child: InfiniteList(
|
||||||
|
padding: EdgeInsets.only(top: 8),
|
||||||
|
hasReachedMax:
|
||||||
|
_totalCount != null && _posts.length >= _totalCount!,
|
||||||
|
itemCount: _posts.length,
|
||||||
|
onFetchData: () => _fetchPosts(),
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
final ele = _posts[idx];
|
||||||
|
return OpenablePostItem(
|
||||||
|
data: ele,
|
||||||
|
onChanged: (data) {
|
||||||
|
_posts[idx] = data;
|
||||||
|
},
|
||||||
|
onDeleted: () {
|
||||||
|
_posts.clear();
|
||||||
|
_fetchPosts();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
const Divider().padding(vertical: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import 'package:surface/controllers/post_write_controller.dart';
|
|||||||
import 'package:surface/providers/config.dart';
|
import 'package:surface/providers/config.dart';
|
||||||
import 'package:surface/providers/sn_attachment.dart';
|
import 'package:surface/providers/sn_attachment.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/providers/sn_realm.dart';
|
||||||
import 'package:surface/types/attachment.dart';
|
import 'package:surface/types/attachment.dart';
|
||||||
import 'package:surface/types/post.dart';
|
import 'package:surface/types/post.dart';
|
||||||
import 'package:surface/types/realm.dart';
|
import 'package:surface/types/realm.dart';
|
||||||
@@ -36,24 +37,27 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:surface/widgets/post/post_poll_editor.dart';
|
import 'package:surface/widgets/post/post_poll_editor.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../../providers/sn_realm.dart';
|
const kPostTypes = ['Story', 'Article', 'Question', 'Video'];
|
||||||
|
const kPostTypeAliases = ['stories', 'articles', 'questions', 'videos'];
|
||||||
|
|
||||||
class PostEditorExtra {
|
class PostEditorExtra {
|
||||||
final String? text;
|
final String? text;
|
||||||
final String? title;
|
final String? title;
|
||||||
final String? description;
|
final String? description;
|
||||||
final List<PostWriteMedia>? attachments;
|
final List<PostWriteMedia>? attachments;
|
||||||
|
final SnRealm? realm;
|
||||||
|
|
||||||
const PostEditorExtra({
|
const PostEditorExtra({
|
||||||
this.text,
|
this.text,
|
||||||
this.title,
|
this.title,
|
||||||
this.description,
|
this.description,
|
||||||
this.attachments,
|
this.attachments,
|
||||||
|
this.realm,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class PostEditorScreen extends StatefulWidget {
|
class PostEditorScreen extends StatefulWidget {
|
||||||
final String mode;
|
final String? mode;
|
||||||
final int? postEditId;
|
final int? postEditId;
|
||||||
final int? postReplyId;
|
final int? postReplyId;
|
||||||
final int? postRepostId;
|
final int? postRepostId;
|
||||||
@@ -72,7 +76,10 @@ class PostEditorScreen extends StatefulWidget {
|
|||||||
State<PostEditorScreen> createState() => _PostEditorScreenState();
|
State<PostEditorScreen> createState() => _PostEditorScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PostEditorScreenState extends State<PostEditorScreen> {
|
class _PostEditorScreenState extends State<PostEditorScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final TabController _tabController =
|
||||||
|
TabController(length: 4, vsync: this);
|
||||||
late final PostWriteController _writeController = PostWriteController(
|
late final PostWriteController _writeController = PostWriteController(
|
||||||
doLoadFromTemporary: widget.postEditId == null,
|
doLoadFromTemporary: widget.postEditId == null,
|
||||||
);
|
);
|
||||||
@@ -95,8 +102,9 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
|
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
|
||||||
);
|
);
|
||||||
final beforeId = config.prefs.getInt('int_last_publisher_id');
|
final beforeId = config.prefs.getInt('int_last_publisher_id');
|
||||||
_writeController
|
_writeController.setPublisher(
|
||||||
.setPublisher(_publishers?.where((ele) => ele.id == beforeId).firstOrNull ?? _publishers?.firstOrNull);
|
_publishers?.where((ele) => ele.id == beforeId).firstOrNull ??
|
||||||
|
_publishers?.firstOrNull);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -125,7 +133,20 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
|
|
||||||
final HotKey _pasteHotKey = HotKey(
|
final HotKey _pasteHotKey = HotKey(
|
||||||
key: PhysicalKeyboardKey.keyV,
|
key: PhysicalKeyboardKey.keyV,
|
||||||
modifiers: [(!kIsWeb && Platform.isMacOS) ? HotKeyModifier.meta : HotKeyModifier.control],
|
modifiers: [
|
||||||
|
(!kIsWeb && Platform.isMacOS)
|
||||||
|
? HotKeyModifier.meta
|
||||||
|
: HotKeyModifier.control
|
||||||
|
],
|
||||||
|
scope: HotKeyScope.inapp,
|
||||||
|
);
|
||||||
|
final HotKey _saveDraftHotKey = HotKey(
|
||||||
|
key: PhysicalKeyboardKey.keyS,
|
||||||
|
modifiers: [
|
||||||
|
(!kIsWeb && Platform.isMacOS)
|
||||||
|
? HotKeyModifier.meta
|
||||||
|
: HotKeyModifier.control
|
||||||
|
],
|
||||||
scope: HotKeyScope.inapp,
|
scope: HotKeyScope.inapp,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -143,6 +164,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
]);
|
]);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
});
|
});
|
||||||
|
hotKeyManager.register(_saveDraftHotKey, keyDownHandler: (_) async {
|
||||||
|
if (mounted) {
|
||||||
|
_writeController.sendPost(context, saveAsDraft: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPublisherPopup() {
|
void _showPublisherPopup() {
|
||||||
@@ -204,9 +230,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
_writeController.dispose();
|
_writeController.dispose();
|
||||||
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
|
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
|
||||||
hotKeyManager.unregister(_pasteHotKey);
|
hotKeyManager.unregister(_pasteHotKey);
|
||||||
|
hotKeyManager.unregister(_saveDraftHotKey);
|
||||||
}
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -215,14 +243,16 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_registerHotKey();
|
_registerHotKey();
|
||||||
if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
|
|
||||||
context.showErrorDialog('Unknown post type');
|
|
||||||
Navigator.pop(context);
|
|
||||||
} else {
|
|
||||||
_writeController.setMode(widget.mode);
|
|
||||||
}
|
|
||||||
_fetchRealms();
|
_fetchRealms();
|
||||||
_fetchPublishers();
|
_fetchPublishers();
|
||||||
|
if (widget.mode != null) {
|
||||||
|
_writeController.setMode(widget.mode!);
|
||||||
|
}
|
||||||
|
_tabController.addListener(() {
|
||||||
|
if (_tabController.indexIsChanging) {
|
||||||
|
_writeController.setMode(kPostTypeAliases[_tabController.index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
_writeController.fetchRelatedPost(
|
_writeController.fetchRelatedPost(
|
||||||
context,
|
context,
|
||||||
editing: widget.postEditId,
|
editing: widget.postEditId,
|
||||||
@@ -232,8 +262,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
if (widget.extraProps != null) {
|
if (widget.extraProps != null) {
|
||||||
_writeController.contentController.text = widget.extraProps!.text ?? '';
|
_writeController.contentController.text = widget.extraProps!.text ?? '';
|
||||||
_writeController.titleController.text = widget.extraProps!.title ?? '';
|
_writeController.titleController.text = widget.extraProps!.title ?? '';
|
||||||
_writeController.descriptionController.text = widget.extraProps!.description ?? '';
|
_writeController.descriptionController.text =
|
||||||
|
widget.extraProps!.description ?? '';
|
||||||
_writeController.addAttachments(widget.extraProps!.attachments ?? []);
|
_writeController.addAttachments(widget.extraProps!.attachments ?? []);
|
||||||
|
_writeController.setRealm(widget.extraProps!.realm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,38 +281,58 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
title: RichText(
|
title: Text(
|
||||||
textAlign: TextAlign.center,
|
_writeController.title.isNotEmpty
|
||||||
text: TextSpan(children: [
|
? _writeController.title
|
||||||
TextSpan(
|
: 'untitled'.tr(),
|
||||||
text: _writeController.title.isNotEmpty ? _writeController.title : 'untitled'.tr(),
|
|
||||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
|
||||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const TextSpan(text: '\n'),
|
|
||||||
TextSpan(
|
|
||||||
text: PostWriteController.kTitleMap[widget.mode]!.tr(),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
|
||||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
maxLines: 2,
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: _writeController.editingDraft
|
||||||
|
? const Icon(Icons.save)
|
||||||
|
: const Icon(Symbols.save_as),
|
||||||
|
onPressed: () {
|
||||||
|
_writeController.sendPost(context, saveAsDraft: true).then(
|
||||||
|
(_) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
context.showSnackbar('postDraftSaved'.tr());
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.tune),
|
icon: const Icon(Symbols.tune),
|
||||||
onPressed: _writeController.isBusy ? null : _updateMeta,
|
onPressed: _writeController.isBusy ? null : _updateMeta,
|
||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
],
|
],
|
||||||
|
bottom: _writeController.isNotEmpty || widget.mode != null
|
||||||
|
? null
|
||||||
|
: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: [
|
||||||
|
for (final type in kPostTypes)
|
||||||
|
Tab(
|
||||||
|
child: Text(
|
||||||
|
'postType$type'.tr(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.appBarTheme
|
||||||
|
.foregroundColor!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
if (_writeController.editingPost != null)
|
if (_writeController.editingPost != null &&
|
||||||
|
!_writeController.editingDraft)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20, right: 20),
|
padding: const EdgeInsets.only(
|
||||||
|
top: 4, bottom: 4, left: 20, right: 20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
@@ -294,13 +346,16 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.edit, size: 16),
|
const Icon(Icons.edit, size: 16),
|
||||||
const Gap(10),
|
const Gap(10),
|
||||||
Text('postEditingNotice').tr(args: ['@${_writeController.editingPost!.publisher.name}']),
|
Text('postEditingNotice').tr(args: [
|
||||||
|
'@${_writeController.editingPost!.publisher.name}'
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_writeController.replyingPost != null)
|
if (_writeController.replyingPost != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20, right: 20),
|
padding: const EdgeInsets.only(
|
||||||
|
top: 4, bottom: 4, left: 20, right: 20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
@@ -314,7 +369,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Symbols.reply, size: 16),
|
const Icon(Symbols.reply, size: 16),
|
||||||
const Gap(10),
|
const Gap(10),
|
||||||
Text('@${_writeController.replyingPost!.publisher.name}').bold(),
|
Text('@${_writeController.replyingPost!.publisher.name}')
|
||||||
|
.bold(),
|
||||||
const Gap(4),
|
const Gap(4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -328,7 +384,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
),
|
),
|
||||||
if (_writeController.repostingPost != null)
|
if (_writeController.repostingPost != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20, right: 20),
|
padding: const EdgeInsets.only(
|
||||||
|
top: 4, bottom: 4, left: 20, right: 20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
@@ -342,7 +399,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Symbols.forward, size: 16),
|
const Icon(Symbols.forward, size: 16),
|
||||||
const Gap(10),
|
const Gap(10),
|
||||||
Text('@${_writeController.repostingPost!.publisher.name}').bold(),
|
Text('@${_writeController.repostingPost!.publisher.name}')
|
||||||
|
.bold(),
|
||||||
const Gap(4),
|
const Gap(4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -359,7 +417,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
children: [
|
children: [
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
padding: EdgeInsets.only(bottom: 160),
|
padding: EdgeInsets.only(bottom: 160),
|
||||||
child: StyledWidget(switch (_writeController.mode) {
|
child: switch (_writeController.mode) {
|
||||||
'stories' => _PostStoryEditor(
|
'stories' => _PostStoryEditor(
|
||||||
controller: _writeController,
|
controller: _writeController,
|
||||||
onTapPublisher: _showPublisherPopup,
|
onTapPublisher: _showPublisherPopup,
|
||||||
@@ -381,10 +439,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
onTapRealm: _showRealmPopup,
|
onTapRealm: _showRealmPopup,
|
||||||
),
|
),
|
||||||
_ => const Placeholder(),
|
_ => const Placeholder(),
|
||||||
})
|
},
|
||||||
.padding(top: 8),
|
|
||||||
),
|
),
|
||||||
if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null)
|
if (_writeController.attachments.isNotEmpty ||
|
||||||
|
_writeController.thumbnail != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -393,16 +451,19 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
attachments: _writeController.attachments,
|
attachments: _writeController.attachments,
|
||||||
isBusy: _writeController.isBusy,
|
isBusy: _writeController.isBusy,
|
||||||
onUpload: (int idx) async {
|
onUpload: (int idx) async {
|
||||||
await _writeController.uploadSingleAttachment(context, idx);
|
await _writeController.uploadSingleAttachment(
|
||||||
|
context, idx);
|
||||||
},
|
},
|
||||||
onInsertLink: (int idx) async {
|
onInsertLink: (int idx) async {
|
||||||
_writeController.contentController.text +=
|
_writeController.contentController.text +=
|
||||||
'\n';
|
'\n';
|
||||||
},
|
},
|
||||||
onUpdate: (int idx, PostWriteMedia updatedMedia) async {
|
onUpdate:
|
||||||
|
(int idx, PostWriteMedia updatedMedia) async {
|
||||||
_writeController.setIsBusy(true);
|
_writeController.setIsBusy(true);
|
||||||
try {
|
try {
|
||||||
_writeController.setAttachmentAt(idx, updatedMedia);
|
_writeController.setAttachmentAt(
|
||||||
|
idx, updatedMedia);
|
||||||
} finally {
|
} finally {
|
||||||
_writeController.setIsBusy(false);
|
_writeController.setIsBusy(false);
|
||||||
}
|
}
|
||||||
@@ -415,7 +476,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
_writeController.setIsBusy(false);
|
_writeController.setIsBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUpdateBusy: (state) => _writeController.setIsBusy(state),
|
onUpdateBusy: (state) =>
|
||||||
|
_writeController.setIsBusy(state),
|
||||||
).padding(bottom: 8),
|
).padding(bottom: 8),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -426,11 +488,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (_writeController.isBusy && _writeController.progress != null)
|
if (_writeController.isBusy &&
|
||||||
|
_writeController.progress != null)
|
||||||
TweenAnimationBuilder<double>(
|
TweenAnimationBuilder<double>(
|
||||||
tween: Tween(begin: 0, end: _writeController.progress),
|
tween: Tween(begin: 0, end: _writeController.progress),
|
||||||
duration: Duration(milliseconds: 300),
|
duration: Duration(milliseconds: 300),
|
||||||
builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
|
builder: (context, value, _) =>
|
||||||
|
LinearProgressIndicator(value: value, minHeight: 2),
|
||||||
)
|
)
|
||||||
else if (_writeController.isBusy)
|
else if (_writeController.isBusy)
|
||||||
const LinearProgressIndicator(value: null, minHeight: 2),
|
const LinearProgressIndicator(value: null, minHeight: 2),
|
||||||
@@ -439,12 +503,14 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
Container(
|
Container(
|
||||||
child: _writeController.temporaryRestored
|
child: _writeController.temporaryRestored
|
||||||
? Container(
|
? Container(
|
||||||
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 28, right: 22),
|
padding: const EdgeInsets.only(
|
||||||
|
top: 4, bottom: 4, left: 28, right: 22),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
color: Theme.of(context).dividerColor,
|
color: Theme.of(context).dividerColor,
|
||||||
width: 1 / MediaQuery.of(context).devicePixelRatio,
|
width: 1 /
|
||||||
|
MediaQuery.of(context).devicePixelRatio,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -453,7 +519,9 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Icons.restore, size: 20),
|
const Icon(Icons.restore, size: 20),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Expanded(child: Text('postLocalDraftRestored').tr()),
|
Expanded(
|
||||||
|
child:
|
||||||
|
Text('postLocalDraftRestored').tr()),
|
||||||
InkWell(
|
InkWell(
|
||||||
child: Text('dialogDismiss').tr(),
|
child: Text('dialogDismiss').tr(),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -464,8 +532,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
))
|
))
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
)
|
)
|
||||||
.height(_writeController.temporaryRestored ? 32 : 0, animate: true)
|
.height(_writeController.temporaryRestored ? 32 : 0,
|
||||||
.animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn),
|
animate: true)
|
||||||
|
.animate(const Duration(milliseconds: 300),
|
||||||
|
Curves.fastLinearToSlowEaseIn),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -485,11 +555,18 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
),
|
),
|
||||||
if (_writeController.mode == 'stories')
|
if (_writeController.mode == 'stories')
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Symbols.poll, color: Theme.of(context).colorScheme.primary),
|
icon: Icon(Symbols.poll,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary),
|
||||||
style: ButtonStyle(
|
style: ButtonStyle(
|
||||||
backgroundColor: _writeController.poll == null
|
backgroundColor:
|
||||||
? null
|
_writeController.poll == null
|
||||||
: WidgetStatePropertyAll(Theme.of(context).colorScheme.surfaceContainer),
|
? null
|
||||||
|
: WidgetStatePropertyAll(
|
||||||
|
Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainer),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_showPollEditorDialog();
|
_showPollEditorDialog();
|
||||||
@@ -497,14 +574,22 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
),
|
),
|
||||||
if (_writeController.mode == 'articles')
|
if (_writeController.mode == 'articles')
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Symbols.full_coverage, color: Theme.of(context).colorScheme.primary),
|
icon: Icon(Symbols.full_coverage,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary),
|
||||||
style: ButtonStyle(
|
style: ButtonStyle(
|
||||||
backgroundColor: _writeController.thumbnail == null
|
backgroundColor:
|
||||||
? null
|
_writeController.thumbnail == null
|
||||||
: WidgetStatePropertyAll(Theme.of(context).colorScheme.surfaceContainer),
|
? null
|
||||||
|
: WidgetStatePropertyAll(
|
||||||
|
Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainer),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_writeController.thumbnail != null) {
|
if (_writeController.thumbnail !=
|
||||||
|
null) {
|
||||||
_writeController.setThumbnail(null);
|
_writeController.setThumbnail(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -517,7 +602,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: (_writeController.isBusy || _writeController.publisher == null)
|
onPressed: (_writeController.isBusy ||
|
||||||
|
_writeController.publisher == null)
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
_writeController.sendPost(context).then((_) {
|
_writeController.sendPost(context).then((_) {
|
||||||
@@ -556,7 +642,8 @@ class _PostPublisherPopup extends StatelessWidget {
|
|||||||
final List<SnPublisher>? publishers;
|
final List<SnPublisher>? publishers;
|
||||||
final Function onUpdate;
|
final Function onUpdate;
|
||||||
|
|
||||||
const _PostPublisherPopup({required this.controller, this.publishers, required this.onUpdate});
|
const _PostPublisherPopup(
|
||||||
|
{required this.controller, this.publishers, required this.onUpdate});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -568,7 +655,9 @@ class _PostPublisherPopup extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Symbols.face, size: 24),
|
const Icon(Symbols.face, size: 24),
|
||||||
const Gap(16),
|
const Gap(16),
|
||||||
Text('accountPublishers', style: Theme.of(context).textTheme.titleLarge).tr(),
|
Text('accountPublishers',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge)
|
||||||
|
.tr(),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -612,7 +701,8 @@ class _PostRealmPopup extends StatelessWidget {
|
|||||||
final List<SnRealm>? realms;
|
final List<SnRealm>? realms;
|
||||||
final Function onUpdate;
|
final Function onUpdate;
|
||||||
|
|
||||||
const _PostRealmPopup({required this.controller, this.realms, required this.onUpdate});
|
const _PostRealmPopup(
|
||||||
|
{required this.controller, this.realms, required this.onUpdate});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -624,7 +714,8 @@ class _PostRealmPopup extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Symbols.face, size: 24),
|
const Icon(Symbols.face, size: 24),
|
||||||
const Gap(16),
|
const Gap(16),
|
||||||
Text('accountRealms', style: Theme.of(context).textTheme.titleLarge).tr(),
|
Text('accountRealms', style: Theme.of(context).textTheme.titleLarge)
|
||||||
|
.tr(),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -665,12 +756,13 @@ class _PostStoryEditor extends StatelessWidget {
|
|||||||
final Function? onTapPublisher;
|
final Function? onTapPublisher;
|
||||||
final Function? onTapRealm;
|
final Function? onTapRealm;
|
||||||
|
|
||||||
const _PostStoryEditor({required this.controller, this.onTapPublisher, this.onTapRealm});
|
const _PostStoryEditor(
|
||||||
|
{required this.controller, this.onTapPublisher, this.onTapRealm});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
|
||||||
constraints: const BoxConstraints(maxWidth: 640),
|
constraints: const BoxConstraints(maxWidth: 640),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -717,7 +809,8 @@ class _PostStoryEditor extends StatelessWidget {
|
|||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
).padding(horizontal: 16),
|
).padding(horizontal: 16),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -732,8 +825,10 @@ class _PostStoryEditor extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
contentInsertionConfiguration: controller.contentInsertionConfiguration,
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
contentInsertionConfiguration:
|
||||||
|
controller.contentInsertionConfiguration,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -749,7 +844,8 @@ class _PostArticleEditor extends StatelessWidget {
|
|||||||
final Function? onTapPublisher;
|
final Function? onTapPublisher;
|
||||||
final Function? onTapRealm;
|
final Function? onTapRealm;
|
||||||
|
|
||||||
const _PostArticleEditor({required this.controller, this.onTapPublisher, this.onTapRealm});
|
const _PostArticleEditor(
|
||||||
|
{required this.controller, this.onTapPublisher, this.onTapRealm});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -857,8 +953,10 @@ class _PostArticleEditor extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
contentInsertionConfiguration: controller.contentInsertionConfiguration,
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
contentInsertionConfiguration:
|
||||||
|
controller.contentInsertionConfiguration,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
@@ -893,7 +991,8 @@ class _PostArticleEditor extends StatelessWidget {
|
|||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
contentInsertionConfiguration: controller.contentInsertionConfiguration,
|
contentInsertionConfiguration:
|
||||||
|
controller.contentInsertionConfiguration,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -906,12 +1005,13 @@ class _PostQuestionEditor extends StatelessWidget {
|
|||||||
final Function? onTapPublisher;
|
final Function? onTapPublisher;
|
||||||
final Function? onTapRealm;
|
final Function? onTapRealm;
|
||||||
|
|
||||||
const _PostQuestionEditor({required this.controller, this.onTapPublisher, this.onTapRealm});
|
const _PostQuestionEditor(
|
||||||
|
{required this.controller, this.onTapPublisher, this.onTapRealm});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
|
||||||
constraints: const BoxConstraints(maxWidth: 640),
|
constraints: const BoxConstraints(maxWidth: 640),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -958,7 +1058,8 @@ class _PostQuestionEditor extends StatelessWidget {
|
|||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
).padding(horizontal: 16),
|
).padding(horizontal: 16),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -969,7 +1070,8 @@ class _PostQuestionEditor extends StatelessWidget {
|
|||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
).padding(horizontal: 16),
|
).padding(horizontal: 16),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -984,14 +1086,16 @@ class _PostQuestionEditor extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
contentInsertionConfiguration: controller.contentInsertionConfiguration,
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
contentInsertionConfiguration:
|
||||||
|
controller.contentInsertionConfiguration,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).padding(top: 8),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1001,7 +1105,8 @@ class _PostVideoEditor extends StatelessWidget {
|
|||||||
final Function? onTapPublisher;
|
final Function? onTapPublisher;
|
||||||
final Function? onTapRealm;
|
final Function? onTapRealm;
|
||||||
|
|
||||||
const _PostVideoEditor({required this.controller, this.onTapPublisher, this.onTapRealm});
|
const _PostVideoEditor(
|
||||||
|
{required this.controller, this.onTapPublisher, this.onTapRealm});
|
||||||
|
|
||||||
void _selectVideo(BuildContext context) async {
|
void _selectVideo(BuildContext context) async {
|
||||||
final video = await showDialog<SnAttachment?>(
|
final video = await showDialog<SnAttachment?>(
|
||||||
@@ -1022,7 +1127,8 @@ class _PostVideoEditor extends StatelessWidget {
|
|||||||
|
|
||||||
final result = await showDialog<SnAttachment?>(
|
final result = await showDialog<SnAttachment?>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => PendingAttachmentAltDialog(media: PostWriteMedia(controller.videoAttachment)),
|
builder: (context) => PendingAttachmentAltDialog(
|
||||||
|
media: PostWriteMedia(controller.videoAttachment)),
|
||||||
);
|
);
|
||||||
if (result == null) return;
|
if (result == null) return;
|
||||||
|
|
||||||
@@ -1034,7 +1140,8 @@ class _PostVideoEditor extends StatelessWidget {
|
|||||||
|
|
||||||
final result = await showDialog<SnAttachmentBoost?>(
|
final result = await showDialog<SnAttachmentBoost?>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => PendingAttachmentBoostDialog(media: PostWriteMedia(controller.videoAttachment)),
|
builder: (context) => PendingAttachmentBoostDialog(
|
||||||
|
media: PostWriteMedia(controller.videoAttachment)),
|
||||||
);
|
);
|
||||||
if (result == null) return;
|
if (result == null) return;
|
||||||
|
|
||||||
@@ -1077,7 +1184,8 @@ class _PostVideoEditor extends StatelessWidget {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
await sn.client.delete('/cgi/uc/attachments/${controller.videoAttachment!.id}');
|
await sn.client
|
||||||
|
.delete('/cgi/uc/attachments/${controller.videoAttachment!.id}');
|
||||||
controller.setVideoAttachment(null);
|
controller.setVideoAttachment(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -1087,143 +1195,159 @@ class _PostVideoEditor extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Container(
|
||||||
children: [
|
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
|
||||||
Column(
|
constraints: const BoxConstraints(maxWidth: 640),
|
||||||
children: [
|
child: Row(
|
||||||
Material(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
elevation: 2,
|
children: [
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
Column(
|
||||||
child: GestureDetector(
|
children: [
|
||||||
onTap: () {
|
Material(
|
||||||
onTapPublisher?.call();
|
elevation: 2,
|
||||||
},
|
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||||
child: AccountImage(
|
child: GestureDetector(
|
||||||
content: controller.publisher?.avatar,
|
onTap: () {
|
||||||
|
onTapPublisher?.call();
|
||||||
|
},
|
||||||
|
child: AccountImage(
|
||||||
|
content: controller.publisher?.avatar,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const Gap(11),
|
||||||
const Gap(11),
|
Material(
|
||||||
Material(
|
elevation: 1,
|
||||||
elevation: 1,
|
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
child: GestureDetector(
|
||||||
child: GestureDetector(
|
onTap: () {
|
||||||
onTap: () {
|
onTapRealm?.call();
|
||||||
onTapRealm?.call();
|
},
|
||||||
},
|
child: AccountImage(
|
||||||
child: AccountImage(
|
content: controller.realm?.avatar,
|
||||||
content: controller.realm?.avatar,
|
fallbackWidget: const Icon(Symbols.globe, size: 20),
|
||||||
fallbackWidget: const Icon(Symbols.globe, size: 20),
|
radius: 14,
|
||||||
radius: 14,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
const Gap(16),
|
|
||||||
TextField(
|
|
||||||
controller: controller.titleController,
|
|
||||||
decoration: InputDecoration.collapsed(
|
|
||||||
hintText: 'fieldPostTitle'.tr(),
|
|
||||||
border: InputBorder.none,
|
|
||||||
),
|
),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
Expanded(
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
child: Column(
|
||||||
).padding(horizontal: 16),
|
children: [
|
||||||
const Gap(8),
|
const Gap(6),
|
||||||
TextField(
|
TextField(
|
||||||
controller: controller.descriptionController,
|
controller: controller.titleController,
|
||||||
decoration: InputDecoration.collapsed(
|
decoration: InputDecoration.collapsed(
|
||||||
hintText: 'fieldPostDescription'.tr(),
|
hintText: 'fieldPostTitle'.tr(),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
maxLines: null,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
keyboardType: TextInputType.multiline,
|
onTapOutside: (_) =>
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
).padding(horizontal: 16),
|
||||||
).padding(horizontal: 16),
|
const Gap(8),
|
||||||
const Gap(12),
|
TextField(
|
||||||
Container(
|
controller: controller.descriptionController,
|
||||||
margin: const EdgeInsets.only(left: 16, right: 16),
|
decoration: InputDecoration.collapsed(
|
||||||
decoration: BoxDecoration(
|
hintText: 'fieldPostDescription'.tr(),
|
||||||
borderRadius: BorderRadius.circular(16),
|
border: InputBorder.none,
|
||||||
border: Border.all(color: Theme.of(context).dividerColor),
|
),
|
||||||
),
|
maxLines: null,
|
||||||
child: ContextMenuRegion(
|
keyboardType: TextInputType.multiline,
|
||||||
contextMenu: ContextMenu(
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
entries: [
|
onTapOutside: (_) =>
|
||||||
MenuItem(
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
label: 'attachmentSetAlt'.tr(),
|
).padding(horizontal: 16),
|
||||||
icon: Symbols.description,
|
const Gap(12),
|
||||||
onSelected: () {
|
Container(
|
||||||
_setAlt(context);
|
margin: const EdgeInsets.only(left: 16, right: 16),
|
||||||
},
|
decoration: BoxDecoration(
|
||||||
),
|
borderRadius: BorderRadius.circular(16),
|
||||||
MenuItem(
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
label: 'attachmentBoost'.tr(),
|
),
|
||||||
icon: Symbols.bolt,
|
child: ContextMenuRegion(
|
||||||
onSelected: () {
|
contextMenu: ContextMenu(
|
||||||
_createBoost(context);
|
entries: [
|
||||||
},
|
MenuItem(
|
||||||
),
|
label: 'attachmentSetAlt'.tr(),
|
||||||
MenuItem(
|
icon: Symbols.description,
|
||||||
label: 'attachmentSetThumbnail'.tr(),
|
onSelected: () {
|
||||||
icon: Symbols.image,
|
_setAlt(context);
|
||||||
onSelected: () {
|
},
|
||||||
_setThumbnail(context);
|
),
|
||||||
},
|
MenuItem(
|
||||||
),
|
label: 'attachmentBoost'.tr(),
|
||||||
MenuItem(
|
icon: Symbols.bolt,
|
||||||
label: 'attachmentCopyRandomId'.tr(),
|
onSelected: () {
|
||||||
icon: Symbols.content_copy,
|
_createBoost(context);
|
||||||
onSelected: () {
|
},
|
||||||
Clipboard.setData(ClipboardData(text: controller.videoAttachment!.rid));
|
),
|
||||||
},
|
MenuItem(
|
||||||
),
|
label: 'attachmentSetThumbnail'.tr(),
|
||||||
MenuItem(
|
icon: Symbols.image,
|
||||||
label: 'delete'.tr(),
|
onSelected: () {
|
||||||
icon: Symbols.delete,
|
_setThumbnail(context);
|
||||||
onSelected: () => _deleteAttachment(context),
|
},
|
||||||
),
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'unlink'.tr(),
|
label: 'attachmentCopyRandomId'.tr(),
|
||||||
icon: Symbols.link_off,
|
icon: Symbols.content_copy,
|
||||||
onSelected: () {
|
onSelected: () {
|
||||||
controller.setVideoAttachment(null);
|
Clipboard.setData(ClipboardData(
|
||||||
},
|
text: controller.videoAttachment!.rid));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MenuItem(
|
||||||
|
label: 'delete'.tr(),
|
||||||
|
icon: Symbols.delete,
|
||||||
|
onSelected: () => _deleteAttachment(context),
|
||||||
|
),
|
||||||
|
MenuItem(
|
||||||
|
label: 'unlink'.tr(),
|
||||||
|
icon: Symbols.link_off,
|
||||||
|
onSelected: () {
|
||||||
|
controller.setVideoAttachment(null);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: controller.videoAttachment == null
|
||||||
|
? () => _selectVideo(context)
|
||||||
|
: null,
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
child: controller.videoAttachment == null
|
||||||
|
? Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.add),
|
||||||
|
const Gap(4),
|
||||||
|
Text('postVideoUpload'.tr()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: AttachmentItem(
|
||||||
|
data: controller.videoAttachment!,
|
||||||
|
heroTag: const Uuid().v4(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
onTap: controller.videoAttachment == null ? () => _selectVideo(context) : null,
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: 16 / 9,
|
|
||||||
child: controller.videoAttachment == null
|
|
||||||
? Center(
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.add),
|
|
||||||
const Gap(4),
|
|
||||||
Text('postVideoUpload'.tr()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: AttachmentItem(
|
|
||||||
data: controller.videoAttachment!,
|
|
||||||
heroTag: const Uuid().v4(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchPosts() async {
|
Future<void> _fetchPosts() async {
|
||||||
if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return;
|
if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty)
|
||||||
|
return;
|
||||||
if (_postCount != null && _posts.length >= _postCount!) return;
|
if (_postCount != null && _posts.length >= _postCount!) return;
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
@@ -152,7 +153,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
separatorBuilder: (_, __) => const Gap(8),
|
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 16,
|
top: 16,
|
||||||
@@ -166,7 +167,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
|||||||
padding: const WidgetStatePropertyAll(
|
padding: const WidgetStatePropertyAll(
|
||||||
EdgeInsets.symmetric(horizontal: 24),
|
EdgeInsets.symmetric(horizontal: 24),
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
_searchTerm = value;
|
_searchTerm = value;
|
||||||
},
|
},
|
||||||
|
|||||||
127
lib/screens/post/post_shuffle.dart
Normal file
127
lib/screens/post/post_shuffle.dart
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_card_swiper/flutter_card_swiper.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/post.dart';
|
||||||
|
import 'package:surface/types/post.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
import 'package:surface/widgets/post/post_item.dart';
|
||||||
|
|
||||||
|
class PostShuffleScreen extends StatefulWidget {
|
||||||
|
const PostShuffleScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PostShuffleScreen> createState() => _PostShuffleScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PostShuffleScreenState extends State<PostShuffleScreen> {
|
||||||
|
late final CardSwiperController _cardController = CardSwiperController();
|
||||||
|
|
||||||
|
bool _isBusy = false;
|
||||||
|
final List<SnPost> _posts = List.empty(growable: true);
|
||||||
|
|
||||||
|
Future<void> _fetchPosts() async {
|
||||||
|
_posts.clear();
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
try {
|
||||||
|
final pt = context.read<SnPostContentProvider>();
|
||||||
|
final result =
|
||||||
|
await pt.listPosts(take: 10, offset: _posts.length, isShuffle: true);
|
||||||
|
_posts.addAll(result.$1);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
_cardController.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
appBar: AppBar(title: Text('postShuffle').tr()),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
if (_isBusy || _posts.isEmpty)
|
||||||
|
const Expanded(
|
||||||
|
child: Center(child: CircularProgressIndicator()))
|
||||||
|
else
|
||||||
|
Expanded(
|
||||||
|
child: CardSwiper(
|
||||||
|
controller: _cardController,
|
||||||
|
isLoop: false,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
cardsCount: _posts.length,
|
||||||
|
cardBuilder: (context, idx, _, __) {
|
||||||
|
final ele = _posts[idx];
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Center(
|
||||||
|
child: Card(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: OpenablePostItem(
|
||||||
|
key: ValueKey(ele),
|
||||||
|
data: ele,
|
||||||
|
maxWidth: 640,
|
||||||
|
onChanged: (ele) {
|
||||||
|
_posts[idx] = ele;
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
onDeleted: () {
|
||||||
|
_fetchPosts();
|
||||||
|
},
|
||||||
|
).padding(all: 8),
|
||||||
|
).padding(
|
||||||
|
all: 24,
|
||||||
|
bottom:
|
||||||
|
MediaQuery.of(context).padding.bottom + 16 + 50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onEnd: () {
|
||||||
|
_fetchPosts();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (!_isBusy && _posts.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
IconButton.filled(
|
||||||
|
icon: const Icon(Symbols.next_plan),
|
||||||
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
|
onPressed: () {
|
||||||
|
_cardController.swipe(CardSwiperDirection.right);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,9 +34,11 @@ class PostPublisherScreen extends StatefulWidget {
|
|||||||
State<PostPublisherScreen> createState() => _PostPublisherScreenState();
|
State<PostPublisherScreen> createState() => _PostPublisherScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTickerProviderStateMixin {
|
class _PostPublisherScreenState extends State<PostPublisherScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
late final ScrollController _scrollController = ScrollController();
|
late final ScrollController _scrollController = ScrollController();
|
||||||
late final TabController _tabController = TabController(length: 3, vsync: this);
|
late final TabController _tabController =
|
||||||
|
TabController(length: 5, vsync: this);
|
||||||
|
|
||||||
SnPublisher? _publisher;
|
SnPublisher? _publisher;
|
||||||
SnAccount? _account;
|
SnAccount? _account;
|
||||||
@@ -66,7 +68,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
_account = await ud.getAccount(_publisher?.accountId);
|
_account = await ud.getAccount(_publisher?.accountId);
|
||||||
_accountRelationship = await rel.getRelationship(_account!.id);
|
_accountRelationship = await rel.getRelationship(_account!.id);
|
||||||
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
|
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
|
||||||
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
|
final resp =
|
||||||
|
await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
|
||||||
_realm = SnRealm.fromJson(resp.data);
|
_realm = SnRealm.fromJson(resp.data);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -133,12 +136,14 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
double _appBarBlur = 0.0;
|
double _appBarBlur = 0.0;
|
||||||
|
|
||||||
late final _appBarWidth = MediaQuery.of(context).size.width;
|
late final _appBarWidth = MediaQuery.of(context).size.width;
|
||||||
late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
|
late final _appBarHeight =
|
||||||
|
math.min((_appBarWidth * kBannerAspectRatio), 360).roundToDouble();
|
||||||
|
|
||||||
void _updateAppBarBlur() {
|
void _updateAppBarBlur() {
|
||||||
if (_scrollController.offset > _appBarHeight) return;
|
if (_scrollController.offset > _appBarHeight) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
|
_appBarBlur =
|
||||||
|
(_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +165,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
type: switch (_tabController.index) {
|
type: switch (_tabController.index) {
|
||||||
1 => 'story',
|
1 => 'story',
|
||||||
2 => 'article',
|
2 => 'article',
|
||||||
|
3 => 'question',
|
||||||
|
4 => 'video',
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -193,7 +200,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
'related': _account!.name,
|
'related': _account!.name,
|
||||||
});
|
});
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
|
context.showSnackbar(
|
||||||
|
'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -209,9 +217,11 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final rel = context.read<SnRelationshipProvider>();
|
final rel = context.read<SnRelationshipProvider>();
|
||||||
await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
|
await rel.updateRelationship(
|
||||||
|
_account!.id, 1, _accountRelationship?.permNodes ?? {});
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
|
context.showSnackbar(
|
||||||
|
'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
@@ -276,6 +286,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
|
noBackground: true,
|
||||||
body: NestedScrollView(
|
body: NestedScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
|
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
|
||||||
@@ -299,7 +310,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
text: TextSpan(children: [
|
text: TextSpan(children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: _publisher!.nick,
|
text: _publisher!.nick,
|
||||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleLarge!
|
||||||
|
.copyWith(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
shadows: labelShadows,
|
shadows: labelShadows,
|
||||||
),
|
),
|
||||||
@@ -307,7 +321,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
const TextSpan(text: '\n'),
|
const TextSpan(text: '\n'),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '@${_publisher!.name}',
|
text: '@${_publisher!.name}',
|
||||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodySmall!
|
||||||
|
.copyWith(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
shadows: labelShadows,
|
shadows: labelShadows,
|
||||||
),
|
),
|
||||||
@@ -330,13 +347,16 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
Container(
|
Container(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainer,
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: 56 + MediaQuery.of(context).padding.top,
|
height:
|
||||||
|
56 + MediaQuery.of(context).padding.top,
|
||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(
|
filter: ImageFilter.blur(
|
||||||
@@ -345,7 +365,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(
|
color: Colors.black.withOpacity(
|
||||||
clampDouble(_appBarBlur * 0.1, 0, 0.5),
|
clampDouble(
|
||||||
|
_appBarBlur * 0.1, 0, 0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -372,11 +393,14 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
const Gap(16),
|
const Gap(16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_publisher!.nick,
|
_publisher!.nick,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium,
|
||||||
).bold(),
|
).bold(),
|
||||||
Text('@${_publisher!.name}').fontSize(13),
|
Text('@${_publisher!.name}').fontSize(13),
|
||||||
],
|
],
|
||||||
@@ -387,7 +411,9 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
style: ButtonStyle(
|
style: ButtonStyle(
|
||||||
elevation: WidgetStatePropertyAll(0),
|
elevation: WidgetStatePropertyAll(0),
|
||||||
),
|
),
|
||||||
onPressed: _isSubscribing ? null : _toggleSubscription,
|
onPressed: _isSubscribing
|
||||||
|
? null
|
||||||
|
: _toggleSubscription,
|
||||||
label: Text('subscribe').tr(),
|
label: Text('subscribe').tr(),
|
||||||
icon: const Icon(Symbols.add),
|
icon: const Icon(Symbols.add),
|
||||||
)
|
)
|
||||||
@@ -396,14 +422,17 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
style: ButtonStyle(
|
style: ButtonStyle(
|
||||||
elevation: WidgetStatePropertyAll(0),
|
elevation: WidgetStatePropertyAll(0),
|
||||||
),
|
),
|
||||||
onPressed: _isSubscribing ? null : _toggleSubscription,
|
onPressed: _isSubscribing
|
||||||
|
? null
|
||||||
|
: _toggleSubscription,
|
||||||
label: Text('unsubscribe').tr(),
|
label: Text('unsubscribe').tr(),
|
||||||
icon: const Icon(Symbols.remove),
|
icon: const Icon(Symbols.remove),
|
||||||
),
|
),
|
||||||
PopupMenuButton(
|
PopupMenuButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
style: ButtonStyle(
|
style: ButtonStyle(
|
||||||
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
visualDensity: VisualDensity(
|
||||||
|
horizontal: -4, vertical: -4),
|
||||||
),
|
),
|
||||||
itemBuilder: (BuildContext context) => [
|
itemBuilder: (BuildContext context) => [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
@@ -443,7 +472,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
Text(_publisher!.description).padding(horizontal: 8),
|
Text(_publisher!.description)
|
||||||
|
.padding(horizontal: 8),
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -451,8 +481,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Symbols.calendar_add_on),
|
const Icon(Symbols.calendar_add_on),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Text('publisherJoinedAt')
|
Text('publisherJoinedAt').tr(args: [
|
||||||
.tr(args: [DateFormat('y/M/d').format(_publisher!.createdAt)]),
|
DateFormat('y/M/d')
|
||||||
|
.format(_publisher!.createdAt)
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
@@ -460,7 +492,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
const Icon(Symbols.trending_up),
|
const Icon(Symbols.trending_up),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Text('publisherSocialPointTotal').plural(
|
Text('publisherSocialPointTotal').plural(
|
||||||
_publisher!.totalUpvote - _publisher!.totalDownvote,
|
_publisher!.totalUpvote -
|
||||||
|
_publisher!.totalDownvote,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -470,18 +503,22 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
const Icon(Symbols.group_work),
|
const Icon(Symbols.group_work),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
InkWell(
|
InkWell(
|
||||||
child: Text('publisherAffiliatedBy').tr(args: [
|
child: Text('publisherAffiliatedBy')
|
||||||
|
.tr(args: [
|
||||||
'@${_realm?.alias ?? 'unknown'}',
|
'@${_realm?.alias ?? 'unknown'}',
|
||||||
]),
|
]),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed(
|
GoRouter.of(context).pushNamed(
|
||||||
'realmDetail',
|
'realmDetail',
|
||||||
pathParameters: {'alias': _realm!.alias},
|
pathParameters: {
|
||||||
|
'alias': _realm!.alias
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
AccountImage(content: _realm?.avatar, radius: 8),
|
AccountImage(
|
||||||
|
content: _realm?.avatar, radius: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
@@ -502,7 +539,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
AccountImage(content: _account?.avatar, radius: 8),
|
AccountImage(
|
||||||
|
content: _account?.avatar, radius: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -533,6 +571,18 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
|||||||
color: Theme.of(context).colorScheme.onSurface,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(
|
||||||
|
Symbols.help,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(
|
||||||
|
Symbols.video_call,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(child: const Divider(height: 1)),
|
SliverToBoxAdapter(child: const Divider(height: 1)),
|
||||||
@@ -606,7 +656,7 @@ class _PublisherPostList extends StatelessWidget {
|
|||||||
onDeleted: onDeleted,
|
onDeleted: onDeleted,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
separatorBuilder: (_, __) => const Gap(8),
|
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
149
lib/screens/realm/community.dart
Normal file
149
lib/screens/realm/community.dart
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/post.dart';
|
||||||
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/screens/post/post_editor.dart';
|
||||||
|
import 'package:surface/types/post.dart';
|
||||||
|
import 'package:surface/types/realm.dart';
|
||||||
|
import 'package:surface/widgets/dialog.dart';
|
||||||
|
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||||
|
import 'package:surface/widgets/post/post_item.dart';
|
||||||
|
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||||
|
|
||||||
|
class RealmCommunityScreen extends StatefulWidget {
|
||||||
|
final String alias;
|
||||||
|
const RealmCommunityScreen({super.key, required this.alias});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RealmCommunityScreen> createState() => _RealmCommunityScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RealmCommunityScreenState extends State<RealmCommunityScreen> {
|
||||||
|
SnRealm? _realm;
|
||||||
|
|
||||||
|
Future<void> _fetchRealm() async {
|
||||||
|
try {
|
||||||
|
final sn = context.read<SnNetworkProvider>();
|
||||||
|
final resp = await sn.client.get('/cgi/id/realms/${widget.alias}');
|
||||||
|
_realm = SnRealm.fromJson(resp.data);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isBusy = false;
|
||||||
|
int? _totalCount;
|
||||||
|
final List<SnPost> _posts = List.empty(growable: true);
|
||||||
|
|
||||||
|
Future<void> _fetchPosts() async {
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final pt = context.read<SnPostContentProvider>();
|
||||||
|
final out = await pt.listPosts(
|
||||||
|
take: 10,
|
||||||
|
offset: _posts.length,
|
||||||
|
realm: _realm?.id.toString(),
|
||||||
|
);
|
||||||
|
_totalCount = out.$2;
|
||||||
|
_posts.addAll(out.$1);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.showErrorDialog(err);
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchRealm();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppScaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_realm?.name ?? 'loading'.tr()),
|
||||||
|
),
|
||||||
|
floatingActionButton: _realm != null
|
||||||
|
? FloatingActionButton(
|
||||||
|
child: const Icon(Symbols.edit),
|
||||||
|
onPressed: () {
|
||||||
|
GoRouter.of(context).pushNamed(
|
||||||
|
'postEditor',
|
||||||
|
extra: PostEditorExtra(realm: _realm!),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_realm == null)
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator().center(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_realm != null)
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('realmCommunity'.tr(args: [_realm!.name]))
|
||||||
|
.fontSize(17)
|
||||||
|
.padding(horizontal: 20, bottom: 4),
|
||||||
|
Text('postTotalCount'.plural(_totalCount ?? 0))
|
||||||
|
.fontSize(13)
|
||||||
|
.opacity(0.8)
|
||||||
|
.padding(horizontal: 20, bottom: 4),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 20, vertical: 16),
|
||||||
|
const Divider(height: 1),
|
||||||
|
if (_realm != null)
|
||||||
|
Expanded(
|
||||||
|
child: MediaQuery.removePadding(
|
||||||
|
context: context,
|
||||||
|
removeTop: true,
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _fetchPosts,
|
||||||
|
child: InfiniteList(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
itemCount: _posts.length,
|
||||||
|
isLoading: _isBusy,
|
||||||
|
hasReachedMax:
|
||||||
|
_totalCount != null && _posts.length >= _totalCount!,
|
||||||
|
onFetchData: _fetchPosts,
|
||||||
|
itemBuilder: (context, idx) {
|
||||||
|
final post = _posts[idx];
|
||||||
|
return OpenablePostItem(
|
||||||
|
data: post,
|
||||||
|
maxWidth: 640,
|
||||||
|
onChanged: (data) {
|
||||||
|
setState(() => _posts[idx] = data);
|
||||||
|
},
|
||||||
|
onDeleted: () {
|
||||||
|
setState(() => _posts.removeAt(idx));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
const Divider().padding(vertical: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
|||||||
Future<void> _fetchPublishers() async {
|
Future<void> _fetchPublishers() async {
|
||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/co/publishers?realm=${widget.alias}');
|
final resp =
|
||||||
|
await sn.client.get('/cgi/co/publishers?realm=${widget.alias}');
|
||||||
_publishers = List<SnPublisher>.from(
|
_publishers = List<SnPublisher>.from(
|
||||||
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
|
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
|
||||||
);
|
);
|
||||||
@@ -68,7 +69,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
|||||||
Future<void> _fetchChannels() async {
|
Future<void> _fetchChannels() async {
|
||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/im/channels/${widget.alias}/public');
|
final resp =
|
||||||
|
await sn.client.get('/cgi/im/channels/${widget.alias}/public');
|
||||||
_channels = List<SnChannel>.from(
|
_channels = List<SnChannel>.from(
|
||||||
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
|
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
|
||||||
);
|
);
|
||||||
@@ -98,15 +100,32 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
|||||||
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
|
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
|
||||||
return <Widget>[
|
return <Widget>[
|
||||||
SliverOverlapAbsorber(
|
SliverOverlapAbsorber(
|
||||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
handle:
|
||||||
|
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||||
sliver: SliverAppBar(
|
sliver: SliverAppBar(
|
||||||
title: Text(_realm?.name ?? 'loading'.tr()),
|
title: Text(_realm?.name ?? 'loading'.tr()),
|
||||||
bottom: TabBar(
|
bottom: TabBar(
|
||||||
tabs: [
|
tabs: [
|
||||||
Tab(icon: Icon(Symbols.home, color: Theme.of(context).appBarTheme.foregroundColor)),
|
Tab(
|
||||||
Tab(icon: Icon(Symbols.explore, color: Theme.of(context).appBarTheme.foregroundColor)),
|
icon: Icon(Symbols.home,
|
||||||
Tab(icon: Icon(Symbols.group, color: Theme.of(context).appBarTheme.foregroundColor)),
|
color: Theme.of(context)
|
||||||
Tab(icon: Icon(Symbols.settings, color: Theme.of(context).appBarTheme.foregroundColor)),
|
.appBarTheme
|
||||||
|
.foregroundColor)),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Symbols.explore,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.appBarTheme
|
||||||
|
.foregroundColor)),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Symbols.group,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.appBarTheme
|
||||||
|
.foregroundColor)),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Symbols.settings,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.appBarTheme
|
||||||
|
.foregroundColor)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -115,7 +134,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
|||||||
},
|
},
|
||||||
body: TabBarView(
|
body: TabBarView(
|
||||||
children: [
|
children: [
|
||||||
_RealmDetailHomeWidget(realm: _realm, publishers: _publishers, channels: _channels),
|
_RealmDetailHomeWidget(
|
||||||
|
realm: _realm, publishers: _publishers, channels: _channels),
|
||||||
_RealmPostListWidget(realm: _realm),
|
_RealmPostListWidget(realm: _realm),
|
||||||
_RealmMemberListWidget(realm: _realm),
|
_RealmMemberListWidget(realm: _realm),
|
||||||
_RealmSettingsWidget(
|
_RealmSettingsWidget(
|
||||||
@@ -137,7 +157,8 @@ class _RealmDetailHomeWidget extends StatelessWidget {
|
|||||||
final List<SnPublisher>? publishers;
|
final List<SnPublisher>? publishers;
|
||||||
final List<SnChannel>? channels;
|
final List<SnChannel>? channels;
|
||||||
|
|
||||||
const _RealmDetailHomeWidget({required this.realm, this.publishers, this.channels});
|
const _RealmDetailHomeWidget(
|
||||||
|
{required this.realm, this.publishers, this.channels});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -168,7 +189,8 @@ class _RealmDetailHomeWidget extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
child: Text('realmCommunityPublishersHint'.tr(), style: Theme.of(context).textTheme.bodyMedium)
|
child: Text('realmCommunityPublishersHint'.tr(),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium)
|
||||||
.padding(horizontal: 24, vertical: 8),
|
.padding(horizontal: 24, vertical: 8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -199,7 +221,8 @@ class _RealmDetailHomeWidget extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium)
|
child: Text('realmCommunityPublicChannelsHint'.tr(),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium)
|
||||||
.padding(horizontal: 24, vertical: 8),
|
.padding(horizontal: 24, vertical: 8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -295,7 +318,7 @@ class _RealmPostListWidgetState extends State<_RealmPostListWidget> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
separatorBuilder: (_, __) => const Gap(8),
|
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).padding(top: 8);
|
).padding(top: 8);
|
||||||
@@ -323,10 +346,12 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
|
|||||||
try {
|
try {
|
||||||
final ud = context.read<UserDirectoryProvider>();
|
final ud = context.read<UserDirectoryProvider>();
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: {
|
final resp = await sn.client.get(
|
||||||
'take': 10,
|
'/cgi/id/realms/${widget.realm!.alias}/members',
|
||||||
'offset': _members.length,
|
queryParameters: {
|
||||||
});
|
'take': 10,
|
||||||
|
'offset': _members.length,
|
||||||
|
});
|
||||||
|
|
||||||
final out = List<SnRealmMember>.from(
|
final out = List<SnRealmMember>.from(
|
||||||
resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [],
|
resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [],
|
||||||
@@ -432,14 +457,14 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.only(right: 24, left: 16),
|
contentPadding: const EdgeInsets.only(right: 24, left: 16),
|
||||||
leading: AccountImage(
|
leading: AccountImage(
|
||||||
content: ud.getAccountFromCache(member.accountId)?.avatar,
|
content: ud.getFromCache(member.accountId)?.avatar,
|
||||||
fallbackWidget: const Icon(Symbols.group, size: 24),
|
fallbackWidget: const Icon(Symbols.group, size: 24),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
ud.getAccountFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
|
ud.getFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
|
ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(Symbols.person_remove),
|
icon: const Icon(Symbols.person_remove),
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import 'package:gap/gap.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:surface/providers/channel.dart';
|
||||||
import 'package:surface/providers/config.dart';
|
import 'package:surface/providers/config.dart';
|
||||||
import 'package:surface/providers/sn_network.dart';
|
import 'package:surface/providers/sn_network.dart';
|
||||||
|
import 'package:surface/providers/sn_realm.dart';
|
||||||
import 'package:surface/providers/userinfo.dart';
|
import 'package:surface/providers/userinfo.dart';
|
||||||
import 'package:surface/types/chat.dart';
|
import 'package:surface/types/chat.dart';
|
||||||
import 'package:surface/types/realm.dart';
|
import 'package:surface/types/realm.dart';
|
||||||
@@ -57,7 +59,9 @@ class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
|
|||||||
title: Text('screenRealmDiscovery').tr(),
|
title: Text('screenRealmDiscovery').tr(),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: _isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
|
icon: _isCompactView
|
||||||
|
? const Icon(Symbols.view_list)
|
||||||
|
: const Icon(Symbols.view_module),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() => _isCompactView = !_isCompactView);
|
setState(() => _isCompactView = !_isCompactView);
|
||||||
context.read<ConfigProvider>().realmCompactView = _isCompactView;
|
context.read<ConfigProvider>().realmCompactView = _isCompactView;
|
||||||
@@ -117,7 +121,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
|
|||||||
try {
|
try {
|
||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final resp = await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public');
|
final resp =
|
||||||
|
await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public');
|
||||||
final out = List<SnChannel>.from(
|
final out = List<SnChannel>.from(
|
||||||
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
|
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
|
||||||
);
|
);
|
||||||
@@ -135,10 +140,13 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
|
|||||||
setState(() => _isJoining = true);
|
setState(() => _isJoining = true);
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final ua = context.read<UserProvider>();
|
final ua = context.read<UserProvider>();
|
||||||
await sn.client.post('/cgi/id/realms/${widget.realm.alias}/members', data: {
|
final rel = context.read<SnRealmProvider>();
|
||||||
|
await sn.client
|
||||||
|
.post('/cgi/id/realms/${widget.realm.alias}/members', data: {
|
||||||
'related': ua.user?.name,
|
'related': ua.user?.name,
|
||||||
});
|
});
|
||||||
await _joinSelectedChannels();
|
await _joinSelectedChannels();
|
||||||
|
rel.addAvailableRealm(widget.realm);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showSnackbar('realmJoined'.tr(args: [widget.realm.name]));
|
context.showSnackbar('realmJoined'.tr(args: [widget.realm.name]));
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -156,13 +164,20 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
|
|||||||
try {
|
try {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final ua = context.read<UserProvider>();
|
final ua = context.read<UserProvider>();
|
||||||
await sn.client.post('/cgi/im/channels/${widget.realm.alias}/$channel/members', data: {
|
await sn.client.post(
|
||||||
'related': ua.user?.name,
|
'/cgi/im/channels/${widget.realm.alias}/$channel/members',
|
||||||
});
|
data: {
|
||||||
|
'related': ua.user?.name,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showErrorDialog(err);
|
context.showErrorDialog(err);
|
||||||
}
|
}
|
||||||
|
final ct = context.read<ChatChannelProvider>();
|
||||||
|
for (final channel
|
||||||
|
in _channels!.where((ele) => _planJoinChannels.contains(ele.alias))) {
|
||||||
|
ct.addAvailableChannel(channel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +197,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Symbols.group_add, size: 24),
|
const Icon(Symbols.group_add, size: 24),
|
||||||
const Gap(16),
|
const Gap(16),
|
||||||
Text('realmJoin', style: Theme.of(context).textTheme.titleLarge).tr(),
|
Text('realmJoin', style: Theme.of(context).textTheme.titleLarge)
|
||||||
|
.tr(),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||||
Row(
|
Row(
|
||||||
@@ -216,7 +232,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
|
|||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium)
|
child: Text('realmCommunityPublicChannelsHint'.tr(),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium)
|
||||||
.padding(horizontal: 24, vertical: 8),
|
.padding(horizontal: 24, vertical: 8),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
@@ -48,6 +49,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
late final SharedPreferences _prefs;
|
late final SharedPreferences _prefs;
|
||||||
String _docBasepath = '/';
|
String _docBasepath = '/';
|
||||||
|
|
||||||
|
final TextEditingController _customFontController = TextEditingController();
|
||||||
final TextEditingController _serverUrlController = TextEditingController();
|
final TextEditingController _serverUrlController = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -62,11 +64,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final config = context.read<ConfigProvider>();
|
final config = context.read<ConfigProvider>();
|
||||||
_prefs = config.prefs;
|
_prefs = config.prefs;
|
||||||
_serverUrlController.text = config.serverUrl;
|
_serverUrlController.text = config.serverUrl;
|
||||||
|
if (_prefs.getString(kAppCustomFonts) != null) {
|
||||||
|
_customFontController.text = _prefs.getString(kAppCustomFonts) ?? '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_serverUrlController.dispose();
|
_serverUrlController.dispose();
|
||||||
|
_customFontController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +80,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sn = context.read<SnNetworkProvider>();
|
final sn = context.read<SnNetworkProvider>();
|
||||||
final dt = context.read<DatabaseProvider>();
|
final dt = context.read<DatabaseProvider>();
|
||||||
|
final cfg = context.watch<ConfigProvider>();
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -330,6 +339,85 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
secondary: const Icon(Symbols.hide),
|
||||||
|
title: Text('settingsHideBottomNav').tr(),
|
||||||
|
subtitle: Text('settingsHideBottomNavDescription').tr(),
|
||||||
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
|
value: _prefs.getBool(kAppHideBottomNav) ?? false,
|
||||||
|
onChanged: (value) {
|
||||||
|
_prefs.setBool(kAppHideBottomNav, value ?? false);
|
||||||
|
final cfg = context.read<ConfigProvider>();
|
||||||
|
cfg.calcDrawerSize(context);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: cfg.soundEffects,
|
||||||
|
onChanged: (value) {
|
||||||
|
cfg.soundEffects = value ?? false;
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
|
title: Text('settingsSoundEffects').tr(),
|
||||||
|
subtitle: Text('settingsSoundEffectsDescription').tr(),
|
||||||
|
secondary: const Icon(Symbols.sound_sampler),
|
||||||
|
),
|
||||||
|
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS))
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Symbols.window),
|
||||||
|
title: Text('settingsResetMemorizedWindowSize').tr(),
|
||||||
|
subtitle:
|
||||||
|
Text('settingsResetMemorizedWindowSizeDescription')
|
||||||
|
.tr(),
|
||||||
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
|
contentPadding: const EdgeInsets.only(left: 24, right: 24),
|
||||||
|
onTap: () {
|
||||||
|
final prefs = context.read<ConfigProvider>().prefs;
|
||||||
|
prefs.remove(kAppWindowSize);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Symbols.font_download),
|
||||||
|
title: Text('settingsCustomFonts').tr(),
|
||||||
|
subtitle: Text('settingsCustomFontsDescription').tr(),
|
||||||
|
contentPadding: const EdgeInsets.only(left: 24, right: 14),
|
||||||
|
trailing: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_prefs.remove(kAppCustomFonts);
|
||||||
|
context.showSnackbar('settingsCustomFontApplied'.tr());
|
||||||
|
final theme = context.read<ThemeProvider>();
|
||||||
|
_customFontController.clear();
|
||||||
|
theme.reloadTheme();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: _customFontController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: 'settingsCustomFontFamily'.tr(),
|
||||||
|
helperText: 'settingsCustomFontFamilyHint'.tr(),
|
||||||
|
prefixIcon: const Icon(Symbols.format_paint),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Symbols.save),
|
||||||
|
onPressed: () {
|
||||||
|
_prefs.setString(
|
||||||
|
kAppCustomFonts,
|
||||||
|
_customFontController.text,
|
||||||
|
);
|
||||||
|
context.showSnackbar('settingsCustomFontApplied'.tr());
|
||||||
|
final theme = context.read<ThemeProvider>();
|
||||||
|
theme.reloadTheme();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
).padding(horizontal: 16, top: 8, bottom: 4),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Column(
|
Column(
|
||||||
@@ -340,6 +428,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
.fontSize(17)
|
.fontSize(17)
|
||||||
.tr()
|
.tr()
|
||||||
.padding(horizontal: 20, bottom: 4),
|
.padding(horizontal: 20, bottom: 4),
|
||||||
|
CheckboxListTile(
|
||||||
|
secondary: const Icon(Symbols.translate),
|
||||||
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
|
title: Text('settingsAutoTranslate').tr(),
|
||||||
|
subtitle: Text('settingsAutoTranslateDescription').tr(),
|
||||||
|
value: _prefs.getBool(kAppAutoTranslate) ?? false,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_prefs.setBool(kAppAutoTranslate, value ?? false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
secondary: const Icon(Symbols.vibration),
|
secondary: const Icon(Symbols.vibration),
|
||||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
@@ -534,6 +634,37 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
.fontSize(17)
|
.fontSize(17)
|
||||||
.tr()
|
.tr()
|
||||||
.padding(horizontal: 20, bottom: 4),
|
.padding(horizontal: 20, bottom: 4),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Symbols.home_storage),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
title: Text('cacheSize').tr(),
|
||||||
|
subtitle: FutureBuilder(
|
||||||
|
future: DefaultCacheManager().store.getCacheSize(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData || kIsWeb) {
|
||||||
|
return Text('unknown').tr();
|
||||||
|
}
|
||||||
|
return Text(
|
||||||
|
snapshot.data!.formatBytes(),
|
||||||
|
style: GoogleFonts.robotoMono(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Symbols.cleaning_services),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
title: Text('cacheDelete').tr(),
|
||||||
|
subtitle: Text('cacheDeleteDescription').tr(),
|
||||||
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
|
onTap: () async {
|
||||||
|
await DefaultCacheManager().emptyCache();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
context.showSnackbar('cacheDeleted'.tr());
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Symbols.database),
|
leading: const Icon(Symbols.database),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
@@ -618,6 +749,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text('runtimeLogsOpen').tr(),
|
||||||
|
subtitle: Text('runtimeLogsDescription').tr(),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Symbols.receipt_long),
|
||||||
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
|
onTap: () async {
|
||||||
|
GoRouter.of(context).pushNamed('debugLogging');
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('settingsMiscAbout').tr(),
|
title: Text('settingsMiscAbout').tr(),
|
||||||
subtitle: Text('settingsMiscAboutDescription').tr(),
|
subtitle: Text('settingsMiscAboutDescription').tr(),
|
||||||
@@ -628,6 +769,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
GoRouter.of(context).pushNamed('about');
|
GoRouter.of(context).pushNamed('about');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (now.day == 1 && now.month == 4)
|
||||||
|
CheckboxListTile(
|
||||||
|
title: Text('settingsAprilFoolFeatures').tr(),
|
||||||
|
subtitle: Text('settingsAprilFoolFeaturesDescription').tr(),
|
||||||
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
|
secondary: const Icon(Symbols.new_releases),
|
||||||
|
value: cfg.aprilFoolFeatures,
|
||||||
|
onChanged: (value) {
|
||||||
|
cfg.aprilFoolFeatures = value ?? false;
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -50,27 +50,37 @@ class _AppSharingListenerState extends State<AppSharingListener> {
|
|||||||
Card(
|
Card(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
const SizedBox(width: double.infinity),
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
contentPadding:
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8)),
|
||||||
leading: Icon(Icons.post_add),
|
leading: Icon(Icons.post_add),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
title: Text('shareIntentPostStory').tr(),
|
title: Text('shareIntentPostStory').tr(),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed(
|
GoRouter.of(context).pushNamed(
|
||||||
'postEditor',
|
'postEditor',
|
||||||
pathParameters: {
|
queryParameters: {
|
||||||
'mode': 'stories',
|
'mode': 'stories',
|
||||||
},
|
},
|
||||||
extra: PostEditorExtra(
|
extra: PostEditorExtra(
|
||||||
text: value
|
text: value
|
||||||
.where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
|
.where((e) => [
|
||||||
|
SharedMediaType.text,
|
||||||
|
SharedMediaType.url
|
||||||
|
].contains(e.type))
|
||||||
.map((e) => e.path)
|
.map((e) => e.path)
|
||||||
.join('\n'),
|
.join('\n'),
|
||||||
attachments: value
|
attachments: value
|
||||||
.where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image]
|
.where((e) => [
|
||||||
.contains(e.type))
|
SharedMediaType.video,
|
||||||
.map((e) => PostWriteMedia.fromFile(XFile(e.path)))
|
SharedMediaType.file,
|
||||||
|
SharedMediaType.image
|
||||||
|
].contains(e.type))
|
||||||
|
.map((e) =>
|
||||||
|
PostWriteMedia.fromFile(XFile(e.path)))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -78,15 +88,18 @@ class _AppSharingListenerState extends State<AppSharingListener> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
contentPadding:
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8)),
|
||||||
leading: Icon(Icons.chat_outlined),
|
leading: Icon(Icons.chat_outlined),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
title: Text('shareIntentSendChannel').tr(),
|
title: Text('shareIntentSendChannel').tr(),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => _ShareIntentChannelSelect(value: value),
|
builder: (context) =>
|
||||||
|
_ShareIntentChannelSelect(value: value),
|
||||||
).then((val) {
|
).then((val) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (val == true) Navigator.pop(context);
|
if (val == true) Navigator.pop(context);
|
||||||
@@ -110,7 +123,8 @@ class _AppSharingListenerState extends State<AppSharingListener> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _initialize() async {
|
void _initialize() async {
|
||||||
_shareIntentSubscription = ReceiveSharingIntent.instance.getMediaStream().listen((value) {
|
_shareIntentSubscription =
|
||||||
|
ReceiveSharingIntent.instance.getMediaStream().listen((value) {
|
||||||
if (value.isEmpty) return;
|
if (value.isEmpty) return;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_gotoPost(value);
|
_gotoPost(value);
|
||||||
@@ -157,7 +171,8 @@ class _ShareIntentChannelSelect extends StatefulWidget {
|
|||||||
const _ShareIntentChannelSelect({required this.value});
|
const _ShareIntentChannelSelect({required this.value});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState();
|
State<_ShareIntentChannelSelect> createState() =>
|
||||||
|
_ShareIntentChannelSelectState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
||||||
@@ -178,8 +193,11 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
|||||||
final lastMessages = await chan.getLastMessages(channels);
|
final lastMessages = await chan.getLastMessages(channels);
|
||||||
_lastMessages = {for (final val in lastMessages) val.channelId: val};
|
_lastMessages = {for (final val in lastMessages) val.channelId: val};
|
||||||
channels.sort((a, b) {
|
channels.sort((a, b) {
|
||||||
if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
|
if (_lastMessages!.containsKey(a.id) &&
|
||||||
return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
|
_lastMessages!.containsKey(b.id)) {
|
||||||
|
return _lastMessages![b.id]!
|
||||||
|
.createdAt
|
||||||
|
.compareTo(_lastMessages![a.id]!.createdAt);
|
||||||
}
|
}
|
||||||
if (_lastMessages!.containsKey(a.id)) return -1;
|
if (_lastMessages!.containsKey(a.id)) return -1;
|
||||||
if (_lastMessages!.containsKey(b.id)) return 1;
|
if (_lastMessages!.containsKey(b.id)) return 1;
|
||||||
@@ -232,7 +250,9 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
|||||||
children: [
|
children: [
|
||||||
const Icon(Symbols.chat, size: 24),
|
const Icon(Symbols.chat, size: 24),
|
||||||
const Gap(16),
|
const Gap(16),
|
||||||
Text('shareIntentSendChannel', style: Theme.of(context).textTheme.titleLarge).tr(),
|
Text('shareIntentSendChannel',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge)
|
||||||
|
.tr(),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||||
LoadingIndicator(isActive: _isBusy),
|
LoadingIndicator(isActive: _isBusy),
|
||||||
@@ -249,29 +269,34 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
|||||||
final lastMessage = _lastMessages?[channel.id];
|
final lastMessage = _lastMessages?[channel.id];
|
||||||
|
|
||||||
if (channel.type == 1) {
|
if (channel.type == 1) {
|
||||||
final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
|
final otherMember =
|
||||||
(ele) => ele?.accountId != ua.user?.id,
|
channel.members?.cast<SnChannelMember?>().firstWhere(
|
||||||
orElse: () => null,
|
(ele) => ele?.accountId != ua.user?.id,
|
||||||
);
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
|
title: Text(
|
||||||
|
ud.getFromCache(otherMember?.accountId)?.nick ??
|
||||||
|
channel.name),
|
||||||
subtitle: lastMessage != null
|
subtitle: lastMessage != null
|
||||||
? Text(
|
? Text(
|
||||||
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
|
'${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
'channelDirectMessageDescription'.tr(args: [
|
'channelDirectMessageDescription'.tr(args: [
|
||||||
'@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
|
'@${ud.getFromCache(otherMember?.accountId)?.name}',
|
||||||
]),
|
]),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16),
|
||||||
leading: AccountImage(
|
leading: AccountImage(
|
||||||
content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
|
content:
|
||||||
|
ud.getFromCache(otherMember?.accountId)?.avatar,
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed(
|
GoRouter.of(context).pushNamed(
|
||||||
@@ -291,7 +316,7 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
|||||||
title: Text(channel.name),
|
title: Text(channel.name),
|
||||||
subtitle: lastMessage != null
|
subtitle: lastMessage != null
|
||||||
? Text(
|
? Text(
|
||||||
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
|
'${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
)
|
)
|
||||||
@@ -316,13 +341,20 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
|||||||
},
|
},
|
||||||
extra: ChatRoomScreenExtra(
|
extra: ChatRoomScreenExtra(
|
||||||
initialText: widget.value
|
initialText: widget.value
|
||||||
.where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
|
.where((e) => [
|
||||||
|
SharedMediaType.text,
|
||||||
|
SharedMediaType.url
|
||||||
|
].contains(e.type))
|
||||||
.map((e) => e.path)
|
.map((e) => e.path)
|
||||||
.join('\n'),
|
.join('\n'),
|
||||||
initialAttachments: widget.value
|
initialAttachments: widget.value
|
||||||
.where((e) =>
|
.where((e) => [
|
||||||
[SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
|
SharedMediaType.video,
|
||||||
.map((e) => PostWriteMedia.fromFile(XFile(e.path)))
|
SharedMediaType.file,
|
||||||
|
SharedMediaType.image
|
||||||
|
].contains(e.type))
|
||||||
|
.map(
|
||||||
|
(e) => PostWriteMedia.fromFile(XFile(e.path)))
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import 'package:surface/providers/sn_network.dart';
|
|||||||
import 'package:surface/providers/sn_sticker.dart';
|
import 'package:surface/providers/sn_sticker.dart';
|
||||||
import 'package:surface/providers/userinfo.dart';
|
import 'package:surface/providers/userinfo.dart';
|
||||||
import 'package:surface/types/attachment.dart';
|
import 'package:surface/types/attachment.dart';
|
||||||
import 'package:surface/widgets/app_bar_leading.dart';
|
|
||||||
import 'package:surface/widgets/attachment/attachment_item.dart';
|
import 'package:surface/widgets/attachment/attachment_item.dart';
|
||||||
import 'package:surface/widgets/dialog.dart';
|
import 'package:surface/widgets/dialog.dart';
|
||||||
import 'package:surface/widgets/loading_indicator.dart';
|
import 'package:surface/widgets/loading_indicator.dart';
|
||||||
@@ -134,7 +133,7 @@ class _StickerScreenState extends State<StickerScreen>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: AutoAppBarLeading(),
|
leading: PageBackButton(),
|
||||||
title: Text('screenStickers').tr(),
|
title: Text('screenStickers').tr(),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -179,7 +178,9 @@ class _StickerScreenState extends State<StickerScreen>
|
|||||||
child: InfiniteList(
|
child: InfiniteList(
|
||||||
itemCount: _packs.length,
|
itemCount: _packs.length,
|
||||||
onFetchData: _fetchPacks,
|
onFetchData: _fetchPacks,
|
||||||
hasReachedMax: _totalCount != null && _packs.length >= _totalCount!,
|
hasReachedMax:
|
||||||
|
(_totalCount != null && _packs.length >= _totalCount!) ||
|
||||||
|
_tabController.index == 2,
|
||||||
isLoading: _isBusy,
|
isLoading: _isBusy,
|
||||||
itemBuilder: (context, idx) {
|
itemBuilder: (context, idx) {
|
||||||
final pack = _packs[idx];
|
final pack = _packs[idx];
|
||||||
@@ -282,7 +283,10 @@ class _StickerPackAddPopupState extends State<_StickerPackAddPopup> {
|
|||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.showSnackbar('stickersAdded'.tr());
|
context.showSnackbar('stickersAdded'.tr());
|
||||||
if (_pack?.stickers != null) stickers.putSticker(_pack!.stickers!);
|
if (_pack?.stickers != null) {
|
||||||
|
stickers.putSticker(
|
||||||
|
_pack!.stickers!.map((ele) => ele.copyWith(pack: _pack!)));
|
||||||
|
}
|
||||||
Navigator.pop(context, true);
|
Navigator.pop(context, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user