Compare commits

..

113 Commits

Author SHA1 Message Date
b492db90ca 🚀 Launch Feature Drop 2.4.2+80 2025-03-16 23:27:52 +08:00
c9f69fed2c Complete translation 2025-03-16 23:24:36 +08:00
d2f4e7a969 Message translation 2025-03-16 23:10:59 +08:00
aecd04e0b9 Translate infra & post translation 2025-03-16 23:05:07 +08:00
e5212419ae 🐛 Fix poll percentage background 2025-03-16 22:13:19 +08:00
ec7650a920 💄 Optimize nesting 2025-03-16 22:11:40 +08:00
7b96013406 Better(?) comment nesting 2025-03-16 21:41:38 +08:00
fc5a79b29b Blurry attachment background 2025-03-16 19:34:42 +08:00
4146820be5 🐛 Bug fixes 2025-03-16 19:24:21 +08:00
9ec0f1ff19 💄 Redesigned post item 2025-03-16 18:56:08 +08:00
ac2aec48aa Allow to delete contact methods 2025-03-16 11:55:03 +08:00
58421e5d5e Basic contact methods 2025-03-16 01:39:05 +08:00
172d0d24fb 🐛 Fix unconfirmed indicator display logic 2025-03-15 23:17:03 +08:00
71899dd4f2 Empty sticker picker placeholder 2025-03-15 23:07:46 +08:00
02ffe9866d Unconfirmed account indicator 2025-03-15 22:53:27 +08:00
1b7e668b3f Dispose current session when logout 2025-03-15 21:11:49 +08:00
f03d80ba88 Auth tickets management 2025-03-15 20:27:14 +08:00
14ee6845ed Action events 2025-03-15 19:28:37 +08:00
8fe6c2be46 Upgrade deps and add flutter map 2025-03-15 18:37:00 +08:00
78e765f69d 🐛 Bug fixes 2025-03-15 15:44:56 +08:00
ddd6ff7eee Service status on home
🗑️ Remove news from home
2025-03-15 15:38:50 +08:00
b8f379796f News in feed 2025-03-15 14:53:42 +08:00
3a10e9280c 💄 Optimize news rendering 2025-03-13 23:04:34 +08:00
65fe06de22 ♻️ Optimized fediverse post displaying 2025-03-13 22:26:35 +08:00
e44320e0fe Basic fediverse posts displaying 2025-03-13 00:09:28 +08:00
f2d913ffec 🐛 Fix un-centered text 2025-03-10 21:37:58 +08:00
e88dea8858 MacOS menubar 2025-03-10 21:35:33 +08:00
813679b161 🐛 Bug fixes on post editor 2025-03-10 21:02:18 +08:00
9d4ce6ca8c 🚀 Launch bug hotfix +79 2025-03-09 15:22:25 +08:00
88396647f3 🚀 Launch 2.4.2+78 Feature Drop 2025-03-09 14:04:18 +08:00
335318ae3f Status system 2025-03-09 14:00:35 +08:00
da25fb9c29 🐛 Fix user cache 2025-03-09 13:03:57 +08:00
c1aef89b84 ♻️ Refactor account badge showing 2025-03-09 12:57:53 +08:00
0241c5f804 Check in streak 2025-03-09 12:41:34 +08:00
f6939d7c23 💄 Adjust icon size 2025-03-09 01:31:31 +08:00
d654c162e3 Shuffle post 2025-03-09 00:49:13 +08:00
25550ba197 💄 Changes to the showing of realm post 2025-03-09 00:11:01 +08:00
3defd3a593 💄 Modify explore appbar 2025-03-08 23:51:22 +08:00
d62ed4c375 Adjust explore categorized mode 2025-03-08 22:40:17 +08:00
857f3cc832 Post drafts 2025-03-08 22:32:38 +08:00
e16bc80eea 🐛 Fix attachments 2025-03-08 19:19:06 +08:00
a4f6e8af56 ♻️ New post explore realm design 2025-03-08 18:43:58 +08:00
060a97f5ec ♻️ Refactored explore screen 2025-03-08 18:19:57 +08:00
92f7e92018 🐛 Bug fixes due to post editor changes 2025-03-08 16:04:51 +08:00
5c483bd3b8 ♻️ Move the post editor mode into editor itself 2025-03-08 16:00:10 +08:00
1c510d63fe 🐛 Fix share via image errored 2025-03-06 22:46:02 +08:00
115cb4adc1 💄 Redesigned attachment zoom view 2025-03-06 22:35:06 +08:00
54c098c274 🍱 Update assets
 Optimize loading of web version in some regions
2025-03-05 22:23:42 +08:00
29731728cd 🚀 Launch 2.4.2+76 2025-03-05 00:43:50 +08:00
9e8882c580 Complete profile page 2025-03-05 00:21:25 +08:00
6042e57e7a 🐛 Fix orientation inconsistences 2025-03-05 00:00:11 +08:00
6235e736b9 Sticker cache 2025-03-04 23:56:39 +08:00
e075804782 🐛 Bug fixes on channel member cache 2025-03-04 23:39:55 +08:00
d40a6ca1c4 User channel profile cache 2025-03-04 23:35:28 +08:00
5ac657e526 Attachment local cache 2025-03-04 23:13:43 +08:00
97ddc18b8e 🗃️ Add expired to cache
 Add sticker cache
2025-03-04 22:56:43 +08:00
b835c8edea 💄 Optimize badges list screen 2025-03-04 22:33:56 +08:00
288c0399f9 User cache 2025-03-04 22:30:17 +08:00
1478933cf1 🐛 Fix editing message mock issue 2025-03-04 21:59:18 +08:00
93c6fa6e53 🗃️ Add more cache ability to local database 2025-03-04 21:49:24 +08:00
ce6e9c185a ♻️ Refactor channel list
💄 Stop previewing encrypted message raw message
2025-03-04 21:34:28 +08:00
cdaa8cfe58 🐛 Fix loading indicator not hiding on first time load 2025-03-04 21:20:54 +08:00
76d8cd943d 💄 Optimize de/encrypting animations 2025-03-04 21:17:17 +08:00
d6f3ffc655 Functional key exchange 2025-03-04 21:08:40 +08:00
5a6b841253 Sending encrypted message 2025-03-03 23:56:45 +08:00
cb2de52bee Key pairs 2025-03-03 23:04:02 +08:00
64e2644745 Keypair Infra 2025-03-03 22:25:59 +08:00
56711889ab 🗃️ Local keypair db 2025-03-03 21:31:41 +08:00
4f47cd2c0c 💄 Optimize chat style 2025-03-03 21:13:26 +08:00
2b61c372f5 Allow profile picture (avatar & banner) upload gif 2025-03-03 20:53:42 +08:00
73777fe74e 💄 Optimize attachment view 2025-03-02 22:53:14 +08:00
33a4bd7e71 🐛 Bug fixes 2025-03-02 21:56:45 +08:00
17e6b81f76 Show badge in more places
♻️ Refactor account image
2025-03-02 21:52:41 +08:00
22fde6b400 🍱 Add more badges 2025-03-02 21:19:59 +08:00
6e03a00280 Wearable badge 2025-03-02 21:08:41 +08:00
72e6a6a1f6 Enhanced profile edit 2025-03-02 20:37:36 +08:00
66aef44281 ⬆️ Upgrade freezed 2025-03-02 15:22:24 +08:00
7bb73c80b0 🐛 Fixes on load new messages 2025-03-01 22:52:22 +08:00
d043ef2410 🐛 Fix websocket uri too long cause disconnect 2025-03-01 18:49:45 +08:00
1d0e2f7591 Provide client id to websocket 2025-03-01 18:34:59 +08:00
e9ef28d764 Optimize loading speed of chat
 Support new subscribe channel
2025-03-01 18:32:31 +08:00
289aa17a7a 🐛 Fix video post editor layout issue 2025-02-28 00:11:54 +08:00
93f41bb523 Chat input auto grow 2025-02-28 00:08:12 +08:00
09ec9d4a0c 🐛 Fix displaying quoted message attachment with weird padding 2025-02-28 00:03:47 +08:00
1153fbdeee Cache management 2025-02-27 23:46:47 +08:00
e933058338 💄 Optimize runtime log screen 2025-02-27 23:33:29 +08:00
ae9743c84f ♻️ Refactor logging module 2025-02-27 23:30:08 +08:00
32bf834108 Logging framework 2025-02-27 22:58:31 +08:00
1b41c847a6 Custom fonts 2025-02-27 22:35:12 +08:00
b1af6c2c97 🐛 Optimize and fix profile page loading issue 2025-02-27 22:11:53 +08:00
8e76ff3f84 Optimize user loading api usage 2025-02-27 20:51:47 +08:00
bd26602299 Code highlighting 2025-02-26 23:29:02 +08:00
52ab1d0d10 🐛 Fix chat last message displaying inconsistences 2025-02-26 00:29:35 +08:00
f746e06f65 ⚗️ Experimental user first badge showing on chat 2025-02-26 00:25:42 +08:00
d11069a2be 🐛 Bug fixes on notification page 2025-02-26 00:00:53 +08:00
d6dc487d9e Latex Rendering, closed #9 2025-02-25 23:49:48 +08:00
a07c7cdede 🐛 Fix infinite loading own sticker 2025-02-25 22:56:30 +08:00
acbc125dec 🚀 Launch 2.3.2+75 2025-02-24 23:21:06 +08:00
ad0ee971c1 Desktop mute notification
🐛 Bug fixes on tray icon
2025-02-24 22:46:02 +08:00
52d6bb083e 🐛 Fix macos titlebar not centered 2025-02-24 22:38:08 +08:00
2027eab49b 💄 Optimize displaying of message 2025-02-24 22:35:14 +08:00
566ebde1dd 🐛 Fix windows tray issue 2025-02-24 21:59:41 +08:00
9e039cc532 🐛 Fix editing message 2025-02-24 21:31:12 +08:00
c4b95d7084 🐛 Fix account settings screen error cause by locale 2025-02-24 21:25:12 +08:00
a66129a9ba 🐛 Bug fixes 2025-02-24 21:18:49 +08:00
44e1a8bf67 🚀 Launch 2.3.2+74 2025-02-23 22:45:01 +08:00
efcfd3f57d 🚀 Launch 2.3.2+73 2025-02-23 21:37:33 +08:00
84759715a4 💄 Not showing notification when in the channel 2025-02-23 21:19:34 +08:00
fda09382dd 💄 Hide unread count auto after entering channel 2025-02-23 21:10:32 +08:00
2c5dd0563a 🐛 Fix checking for update db issue 2025-02-23 21:10:18 +08:00
5bdd8e94fa 🐛 Bug hotfix launch 2.3.2+72 2025-02-23 18:48:26 +08:00
2a53031c9a 🚀 Relaunch 2.3.2+71 2025-02-23 15:41:18 +08:00
e8bc7261f3 Updater 2025-02-23 15:41:03 +08:00
158 changed files with 28536 additions and 13027 deletions

View 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
}

View File

@ -0,0 +1,11 @@
meta {
name: Check Status
type: http
seq: 1
}
get {
url: {{endpoint}}/directory/status
body: none
auth: none
}

View File

@ -0,0 +1,11 @@
meta {
name: List Services
type: http
seq: 2
}
get {
url: {{endpoint}}/directory/services
body: none
auth: none
}

View 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"
}
}

View File

@ -15,12 +15,10 @@ body:json {
"client_id": "{{third_client_id}}",
"client_secret":"{{third_client_tk}}",
"type": "general",
"subject": "新年快乐!",
"subtitle": "一条来自 Solar Network 团队的信息",
"content": "今天是农历正月初一,小羊祝您新年快乐 🎉",
"metadata": {
"image": "D2EDbcrsTugs3xk5"
},
"subject": "关于迁移服务器完成的提示",
"subtitle": "一条来自 Solar Network 团队的运营信息",
"content": "我们已经将所有用户数据迁移到新版服务器,刚刚发布新的 DNS因为部分 DNS 缓存的影响。可能更改不会生效,可以使用 nslookup / ping 检查解析地址是否未 8. 开头,您可以主动刷新 DNS。谢谢",
"metadata": {},
"priority": 10
}
}

View File

@ -7,5 +7,5 @@ meta {
get {
url: {{endpoint}}/cgi/re/well-known/sources
body: none
auth: none
auth: inherit
}

View File

@ -12,7 +12,7 @@ post {
body:json {
{
"sources": ["taiwan-ltn"],
"sources": ["taiwan-pts"],
"eager": true
}
}

BIN
assets/fonts/Nunito-Bold.ttf Executable file

Binary file not shown.

BIN
assets/fonts/Nunito-Italic.ttf Executable file

Binary file not shown.

BIN
assets/fonts/Nunito-Regular.ttf Executable file

Binary file not shown.

View File

@ -153,6 +153,11 @@
"publisherRunBy": "Run by {}",
"fieldPublisherBelongToRealm": "Belongs to",
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
"writePost": "Compose",
"postTypeStory": "Story",
"postTypeArticle": "Article",
"postTypeQuestion": "Question",
"postTypeVideo": "Video",
"writePostTypeStory": "Post a story",
"writePostTypeArticle": "Write an article",
"writePostTypeQuestion": "Ask a question",
@ -202,7 +207,13 @@
"one": "{} comment",
"other": "{} comments"
},
"postCommentExpand": "Show comments",
"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",
"settingsDisplayLanguageDescription": "Set the application language.",
"settingsDisplayLanguageSystem": "Follow System",
@ -512,8 +523,13 @@
"accountBirthday": "Born on {}",
"accountBadge": "Badge",
"accountCheckInNoRecords": "No check-in records",
"badgeCompanyStaff": "Solsynth Staff",
"badgeCompanyStaff": "Staff",
"badgeSiteMigration": "Solar Network Native",
"badgeCommunitySurvey": "Survey Participant",
"badgeCommunityVerified": "Verified User",
"badgeCommunityContributor": "Great Contributor",
"badgeSiteAnniversary": "Anniversary",
"badgeUserBirthday": "Birthday",
"accountStatus": "Status",
"accountStatusOnline": "Online",
"accountStatusOffline": "Offline",
@ -718,5 +734,117 @@
"stickersNew": "New Sticker",
"stickersNewDescription": "Create a new sticker belongs to this pack.",
"stickersPackNew": "New Sticker Pack",
"trayMenuShow": "Show"
"trayMenuShow": "Show",
"trayMenuMuteNotification": "Do Not Disturb",
"update": "Update",
"forceUpdate": "Force Update",
"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."
}

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所属领域",
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
"writePost": "撰写",
"postTypeStory": "动态",
"postTypeArticle": "文章",
"postTypeQuestion": "问题",
"postTypeVideo": "视频",
"writePostTypeStory": "发动态",
"writePostTypeArticle": "写文章",
"writePostTypeQuestion": "提问题",
@ -200,7 +205,13 @@
"one": "{} 条评论",
"other": "{} 条评论"
},
"postCommentExpand": "展开评论",
"settingsAppearance": "外观",
"settingsCustomFonts": "自定义字体",
"settingsCustomFontsDescription": "设置应用程序使用的字体。",
"settingsCustomFontFamily": "应用字体",
"settingsCustomFontFamilyHint": "使用英文逗号分割每一种字体,越前优先级越高",
"settingsCustomFontApplied": "自定义字体已经应用。",
"settingsDisplayLanguage": "显示语言",
"settingsDisplayLanguageDescription": "设置应用程序使用的语言",
"settingsDisplayLanguageSystem": "跟随系统",
@ -510,8 +521,13 @@
"accountBirthday": "出生于 {}",
"accountBadge": "徽章",
"accountCheckInNoRecords": "暂无运势记录",
"badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
"badgeCompanyStaff": "工作人员",
"badgeSiteMigration": "Solar Network 原住民",
"badgeCommunitySurvey": "调研参与者",
"badgeCommunityVerified": "认证用户",
"badgeCommunityContributor": "优秀社区贡献者",
"badgeSiteAnniversary": "周年纪念",
"badgeUserBirthday": "生日纪念",
"accountStatus": "状态",
"accountStatusOnline": "在线",
"accountStatusOffline": "离线",
@ -716,5 +732,117 @@
"stickersNew": "新建贴图",
"stickersNewDescription": "创建一个新的贴图。",
"stickersPackNew": "新建贴图包",
"trayMenuShow": "显示"
"trayMenuShow": "显示",
"trayMenuMuteNotification": "静音通知",
"update": "更新",
"forceUpdate": "强制更新",
"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": "在查看帖子、消息时自动翻译文本。"
}

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題",
@ -200,7 +205,13 @@
"one": "{} 條評論",
"other": "{} 條評論"
},
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
"settingsCustomFontFamily": "應用字體",
"settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
"settingsCustomFontApplied": "自定義字體已經應用。",
"settingsDisplayLanguage": "顯示語言",
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
"settingsDisplayLanguageSystem": "跟隨系統",
@ -510,8 +521,13 @@
"accountBirthday": "出生於 {}",
"accountBadge": "徽章",
"accountCheckInNoRecords": "暫無運勢記錄",
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
"badgeCompanyStaff": "工作人員",
"badgeSiteMigration": "Solar Network 原住民",
"badgeCommunitySurvey": "調研參與者",
"badgeCommunityVerified": "認證用户",
"badgeCommunityContributor": "優秀社區貢獻者",
"badgeSiteAnniversary": "週年紀念",
"badgeUserBirthday": "生日紀念",
"accountStatus": "狀態",
"accountStatusOnline": "在線",
"accountStatusOffline": "離線",
@ -715,5 +731,118 @@
"fieldStickerAttachment": "附件",
"stickersNew": "新建貼圖",
"stickersNewDescription": "創建一個新的貼圖。",
"stickersPackNew": "新建貼圖包"
"stickersPackNew": "新建貼圖包",
"trayMenuShow": "顯示",
"trayMenuMuteNotification": "靜音通知",
"update": "更新",
"forceUpdate": "強制更新",
"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": "在查看帖子、消息時自動翻譯文本。"
}

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題",
@ -200,7 +205,13 @@
"one": "{} 條評論",
"other": "{} 條評論"
},
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
"settingsCustomFontFamily": "應用字體",
"settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
"settingsCustomFontApplied": "自定義字體已經應用。",
"settingsDisplayLanguage": "顯示語言",
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
"settingsDisplayLanguageSystem": "跟隨系統",
@ -510,8 +521,13 @@
"accountBirthday": "出生於 {}",
"accountBadge": "徽章",
"accountCheckInNoRecords": "暫無運勢記錄",
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
"badgeCompanyStaff": "工作人員",
"badgeSiteMigration": "Solar Network 原住民",
"badgeCommunitySurvey": "調研參與者",
"badgeCommunityVerified": "認證用戶",
"badgeCommunityContributor": "優秀社區貢獻者",
"badgeSiteAnniversary": "週年紀念",
"badgeUserBirthday": "生日紀念",
"accountStatus": "狀態",
"accountStatusOnline": "在線",
"accountStatusOffline": "離線",
@ -715,5 +731,118 @@
"fieldStickerAttachment": "附件",
"stickersNew": "新建貼圖",
"stickersNewDescription": "創建一個新的貼圖。",
"stickersPackNew": "新建貼圖包"
"stickersPackNew": "新建貼圖包",
"trayMenuShow": "顯示",
"trayMenuMuteNotification": "靜音通知",
"update": "更新",
"forceUpdate": "強制更新",
"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": "在查看帖子、消息時自動翻譯文本。"
}

View File

@ -5,3 +5,7 @@ targets:
options:
explicit_to_json: true
field_rename: snake
drift_dev:
options:
databases:
my_database: lib/database/database.dart

View 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":[]}}]}

View 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"]}}]}

File diff suppressed because one or more lines are too long

View File

@ -37,6 +37,8 @@ PODS:
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- fast_rsa (0.6.0):
- Flutter
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
@ -52,14 +54,14 @@ PODS:
- Firebase/Messaging (11.8.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 11.8.0)
- firebase_analytics (11.4.3):
- firebase_analytics (11.4.4):
- Firebase/Analytics (= 11.8.0)
- firebase_core
- Flutter
- firebase_core (3.12.0):
- firebase_core (3.12.1):
- Firebase/CoreOnly (= 11.8.0)
- Flutter
- firebase_messaging (15.2.3):
- firebase_messaging (15.2.4):
- Firebase/Messaging (= 11.8.0)
- firebase_core
- Flutter
@ -113,6 +115,8 @@ PODS:
- OrderedSet (~> 6.0.3)
- flutter_native_splash (2.4.3):
- Flutter
- flutter_timezone (0.0.1):
- Flutter
- flutter_udid (0.0.1):
- Flutter
- SAMKeychain
@ -179,7 +183,7 @@ PODS:
- in_app_review (2.0.0):
- Flutter
- Kingfisher (8.2.0)
- livekit_client (2.3.6):
- livekit_client (2.4.1):
- Flutter
- flutter_webrtc
- WebRTC-SDK (= 125.6422.06)
@ -235,7 +239,7 @@ PODS:
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.49.0)
- sqlite3 (~> 3.49.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/perf-threadsafe
@ -258,6 +262,7 @@ DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- croppy (from `.symlinks/plugins/croppy/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_saver (from `.symlinks/plugins/file_saver/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
@ -267,6 +272,7 @@ DEPENDENCIES:
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/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_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
@ -325,6 +331,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/croppy/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
fast_rsa:
:path: ".symlinks/plugins/fast_rsa/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
file_saver:
@ -343,6 +351,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_timezone:
:path: ".symlinks/plugins/flutter_timezone/ios"
flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios"
flutter_webrtc:
@ -401,12 +411,13 @@ SPEC CHECKSUMS:
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
fast_rsa: dc48fb05f26bb108863de122b2a9f5554e8e2591
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf
firebase_analytics: 7ec1166af61987fa968766eb11587c562a5650ee
firebase_core: 6e223dfa350b2edceb729cea505eaaef59330682
firebase_messaging: 07fde77ae28c08616a1d4d870450efc2b38cf40d
firebase_analytics: e3b6782e70e32b7fa18f7cd233e3201975dd86aa
firebase_core: ac395f994af4e28f6a38b59e05a88ca57abeb874
firebase_messaging: 7e223f4ee7ca053bf4ce43748e84a6d774ec9728
FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b
FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d
FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629
@ -416,6 +427,7 @@ SPEC CHECKSUMS:
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
@ -426,7 +438,7 @@ SPEC CHECKSUMS:
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
livekit_client: 148b2cf67a09aaf475ba8e5bf1667fe10dc35f81
livekit_client: 170022ce5f7c8c70d7f862ac9c17e11508ad5fbc
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
@ -445,7 +457,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: 069c435986dd4b63461aecd68f4b30be4a9e9daa
sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe

View File

@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:math' as math;
import 'package:dio/dio.dart';
@ -8,7 +7,10 @@ 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/channel.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_network.dart';
import 'package:surface/providers/user_directory.dart';
@ -25,6 +27,8 @@ class ChatMessageController extends ChangeNotifier {
late final WebSocketProvider _ws;
late final SnAttachmentProvider _attach;
late final DatabaseProvider _dt;
late final ChatChannelProvider _ct;
late final KeyPairProvider _kp;
StreamSubscription? _wsSubscription;
@ -33,11 +37,14 @@ class ChatMessageController extends ChangeNotifier {
_ud = context.read<UserDirectoryProvider>();
_ws = context.read<WebSocketProvider>();
_attach = context.read<SnAttachmentProvider>();
_ct = context.read<ChatChannelProvider>();
_dt = context.read<DatabaseProvider>();
_kp = context.read<KeyPairProvider>();
}
bool isPending = true;
bool isLoading = false;
bool isAggressiveLoading = false;
int? messageTotal;
@ -61,10 +68,7 @@ class ChatMessageController extends ChangeNotifier {
channel = chan;
// Fetch channel profile
final resp = await _sn.client.get(
'/cgi/im/channels/${chan.keyPath}/me',
);
profile = SnChannelMember.fromJson(resp.data);
profile = await _ct.getChannelProfile(channel!);
_wsSubscription = _ws.pk.stream.listen((event) {
switch (event.method) {
@ -183,6 +187,7 @@ class ChatMessageController extends ChangeNotifier {
} else {
messages.insert(0, message);
}
notifyListeners();
await _applyMessage(message);
notifyListeners();
@ -194,9 +199,11 @@ class ChatMessageController extends ChangeNotifier {
channelId: channel!.id,
createdAt: Value(message.createdAt),
),
onConflict: DoUpdate((_) => SnLocalChatMessageCompanion.custom(
content: Constant(jsonEncode(message.toJson())),
)),
onConflict: DoUpdate(
(_) => SnLocalChatMessageCompanion.custom(
content: Constant(jsonEncode(message.toJson())),
),
),
);
} else {
incomeStrandedQueue.add(message);
@ -212,21 +219,21 @@ class ChatMessageController extends ChangeNotifier {
final idx =
messages.indexWhere((x) => x.id == message.relatedEventId);
if (idx != -1) {
final newBody = message.body;
final newBody = Map<String, dynamic>.from(message.body);
newBody.remove('related_event');
messages[idx] = messages[idx].copyWith(
body: newBody,
updatedAt: message.updatedAt,
);
if (message.relatedEventId != null) {
await (_dt.db.snLocalChatMessage.update()
..where((e) => e.id.equals(message.relatedEventId!)))
.write(
SnLocalChatMessageCompanion.custom(
content: Constant(jsonEncode(messages[idx].toJson())),
),
);
}
}
if (message.relatedEventId != null) {
await (_dt.db.snLocalChatMessage.update()
..where((e) => e.id.equals(message.relatedEventId!)))
.write(
SnLocalChatMessageCompanion.custom(
content: Constant(jsonEncode(messages[idx].toJson())),
),
);
}
}
case 'messages.delete':
@ -241,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(
String type,
String content, {
@ -248,13 +273,13 @@ class ChatMessageController extends ChangeNotifier {
int? relatedId,
List<String>? attachments,
SnChatMessage? editingMessage,
bool isEncrypted = false,
}) async {
if (channel == null) return;
const uuid = Uuid();
final nonce = uuid.v4();
final body = {
'text': content,
'algorithm': 'plain',
...(await _encodeMessageBody(content, isEncrypted)),
if (quoteId != null) 'quote_event': quoteId,
if (relatedId != null) 'related_event': relatedId,
if (attachments != null && attachments.isNotEmpty)
@ -262,23 +287,26 @@ class ChatMessageController extends ChangeNotifier {
};
// Mock the message locally
final createdAt = DateTime.now();
final message = SnChatMessage(
id: 0,
createdAt: createdAt,
updatedAt: createdAt,
deletedAt: null,
uuid: nonce,
body: body,
type: type,
channel: channel!,
channelId: channel!.id,
sender: profile!,
senderId: profile!.id,
quoteEventId: quoteId,
relatedEventId: relatedId,
);
_addUnconfirmedMessage(message);
// Do not mock the editing message
if (editingMessage == null) {
final createdAt = DateTime.now();
final message = SnChatMessage(
id: 0,
createdAt: createdAt,
updatedAt: createdAt,
deletedAt: null,
uuid: nonce,
body: body,
type: type,
channel: channel!,
channelId: channel!.id,
sender: profile!,
senderId: profile!.id,
quoteEventId: quoteId,
relatedEventId: relatedId,
);
_addUnconfirmedMessage(message);
}
// Send to server
try {
@ -318,10 +346,11 @@ class ChatMessageController extends ChangeNotifier {
/// Check the local storage is up to date with the server.
/// If the local storage is not up to date, it will be updated.
Future<void> checkUpdate() async {
isLoading = true;
isAggressiveLoading = true;
notifyListeners();
final mostRecentMessage = await (_dt.db.snLocalChatMessage.select()
..where((e) => e.channelId.equals(channel!.id))
..limit(1)
..orderBy([
(e) =>
@ -331,6 +360,7 @@ class ChatMessageController extends ChangeNotifier {
if (mostRecentMessage == null) {
// Initial load
await loadMessages(take: 20);
isAggressiveLoading = false;
isCheckedUpdate = true;
return;
}
@ -348,13 +378,19 @@ class ChatMessageController extends ChangeNotifier {
final countToFetch = math.min(resp.data['count'] as int, 100);
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) {
rethrow;
} finally {
await loadMessages();
isLoading = false;
isAggressiveLoading = false;
isCheckedUpdate = true;
_saveMessageToLocal(incomeStrandedQueue).then((_) {
@ -529,7 +565,7 @@ class ChatMessageController extends ChangeNotifier {
},
).toJson(),
));
log('[Messaging] Send read event request: $_readEventAnchor');
logging.debug('[Messaging] Send read event request: $_readEventAnchor');
}
@override

View File

@ -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;
@ -105,7 +106,8 @@ class PostWriteMedia {
}) {
if (attachment != null) {
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) {
return ResizeImage(
provider,
@ -116,7 +118,8 @@ class PostWriteMedia {
}
return provider;
} 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) {
return ResizeImage(
provider,
@ -159,11 +162,14 @@ class PostWriteController extends ChangeNotifier {
final TextEditingController aliasController = TextEditingController();
final TextEditingController rewardController = TextEditingController();
ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration(
ContentInsertionConfiguration get contentInsertionConfiguration =>
ContentInsertionConfiguration(
onContentInserted: (KeyboardInsertedContent content) {
if (content.hasData) {
addAttachments(
[PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]);
addAttachments([
PostWriteMedia.fromBytes(content.data!,
'attachmentInsertedImage'.tr(), SnMediaType.image)
]);
}
},
);
@ -193,7 +199,8 @@ class PostWriteController extends ChangeNotifier {
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;
double? progress;
@ -201,6 +208,7 @@ class PostWriteController extends ChangeNotifier {
SnRealm? realm;
SnPublisher? publisher;
SnPost? editingPost, repostingPost, replyingPost;
bool editingDraft = false;
int visibility = 0;
List<int> visibleUsers = List.empty();
@ -237,14 +245,20 @@ class PostWriteController extends ChangeNotifier {
publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil;
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true);
invisibleUsers =
List.from(post.invisibleUsersList ?? [], growable: true);
visibility = post.visibility;
tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
categories =
List.from(post.categories.map((ele) => ele.alias), growable: true);
attachments.addAll(
post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
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);
}
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 {
final attach = context.read<SnAttachmentProvider>();
@ -281,7 +296,9 @@ class PostWriteController extends ChangeNotifier {
media.name,
'interactive',
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(
@ -297,9 +314,11 @@ class PostWriteController extends ChangeNotifier {
if (media.type == SnMediaType.video && !isCompressed && context.mounted) {
try {
final compressedAttachment = await _tryCompressVideoCopy(context, media);
final compressedAttachment =
await _tryCompressVideoCopy(context, media);
if (compressedAttachment != null) {
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
item = await attach.updateOne(item,
compressedId: compressedAttachment.id);
}
} catch (err) {
if (context.mounted) context.showErrorDialog(err);
@ -309,8 +328,10 @@ class PostWriteController extends ChangeNotifier {
return item;
}
Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null;
Future<SnAttachment?> _tryCompressVideoCopy(
BuildContext context, PostWriteMedia media) async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS))
return null;
if (media.type != SnMediaType.video) return null;
if (media.file == null) return null;
if (VideoCompress.isCompressing) return null;
@ -334,7 +355,8 @@ class PostWriteController extends ChangeNotifier {
if (!context.mounted) return null;
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;
}
@ -370,18 +392,25 @@ class PostWriteController extends ChangeNotifier {
'content': contentController.text,
if (aliasController.text.isNotEmpty) 'alias': aliasController.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 (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
'attachments':
attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true),
if (thumbnail != null && thumbnail!.attachment != null)
'thumbnail': thumbnail!.attachment!.toJson(),
'attachments': attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.toJson())
.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,
'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers,
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
if (publishedAt != null)
'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null)
'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
if (repostingPost != null) 'repost_to': repostingPost!.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;
void _temporaryLoad() {
@ -403,18 +438,24 @@ class PostWriteController extends ChangeNotifier {
titleController.text = data['title'] ?? '';
descriptionController.text = data['description'] ?? '';
rewardController.text = data['reward']?.toString() ?? '';
if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
attachments
.addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
if (data['thumbnail'] != null)
thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
attachments.addAll(data['attachments']
.map((ele) => PostWriteMedia(SnAttachment.fromJson(ele)))
.cast<PostWriteMedia>());
tags = List.from(data['tags'].map((ele) => ele['alias']));
categories = List.from(data['categories'].map((ele) => ele['alias']));
visibility = data['visibility'];
visibleUsers = List.from(data['visible_users_list'] ?? []);
invisibleUsers = List.from(data['invisible_users_list'] ?? []);
if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
if (data['published_until'] != 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;
if (data['published_at'] != null)
publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
if (data['published_until'] != 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;
realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null;
temporaryRestored = true;
@ -436,7 +477,10 @@ class PostWriteController extends ChangeNotifier {
notifyListeners();
}
Future<void> sendPost(BuildContext context) async {
Future<void> sendPost(
BuildContext context, {
bool saveAsDraft = false,
}) async {
if (isBusy || publisher == null) return;
final sn = context.read<SnNetworkProvider>();
@ -463,7 +507,9 @@ class PostWriteController extends ChangeNotifier {
media.name,
'interactive',
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(
@ -472,16 +518,20 @@ class PostWriteController extends ChangeNotifier {
place.$2,
onProgress: (value) {
// Calculate overall progress for attachments
progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value);
progress = math.max(
((i + value) / attachments.length) * kAttachmentProgressWeight,
value);
notifyListeners();
},
);
try {
if (context.mounted) {
final compressedAttachment = await _tryCompressVideoCopy(context, media);
final compressedAttachment =
await _tryCompressVideoCopy(context, media);
if (compressedAttachment != null) {
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
item = await attach.updateOne(item,
compressedId: compressedAttachment.id);
}
}
} catch (err) {
@ -508,7 +558,7 @@ class PostWriteController extends ChangeNotifier {
// Posting the content
try {
final baseProgressVal = progress!;
await sn.client.request(
final resp = await sn.client.request(
[
'/cgi/co/$mode',
if (editingPost != null) '${editingPost!.id}',
@ -518,36 +568,56 @@ class PostWriteController extends ChangeNotifier {
'content': contentController.text,
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
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(),
'categories': categories.map((ele) => {'alias': ele}).toList(),
'visibility': visibility,
'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers,
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
if (publishedAt != null)
'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null)
'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.id,
if (repostingPost != null) 'repost_to': repostingPost!.id,
if (reward != null) 'reward': reward,
if (videoAttachment != null) 'video': videoAttachment!.rid,
if (poll != null) 'poll': poll!.id,
if (realm != null) 'realm': realm!.id,
'is_draft': saveAsDraft,
},
onSendProgress: (count, total) {
progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
progress =
baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
notifyListeners();
},
onReceiveProgress: (count, total) {
progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2);
progress = baseProgressVal +
(kPostingProgressWeight / 2) +
(count / total) * (kPostingProgressWeight / 2);
notifyListeners();
},
options: Options(
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) {
if (!context.mounted) return;
context.showErrorDialog(err);
@ -683,7 +753,8 @@ class PostWriteController extends ChangeNotifier {
repostingPost = null;
mode = kTitleMap.keys.first;
temporaryRestored = false;
SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey));
SharedPreferences.getInstance()
.then((prefs) => prefs.remove(kTemporaryStorageKey));
notifyListeners();
}

42
lib/database/account.dart Normal file
View 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()();
}

View 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()();
}

View File

@ -28,6 +28,7 @@ class SnChannelConverter extends TypeConverter<SnChannel, String>
}
}
@TableIndex(name: 'idx_channel_alias', columns: {#alias})
class SnLocalChatChannel extends Table {
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 {
IntColumn get id => integer().autoIncrement()();
IntColumn get channelId => integer()();
IntColumn get senderId => integer().nullable()();
TextColumn get content => text().map(const SnMessageConverter())();
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()();
}

View File

@ -1,17 +1,33 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.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/database.steps.dart';
import 'package:surface/database/keypair.dart';
import 'package:surface/database/sticker.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/account.dart';
part 'database.g.dart';
@DriftDatabase(tables: [SnLocalChatChannel, SnLocalChatMessage])
@DriftDatabase(tables: [
SnLocalChatChannel,
SnLocalChatMessage,
SnLocalChannelMember,
SnLocalKeyPair,
SnLocalAccount,
SnLocalAttachment,
SnLocalSticker,
SnLocalStickerPack,
])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection());
@override
int get schemaVersion => 1;
int get schemaVersion => 3;
static QueryExecutor _openConnection() {
return driftDatabase(
@ -25,4 +41,15 @@ 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
}),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,445 @@
// 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>;
}
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
}) {
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;
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,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
from2To3: from2To3,
));

16
lib/database/keypair.dart Normal file
View 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};
}

74
lib/database/sticker.dart Normal file
View 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
View File

@ -0,0 +1,10 @@
import 'package:talker/talker.dart';
final logging = Talker(
settings: TalkerSettings(
enabled: true,
useHistory: true,
maxHistoryItems: 1000,
useConsoleLogs: true,
),
);

View File

@ -20,10 +20,12 @@ import 'package:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/firebase_options.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/database.dart';
import 'package:surface/providers/keypair.dart';
import 'package:surface/providers/link_preview.dart';
import 'package:surface/providers/navigation.dart';
import 'package:surface/providers/notification.dart';
@ -35,6 +37,7 @@ import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/sn_sticker.dart';
import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/theme.dart';
import 'package:surface/providers/translation.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
@ -42,6 +45,7 @@ import 'package:surface/providers/widget.dart';
import 'package:surface/router.dart';
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/menu_bar.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:version/version.dart';
import 'package:workmanager/workmanager.dart';
@ -160,9 +164,11 @@ class SolianApp extends StatelessWidget {
Provider(create: (ctx) => SnStickerProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
Provider(create: (ctx) => KeyPairProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
Provider(create: (ctx) => SnTranslator()),
// Additional helper layer
Provider(create: (ctx) => SpecialDayProvider(ctx)),
@ -235,7 +241,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
await inAppReview.requestReview();
prefs.setBool('rating_requested', true);
} else {
log('Unable request app review, unavailable');
logging.error('Unable request app review, unavailable');
}
}
} else {
@ -254,25 +260,29 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
receiveTimeout: const Duration(seconds: 60),
),
).get(
'https://git.solsynth.dev/api/v1/repos/HyperNet/Surface/tags?page=1&limit=1',
'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest',
);
final remoteVersionString =
(resp.data as List).firstOrNull?['name'] ?? '0.0.0+0';
final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0';
final remoteVersion = Version.parse(remoteVersionString.split('+').first);
final localVersion = Version.parse(localVersionString.split('+').first);
final remoteBuildNumber =
int.tryParse(remoteVersionString.split('+').last) ?? 0;
final localBuildNumber =
int.tryParse(localVersionString.split('+').last) ?? 0;
log("[Update] Local: $localVersionString, Remote: $remoteVersionString");
logging.info(
"[Update] Local: $localVersionString, Remote: $remoteVersionString");
if ((remoteVersion > localVersion ||
remoteBuildNumber > localBuildNumber) &&
mounted) {
final config = context.read<ConfigProvider>();
config.setUpdate(remoteVersionString);
log("[Update] Update available: $remoteVersionString");
config.setUpdate(
remoteVersionString,
resp.data?['body'] ?? 'No changelog',
);
logging.info("[Update] Update available: $remoteVersionString");
}
} catch (e) {
logging.error('[Error] Unable to check update...', e);
if (mounted) context.showErrorDialog('Unable to check update: $e');
}
}
@ -303,9 +313,17 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
notify.listen();
await notify.registerPushNotifications();
if (!mounted) return;
final kp = context.read<KeyPairProvider>();
await kp.reloadActive();
kp.listen();
if (!mounted) return;
final sticker = context.read<SnStickerProvider>();
await sticker.listSticker();
log('[Bootstrap] Everything initialized!');
if (!mounted) return;
final ud = context.read<UserDirectoryProvider>();
final userCacheSize = await ud.loadAccountCache();
logging.info('[Users] Loaded local user cache, size: $userCacheSize');
logging.info('[Bootstrap] Everything initialized!');
} catch (err) {
if (!mounted) return;
await context.showErrorDialog(err);
@ -318,20 +336,34 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
Future<void> _hotkeyInitialization() async {
if (kIsWeb) return;
if (Platform.isMacOS) {
HotKey quitHotKey = HotKey(
key: PhysicalKeyboardKey.keyQ,
modifiers: [HotKeyModifier.meta],
scope: HotKeyScope.inapp,
);
await hotKeyManager.register(quitHotKey, keyUpHandler: (_) {
_appLifecycleListener?.dispose();
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
});
}
// The quit key has been removed, and the logic of the quit key is moved to system menu bar activator.
}
final Menu _appTrayMenu = Menu(
items: [
MenuItem(
key: 'version_label',
label: 'Solian',
disabled: true,
),
MenuItem.separator(),
MenuItem.checkbox(
checked: false,
key: 'mute_notification',
label: 'trayMenuMuteNotification'.tr(),
),
MenuItem.separator(),
MenuItem(
key: 'window_show',
label: 'trayMenuShow'.tr(),
),
MenuItem(
key: 'exit',
label: 'trayMenuExit'.tr(),
),
],
);
Future<void> _trayInitialization() async {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
@ -343,32 +375,20 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
trayManager.addListener(this);
await trayManager.setIcon(icon);
Menu menu = Menu(
items: [
MenuItem(
key: 'version_label',
label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
disabled: true,
),
MenuItem.separator(),
MenuItem(
key: 'window_show',
label: 'trayMenuShow'.tr(),
),
MenuItem(
key: 'exit',
label: 'trayMenuExit'.tr(),
),
],
_appTrayMenu.items![0] = MenuItem(
key: 'version_label',
label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
disabled: true,
);
await trayManager.setContextMenu(menu);
await trayManager.setContextMenu(_appTrayMenu);
}
Future<void> _notifyInitialization() async {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
await localNotifier.setup(
appName: 'solian',
appName: 'Solian',
shortcutPolicy: ShortcutPolicy.requireCreate,
);
}
@ -400,6 +420,15 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
return AppExitResponse.cancel;
}
void _quitApp() {
_appLifecycleListener?.dispose();
if (Platform.isWindows) {
appWindow.close();
} else {
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
}
}
@override
void onTrayIconMouseDown() {
if (Platform.isWindows) {
@ -423,12 +452,18 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
@override
void onTrayMenuItemClick(MenuItem menuItem) {
switch (menuItem.key) {
case 'mute_notification':
final nty = context.read<NotificationProvider>();
nty.isMuted = !nty.isMuted;
_appTrayMenu.items![2].checked = nty.isMuted;
trayManager.setContextMenu(_appTrayMenu);
break;
case 'window_show':
appWindow.show();
// To prevent the window from being hide after just show on macOS
Timer(const Duration(milliseconds: 100), () => appWindow.show());
break;
case 'exit':
_appLifecycleListener?.dispose();
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
_quitApp();
break;
}
}
@ -445,23 +480,31 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
@override
Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>();
return NotificationListener<SizeChangedLayoutNotification>(
onNotification: (notification) {
WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context);
});
return false;
},
child: OrientationBuilder(
builder: (context, orientation) {
final cfg = context.read<ConfigProvider>();
return AppSystemMenuBar(
onQuit: _quitApp,
child: NotificationListener<SizeChangedLayoutNotification>(
onNotification: (notification) {
WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context);
});
return SizeChangedLayoutNotifier(
child: widget.child,
);
return false;
},
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: widget.child,
);
},
),
),
);
}

View File

@ -8,6 +8,7 @@ import 'package:surface/providers/database.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart';
class ChatChannelProvider extends ChangeNotifier {
@ -15,12 +16,14 @@ class ChatChannelProvider extends ChangeNotifier {
late final SnNetworkProvider _sn;
late final UserDirectoryProvider _ud;
late final UserProvider _ua;
late final DatabaseProvider _dt;
late final SnRealmProvider _rels;
ChatChannelProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ud = context.read<UserDirectoryProvider>();
_ua = context.read<UserProvider>();
_dt = context.read<DatabaseProvider>();
_rels = context.read<SnRealmProvider>();
}
@ -65,7 +68,11 @@ class ChatChannelProvider extends ChangeNotifier {
.getSingleOrNull();
if (local != null) {
final out = local.content;
return out.copyWith(realm: await _rels.getRealm(out.realmId!));
if (out.realmId != null) {
return out.copyWith(realm: await _rels.getRealm(out.realmId!));
} else {
return out;
}
}
var resp =
@ -145,4 +152,60 @@ class ChatChannelProvider extends ChangeNotifier {
await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet());
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;
}
}

View File

@ -18,6 +18,9 @@ const kAppNotifyWithHaptic = 'app_notify_with_haptic';
const kAppExpandPostLink = 'app_expand_post_link';
const kAppExpandChatLink = 'app_expand_chat_link';
const kAppRealmCompactView = 'app_realm_compact_view';
const kAppCustomFonts = 'app_custom_fonts';
const kAppMixedFeed = 'app_mixed_feed';
const kAppAutoTranslate = 'app_auto_translate';
const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none,
@ -58,7 +61,8 @@ class ConfigProvider extends ChangeNotifier {
: false;
}
if (newDrawerIsExpanded != drawerIsExpanded || newDrawerIsCollapsed != drawerIsCollapsed) {
if (newDrawerIsExpanded != drawerIsExpanded ||
newDrawerIsCollapsed != drawerIsCollapsed) {
drawerIsExpanded = newDrawerIsExpanded;
drawerIsCollapsed = newDrawerIsCollapsed;
notifyListeners();
@ -66,7 +70,9 @@ class ConfigProvider extends ChangeNotifier {
}
FilterQuality get imageQuality {
return kImageQualityLevel.values.elementAtOrNull(prefs.getInt('app_image_quality') ?? 3) ?? FilterQuality.high;
return kImageQualityLevel.values
.elementAtOrNull(prefs.getInt('app_image_quality') ?? 3) ??
FilterQuality.high;
}
String get serverUrl {
@ -76,8 +82,28 @@ class ConfigProvider extends ChangeNotifier {
bool get realmCompactView {
return prefs.getBool(kAppRealmCompactView) ?? false;
}
bool get mixedFeed {
return prefs.getBool(kAppMixedFeed) ?? true;
}
bool get autoTranslate {
return prefs.getBool(kAppAutoTranslate) ?? false;
}
set autoTranslate(bool value) {
prefs.setBool(kAppAutoTranslate, value);
notifyListeners();
}
set mixedFeed(bool value) {
prefs.setBool(kAppMixedFeed, value);
notifyListeners();
}
set realmCompactView(bool value) {
prefs.setBool(kAppRealmCompactView, value);
notifyListeners();
}
set serverUrl(String url) {
@ -86,9 +112,11 @@ class ConfigProvider extends ChangeNotifier {
}
String? updatableVersion;
String? updatableChangelog;
void setUpdate(String newVersion) {
void setUpdate(String newVersion, String newChangelog) {
updatableVersion = newVersion;
updatableChangelog = newChangelog;
notifyListeners();
}
}

245
lib/providers/keypair.dart Normal file
View 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;
}
}

View File

@ -1,8 +1,8 @@
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/link.dart';
@ -20,7 +20,7 @@ class SnLinkPreviewProvider {
final target = b64.encode(url);
if (_cache.containsKey(target)) return _cache[target];
log('[LinkPreview] Fetching $url ($target)');
logging.debug('[LinkPreview] Fetching $url ($target)');
try {
final resp = await _sn.client.get('/cgi/re/link/$target');
@ -28,7 +28,7 @@ class SnLinkPreviewProvider {
_cache[url] = meta;
return meta;
} catch (err) {
log('[LinkPreview] Failed to fetch $url ($target)...');
logging.warning('[LinkPreview] Failed to fetch $url ($target)...', err);
return null;
}
}

View File

@ -1,4 +1,3 @@
import 'dart:developer';
import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart';
@ -9,6 +8,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:local_notifier/local_notifier.dart';
import 'package:provider/provider.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
@ -48,11 +48,13 @@ class NotificationProvider extends ChangeNotifier {
var deviceUuid = await FlutterUdid.consistentUdid;
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;
} else {
log('Device UUID is $deviceUuid');
log('Registering device push notifications...');
logging.info('[Push Notification] Device UUID is $deviceUuid');
logging
.info('[Push Notification] Registering device push notifications...');
}
if (Platform.isIOS || Platform.isMacOS) {
@ -62,7 +64,7 @@ class NotificationProvider extends ChangeNotifier {
provider = 'fcm';
token = await FirebaseMessaging.instance.getToken();
}
log('Device Push Token is $token');
logging.info('[Push Notification] Device Push Token is $token');
await _sn.client.post(
'/cgi/id/notifications/subscription',
@ -78,10 +80,25 @@ class NotificationProvider extends ChangeNotifier {
int showingTrayCount = 0;
List<SnNotification> notifications = List.empty(growable: true);
int? skippableNotifyChannel;
bool isMuted = false;
void listen() {
_ws.pk.stream.listen((event) {
if (event.method == 'notifications.new') {
final notification = SnNotification.fromJson(event.payload!);
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
if (doHaptic) HapticFeedback.mediumImpact();
if (notification.topic == 'messaging.message' &&
skippableNotifyChannel != null) {
if (notification.metadata['channel_id'] != null &&
notification.metadata['channel_id'] == skippableNotifyChannel) {
return;
}
}
if (showingCount < 0) showingCount = 0;
showingCount++;
showingTrayCount++;
@ -92,10 +109,8 @@ class NotificationProvider extends ChangeNotifier {
});
notifyListeners();
updateTray();
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
if (doHaptic) HapticFeedback.mediumImpact();
if (!kIsWeb) {
if (!kIsWeb && !isMuted) {
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
LocalNotification notify = LocalNotification(
title: notification.title,

View File

@ -28,6 +28,7 @@ class SnPostContentProvider {
Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
Set<String> rids = {};
Set<int> uids = {};
for (var i = 0; i < out.length; i++) {
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
if (out[i].body['thumbnail'] != null) {
@ -41,6 +42,9 @@ class SnPostContentProvider {
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());
@ -56,24 +60,32 @@ class SnPostContentProvider {
out[i] = out[i].copyWith(
preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull,
attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(),
video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull,
thumbnail: attachments
.where((ele) => ele?.rid == out[i].body['thumbnail'])
.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,
realm: realm,
),
);
}
await _ud.listAccount(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId).toSet(),
);
uids.addAll(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
await _ud.listAccount(uids);
return out;
}
Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
Set<String> rids = {};
Set<int> uids = {};
rids.addAll(out.body['attachments']?.cast<String>() ?? []);
if (out.body['thumbnail'] != null) {
rids.add(out.body['thumbnail']);
@ -86,6 +98,9 @@ class SnPostContentProvider {
repostTo: await _preloadRelatedDataSingle(out.repostTo!),
);
}
if (out.publisher.type == 0) {
uids.add(out.publisher.accountId);
}
final attachments = await _attach.getMultiple(rids.toList());
@ -100,14 +115,25 @@ class SnPostContentProvider {
out = out.copyWith(
preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull,
attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull,
thumbnail: attachments
.where((ele) => ele?.rid == out.body['thumbnail'])
.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,
realm: realm,
),
);
uids.addAll(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
await _ud.listAccount(uids);
return out;
}
@ -119,6 +145,36 @@ class SnPostContentProvider {
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({
int take = 10,
int offset = 0,
@ -128,17 +184,25 @@ class SnPostContentProvider {
Iterable<String>? tags,
String? realm,
String? channel,
bool isDraft = false,
bool isShuffle = false,
}) async {
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
'take': take,
'offset': offset,
if (type != null) 'type': type,
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 resp = await _sn.client.get(
isShuffle
? '/cgi/co/recommendations/shuffle'
: '/cgi/co/posts${isDraft ? '/drafts' : ''}',
queryParameters: {
'take': take,
'offset': offset,
if (type != null) 'type': type,
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(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
@ -151,7 +215,8 @@ class SnPostContentProvider {
int take = 10,
int offset = 0,
}) 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,
'offset': offset,
});
@ -190,4 +255,9 @@ class SnPostContentProvider {
);
return out;
}
Future<SnPost> completePostData(SnPost post) async {
final out = await _preloadRelatedDataSingle(post);
return out;
}
}

View File

@ -1,11 +1,14 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import 'package:flutter/widgets.dart';
import 'package:cross_file/cross_file.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/types/attachment.dart';
@ -13,10 +16,12 @@ const kConcurrentUploadChunks = 5;
class SnAttachmentProvider {
late final SnNetworkProvider _sn;
late final DatabaseProvider _dt;
final Map<String, SnAttachment> _cache = {};
SnAttachmentProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_dt = context.read<DatabaseProvider>();
}
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
@ -28,20 +33,33 @@ class SnAttachmentProvider {
}
Future<SnAttachment> getOne(String rid, {noCache = false}) async {
// In-memory cache
if (!noCache && _cache.containsKey(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 out = SnAttachment.fromJson(resp.data);
if (out.isAnalyzed) {
_cache[rid] = out;
}
_saveToLocal([out]);
return out;
}
Future<List<SnAttachment?>> getMultiple(List<String> rids, {noCache = false}) async {
Future<List<SnAttachment?>> getMultiple(List<String> rids,
{bool noCache = false}) async {
// In-memory cache
final result = List<SnAttachment?>.filled(rids.length, null);
final Map<String, int> randomMapping = {};
for (int i = 0; i < rids.length; i++) {
@ -52,32 +70,55 @@ class SnAttachmentProvider {
result[i] = _cache[rid]!;
}
}
final pendingFetch = randomMapping.keys;
if (pendingFetch.isNotEmpty) {
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;
var pendingFetch = randomMapping.keys;
// On-disk cache
if (pendingFetch.isEmpty) return result;
if (!noCache) {
final dbResp = await (_dt.db.snLocalAttachment.select()
..where((e) => e.rid.isIn(pendingFetch))
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
.get();
for (final item in dbResp) {
if (item.content.isAnalyzed) {
_cache[item.rid] = item.content;
}
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;
}
static Map<String, String> mimetypeOverrides = {'mov': 'video/quicktime', 'mp4': 'video/mp4'};
static Map<String, String> mimetypeOverrides = {
'mov': 'video/quicktime',
'mp4': 'video/mp4',
'm4a': 'audio/mp4',
'apng': 'image/apng',
'webp': 'image/webp',
};
Future<SnAttachment> directUploadOne(
Uint8List data,
@ -89,8 +130,11 @@ class SnAttachmentProvider {
bool analyzeNow = false,
}) async {
final filePayload = MultipartFile.fromBytes(data, filename: filename);
final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename;
final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
final fileAlt = filename.contains('.')
? filename.substring(0, filename.lastIndexOf('.'))
: filename;
final fileExt =
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
String? mimetypeOverride;
if (mimetype != null) {
@ -127,8 +171,11 @@ class SnAttachmentProvider {
Map<String, dynamic>? metadata, {
String? mimetype,
}) async {
final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename;
final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
final fileAlt = filename.contains('.')
? filename.substring(0, filename.lastIndexOf('.'))
: filename;
final fileExt =
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
String? mimetypeOverride;
if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) {
@ -146,7 +193,10 @@ class SnAttachmentProvider {
if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
});
return (SnAttachmentFragment.fromJson(resp.data['meta']), resp.data['chunk_size'] as int);
return (
SnAttachmentFragment.fromJson(resp.data['meta']),
resp.data['chunk_size'] as int
);
}
Future<dynamic> _chunkedUploadOnePart(
@ -197,7 +247,10 @@ class SnAttachmentProvider {
(entry.value + 1) * chunkSize,
await file.length(),
);
final data = Uint8List.fromList(await file.openRead(beginCursor, endCursor).expand((chunk) => chunk).toList());
final data = Uint8List.fromList(await file
.openRead(beginCursor, endCursor)
.expand((chunk) => chunk)
.toList());
final result = await _chunkedUploadOnePart(
data,
@ -253,6 +306,31 @@ class SnAttachmentProvider {
'metadata': metadata ?? item.usermeta,
'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(days: 7)),
),
onConflict: DoUpdate(
(_) => SnLocalAttachmentCompanion.custom(
content: Constant(jsonEncode(ele.toJson())),
cacheExpiredAt:
Constant(DateTime.now().add(const Duration(days: 7))),
),
),
);
}
}
}

View File

@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
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:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/widget.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 = [
('Solar Network', 'https://api.sn.solsynth.dev'),
@ -36,6 +52,19 @@ class SnNetworkProvider {
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(
dio: client,
retries: 3,
@ -69,7 +98,6 @@ class SnNetworkProvider {
_prefs = _config.prefs;
client.options.baseUrl = _config.serverUrl;
});
}
static Future<Dio> createOffContextClient() async {
@ -91,7 +119,8 @@ class SnNetworkProvider {
RequestOptions options,
RequestInterceptorHandler handler,
) 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(kRtkStoreKey, rtk);
});
@ -103,7 +132,8 @@ class SnNetworkProvider {
},
),
);
client.options.baseUrl = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
client.options.baseUrl =
prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
return client;
}
@ -119,7 +149,8 @@ class SnNetworkProvider {
platformInfo = 'Web; ${deviceInfo.vendor}';
} else if (Platform.isAndroid) {
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) {
final deviceInfo = await DeviceInfoPlugin().iosInfo;
platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}';
@ -128,7 +159,8 @@ class SnNetworkProvider {
platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}';
} else if (Platform.isWindows) {
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
platformInfo = 'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
platformInfo =
'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
} else if (Platform.isLinux) {
final deviceInfo = await DeviceInfoPlugin().linuxInfo;
platformInfo = 'Linux; ${deviceInfo.prettyName}';
@ -148,12 +180,15 @@ class SnNetworkProvider {
final tkLock = Lock();
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);
});
}
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) {
return await _refreshCompleter!.future;
} else {
@ -185,7 +220,8 @@ class SnNetworkProvider {
final payload = b64.decode(rawPayload);
final exp = jsonDecode(payload)['exp'];
if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
log('Access token need refresh, doing it at ${DateTime.now()}');
logging.debug(
'[Auth] Access token need refresh, doing it at ${DateTime.now()}');
final result = await _refreshToken(client.options.baseUrl, rtk);
if (result == null) {
atk = null;
@ -199,12 +235,12 @@ class SnNetworkProvider {
_refreshCompleter!.complete(atk);
return atk;
} else {
log('Access token refresh failed...');
logging.error('[Auth] Access token refresh failed...');
_refreshCompleter!.complete(null);
}
}
} catch (err) {
log('Failed to authenticate user: $err');
logging.error('[Auth] Failed to authenticate user...', err);
_refreshCompleter!.completeError(err);
} finally {
_refreshCompleter = null;
@ -237,7 +273,8 @@ class SnNetworkProvider {
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;
final dio = Dio();

View File

@ -1,12 +1,17 @@
import 'dart:developer';
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/sn_network.dart';
import 'package:surface/types/attachment.dart';
class SnStickerProvider {
late final SnNetworkProvider _sn;
late final DatabaseProvider _dt;
final Map<String, SnSticker?> _cache = {};
final Map<int, List<SnSticker>> stickersByPack = {};
@ -16,6 +21,7 @@ class SnStickerProvider {
SnStickerProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_dt = context.read<DatabaseProvider>();
}
bool hasNotSticker(String alias) {
@ -32,32 +38,54 @@ class SnStickerProvider {
}
}
void putSticker(Iterable<SnSticker> sticker) {
for (final ele in sticker) {
void putSticker(Iterable<SnSticker> stickers) {
for (final ele in stickers) {
_cacheSticker(ele);
}
_saveStickerToLocal(stickers);
_saveStickerPackToLocal(stickers.map((ele) => ele.pack).toSet());
}
Future<SnSticker?> lookupSticker(String alias) async {
// In-memory cache
if (_cache.containsKey(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 {
final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
final sticker = SnSticker.fromJson(resp.data);
_cacheSticker(sticker);
putSticker([sticker]);
return sticker;
} catch (err) {
_cache[alias] = null;
log('[Sticker] Failed to lookup sticker $alias: $err');
logging.warning('[Sticker] Failed to lookup sticker $alias', err);
}
return null;
}
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 {
final resp = await _sn.client.get('/cgi/uc/stickers');
final data = resp.data;
@ -66,8 +94,39 @@ class SnStickerProvider {
_cacheSticker(sticker);
}
} catch (err) {
log('[Sticker] Failed to list stickers: $err');
logging.error('[Sticker] Failed to list stickers...', err);
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);
}
}

View File

@ -13,8 +13,16 @@ class ThemeProvider extends ChangeNotifier {
});
}
void reloadTheme({Color? seedColorOverride, bool? useMaterial3}) {
createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) {
void reloadTheme({
Color? seedColorOverride,
bool? useMaterial3,
String? customFonts,
}) {
createAppThemeSet(
seedColorOverride: seedColorOverride,
useMaterial3: useMaterial3,
customFonts: customFonts,
).then((value) {
theme = value;
notifyListeners();
});

View File

@ -0,0 +1,56 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:surface/logger.dart';
// TODO self host translate api
const kTranslateApiBaseUrl = 'https://translate.disroot.org';
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');
}
}

View File

@ -1,19 +1,44 @@
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/providers/database.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
class UserDirectoryProvider {
late final SnNetworkProvider _sn;
late final DatabaseProvider _dt;
UserDirectoryProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_dt = context.read<DatabaseProvider>();
}
final Map<String, int> _idCache = {};
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 {
// 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 plannedQuery = <int>{};
for (var idx = 0; idx < out.length; idx++) {
@ -27,8 +52,30 @@ class UserDirectoryProvider {
plannedQuery.add(item);
}
}
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();
// On-disk cache
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;
for (var idx = 0; idx < out.length; idx++) {
if (out[idx] != null) continue;
@ -40,17 +87,29 @@ class UserDirectoryProvider {
_idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id;
sideIdx++;
}
if (respDecoded.isNotEmpty) _saveToLocal(respDecoded);
return out;
}
Future<SnAccount?> getAccount(dynamic id) async {
// In-memory cache
if (id is String && _idCache.containsKey(id)) {
id = _idCache[id];
}
if (_cache.containsKey(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 {
final resp = await _sn.client.get('/cgi/id/users/$id');
final account = SnAccount.fromJson(
@ -58,16 +117,42 @@ class UserDirectoryProvider {
);
_cache[account.id] = account;
if (id is String) _idCache[id] = account.id;
_saveToLocal([account]);
return account;
} catch (err) {
return null;
}
}
SnAccount? getAccountFromCache(dynamic id) {
SnAccount? getFromCache(dynamic id) {
if (id is String && _idCache.containsKey(id)) {
id = _idCache[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);
}
}

View File

@ -1,8 +1,9 @@
import 'dart:developer';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
@ -30,12 +31,38 @@ class UserProvider extends ChangeNotifier {
notifyListeners();
refreshUser().then((value) async {
if (value != null) {
log('Logged in as @${value.name}');
log('Atk: ${await atk}');
logging.info('[Auth] Logged in as @${value.name}');
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 {
final resp = await _sn.client.get('/cgi/id/users/me');
final out = SnAccount.fromJson(resp.data);
@ -48,7 +75,13 @@ class UserProvider extends ChangeNotifier {
}
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;
user = null;
notifyListeners();

View File

@ -1,12 +1,15 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:provider/provider.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/websocket.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class WebSocketProvider extends ChangeNotifier {
@ -30,7 +33,7 @@ class WebSocketProvider extends ChangeNotifier {
if (isConnected) return;
if (!_ua.isAuthorized) return;
log('[WebSocket] Connecting to the server...');
logging.debug('[WebSocket] Connecting to the server...');
await connect();
}
@ -39,7 +42,7 @@ class WebSocketProvider extends ChangeNotifier {
Future<void> connect({noRetry = false}) async {
if (_connectCompleter != null) {
await _connectCompleter!.future;
_connectCompleter = null;
return;
}
if (!_ua.isAuthorized) return;
@ -52,27 +55,39 @@ class WebSocketProvider extends ChangeNotifier {
final atk = await _sn.getFreshAtk();
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;
notifyListeners();
conn = WebSocketChannel.connect(uri);
conn = kIsWeb
? WebSocketChannel.connect(uri)
: IOWebSocketChannel.connect(
uri,
headers: {'Authorization': 'Bearer $atk'},
);
await conn!.ready;
_wsStream = conn!.stream.asBroadcastStream();
listen();
log('[WebSocket] Connected to server!');
logging.info('[WebSocket] Connected to server!');
isConnected = true;
} catch (err) {
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 {
log('Failed to connect to websocket: $err');
logging.error('[WebSocket] Failed to connect to websocket...', err);
}
if (!noRetry) {
log('Retry connecting to websocket in 3 seconds...');
logging.warning(
'[WebSocket] Retry connecting to websocket in 3 seconds...',
);
return Future.delayed(
const Duration(seconds: 3),
() => connect(noRetry: true),
@ -100,7 +115,9 @@ class WebSocketProvider extends ChangeNotifier {
_wsStream!.listen(
(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);
},
onDone: () {

View File

@ -4,12 +4,17 @@ import 'package:go_router/go_router.dart';
import 'package:surface/screens/abuse_report.dart';
import 'package:surface/screens/account.dart';
import 'package:surface/screens/account/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/keypairs.dart';
import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/screens/account/profile_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/publishers.dart';
import 'package:surface/screens/account/auth_tickets.dart';
import 'package:surface/screens/album.dart';
import 'package:surface/screens/auth/login.dart';
import 'package:surface/screens/auth/register.dart';
@ -21,11 +26,14 @@ import 'package:surface/screens/chat/room.dart';
import 'package:surface/screens/explore.dart';
import 'package:surface/screens/friend.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_list.dart';
import 'package:surface/screens/notification.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_shuffle.dart';
import 'package:surface/screens/post/publisher_page.dart';
import 'package:surface/screens/post/post_search.dart';
import 'package:surface/screens/realm.dart';
@ -63,10 +71,15 @@ final _appRoutes = [
builder: (context, state) => const ExploreScreen(),
routes: [
GoRoute(
path: '/write/:mode',
path: '/draft',
name: 'postDraftBox',
builder: (context, state) => const PostDraftBox(),
),
GoRoute(
path: '/write',
name: 'postEditor',
builder: (context, state) => PostEditorScreen(
mode: state.pathParameters['mode']!,
mode: state.uri.queryParameters['mode'],
postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '',
),
@ -79,6 +92,11 @@ final _appRoutes = [
extraProps: state.extra as PostEditorExtra?,
),
),
GoRoute(
path: '/shuffle',
name: 'postShuffle',
builder: (context, state) => const PostShuffleScreen(),
),
GoRoute(
path: '/search',
name: 'postSearch',
@ -105,55 +123,81 @@ final _appRoutes = [
],
),
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) => const AccountScreen(),
routes: [
GoRoute(
path: '/wallet',
name: 'accountWallet',
builder: (context, state) => const WalletScreen(),
path: '/account',
name: 'account',
builder: (context, state) => const AccountScreen(),
routes: [
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(),
),
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: '/settings',
name: 'accountSettings',
builder: (context, state) => AccountSettingsScreen(),
),
GoRoute(
path: '/profile/:name',
name: 'accountProfilePage',
pageBuilder: (context, state) => NoTransitionPage(
child: UserScreen(name: state.pathParameters['name']!),
),
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',
@ -249,6 +293,11 @@ final _appRoutes = [
),
],
),
GoRoute(
path: '/debug/logging',
name: 'debugLogging',
builder: (context, state) => const DebugLoggingScreen(),
),
GoRoute(
path: '/album',
name: 'album',

View File

@ -11,7 +11,9 @@ import 'package:surface/providers/database.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.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_status.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
@ -112,7 +114,14 @@ class _AuthorizedAccountScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountImage(content: ua.user!.avatar, radius: 28),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountImage(content: ua.user!.avatar, radius: 28),
_AccountStatusWidget(account: ua.user!),
],
),
const Gap(8),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
@ -125,8 +134,14 @@ class _AuthorizedAccountScreen extends StatelessWidget {
.textStyle(Theme.of(context).textTheme.bodySmall!),
],
),
Text(ua.user!.description)
.textStyle(Theme.of(context).textTheme.bodyMedium!),
Text(
(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!),
],
),
);
@ -172,6 +187,46 @@ class _AuthorizedAccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('accountWallet');
},
),
ListTile(
title: Text('accountBadges').tr(),
subtitle: Text('accountBadgesDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.award_star),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountBadges');
},
),
ListTile(
title: Text('accountKeyPairs').tr(),
subtitle: Text('accountKeyPairsDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.key),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountKeyPairs');
},
),
ListTile(
title: Text('accountActionEvent').tr(),
subtitle: Text('accountActionEventDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.history),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountActionEvents');
},
),
ListTile(
title: Text('accountAuthTickets').tr(),
subtitle: Text('accountAuthTicketsDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.confirmation_number),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountAuthTickets');
},
),
ListTile(
title: Text('accountSettings').tr(),
subtitle: Text('accountSettingsSubtitle').tr(),
@ -264,3 +319,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();
}
});
},
);
}
}

View File

@ -54,14 +54,20 @@ class AccountSettingsScreen extends StatelessWidget {
child: DropdownButton2<Locale?>(
isExpanded: true,
items: [
...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
...EasyLocalization.of(context)!
.supportedLocales
.mapIndexed((idx, ele) {
return DropdownMenuItem<Locale?>(
value: Locale.parse(ele.toString()),
child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
child: Text('${ele.languageCode}-${ele.countryCode}')
.fontSize(14),
);
}),
],
value: ua.user?.language != null ? Locale.parse(ua.user!.language) : Locale.parse('en-US'),
value: ua.user?.language != null
? (Locale.tryParse(ua.user!.language) ??
Locale.parse('en-US'))
: Locale.parse('en-US'),
onChanged: (Locale? value) {
if (value == null) return;
_setAccountLanguage(context, value);
@ -81,6 +87,16 @@ 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('accountProfileEdit').tr(),
subtitle: Text('accountProfileEditSubtitle').tr(),

View File

@ -0,0 +1,160 @@
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(
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(),
),
);
},
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,186 @@
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(
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);
},
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,140 @@
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(
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,
),
);
},
),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,322 @@
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(
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(),
),
],
);
}
}

View File

@ -0,0 +1,106 @@
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(
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();
},
),
);
},
),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountNotifyPrefsScreen extends StatelessWidget {
const AccountNotifyPrefsScreen({super.key});
@override
Widget build(BuildContext context) {
return AppScaffold();
}
}

View File

@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:gap/gap.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
@ -36,11 +37,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _descriptionController = TextEditingController();
final _timezoneController = TextEditingController();
final _genderController = TextEditingController();
final _pronounsController = TextEditingController();
final _locationController = TextEditingController();
final _birthdayController = TextEditingController();
String? _avatar;
String? _banner;
DateTime? _birthday;
List<(String, String)>? _links;
bool _isBusy = false;
@ -51,43 +57,46 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
final prof = ua.user!;
_usernameController.text = prof.name;
_nicknameController.text = prof.nick;
_descriptionController.text = prof.description;
_descriptionController.text = prof.profile!.description;
_firstNameController.text = prof.profile!.firstName;
_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;
_banner = prof.banner;
if (prof.profile!.birthday != null) {
_birthdayController.text = DateFormat(_kDateFormat).format(
prof.profile!.birthday!.toLocal(),
);
_links = prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList();
_birthday = prof.profile!.birthday?.toLocal();
if (_birthday != null) {
_birthdayController.text = DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal());
}
}
void _selectBirthday() async {
await showCupertinoModalPopup<DateTime?>(
context: context,
builder: (BuildContext context) => Container(
height: 216,
padding: const EdgeInsets.only(top: 6.0),
margin: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
top: false,
child: CupertinoDatePicker(
initialDateTime: _birthday?.toLocal(),
mode: CupertinoDatePickerMode.date,
use24hFormat: true,
onDateTimeChanged: (DateTime newDate) {
setState(() {
_birthday = newDate;
_birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
});
},
builder:
(BuildContext context) => Container(
height: 216,
padding: const EdgeInsets.only(top: 6.0),
margin: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
top: false,
child: CupertinoDatePicker(
initialDateTime: _birthday?.toLocal(),
mode: CupertinoDatePickerMode.date,
use24hFormat: true,
onDateTimeChanged: (DateTime newDate) {
setState(() {
_birthday = newDate;
_birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
});
},
),
),
),
),
),
);
}
@ -96,32 +105,42 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
if (image == null) return;
if (!mounted) return;
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios =
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
final skipCrop = image.path.endsWith('.gif');
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;
final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
try {
final attachment = await attach.directUploadOne(
rawBytes,
@ -133,10 +152,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
if (!mounted) return;
final sn = context.read<SnNetworkProvider>();
await sn.client.put(
'/cgi/id/users/me/$place',
data: {'attachment': attachment.rid},
);
await sn.client.put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid});
if (!mounted) return;
final ua = context.read<UserProvider>();
@ -166,7 +182,14 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
'description': _descriptionController.value.text,
'first_name': _firstNameController.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(),
'links': {
for (final link in _links!.where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty)) link.$1: link.$2,
},
},
);
@ -197,6 +220,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
_firstNameController.dispose();
_lastNameController.dispose();
_descriptionController.dispose();
_timezoneController.dispose();
_genderController.dispose();
_pronounsController.dispose();
_locationController.dispose();
_birthdayController.dispose();
super.dispose();
}
@ -208,10 +235,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
final sn = context.read<SnNetworkProvider>();
return AppScaffold(
appBar: AppBar(
leading: const PageBackButton(),
title: Text('screenAccountProfileEdit').tr(),
),
appBar: AppBar(leading: const PageBackButton(), title: Text('screenAccountProfileEdit').tr()),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -230,12 +254,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
aspectRatio: 16 / 9,
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: _banner != null
? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!),
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
child:
_banner != null
? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover)
: const SizedBox.shrink(),
),
),
),
@ -262,6 +284,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
).padding(horizontal: padding),
const Gap(8 + 28),
Column(
spacing: 4,
children: [
TextField(
readOnly: true,
@ -271,16 +294,13 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
labelText: 'fieldUsername'.tr(),
helperText: 'fieldUsernameCannotEditHint'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
TextField(
controller: _nicknameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(),
),
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldNickname'.tr()),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
Row(
children: [
Flexible(
@ -291,6 +311,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldFirstName'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Gap(8),
@ -302,31 +323,165 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
border: const UnderlineInputBorder(),
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(
controller: _descriptionController,
keyboardType: TextInputType.multiline,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldDescription'.tr(),
),
decoration: InputDecoration(border: const UnderlineInputBorder(), 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(
controller: _birthdayController,
readOnly: true,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'fieldBirthday'.tr(),
),
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldBirthday'.tr()),
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),
const Gap(12),
@ -340,6 +495,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
),
],
).padding(horizontal: padding),
Gap(MediaQuery.of(context).padding.bottom),
],
),
),

View File

@ -18,10 +18,13 @@ import 'package:surface/types/account.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.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/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': (
'badgeCompanyStaff',
Symbols.tools_wrench,
@ -32,6 +35,31 @@ const Map<String, (String, IconData, Color)> kBadgesMeta = {
Symbols.flag,
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 {
@ -43,7 +71,8 @@ class UserScreen extends StatefulWidget {
State<UserScreen> createState() => _UserScreenState();
}
class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
class _UserScreenState extends State<UserScreen>
with SingleTickerProviderStateMixin {
late final ScrollController _scrollController = ScrollController();
SnAccount? _account;
@ -64,13 +93,18 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
}
}
Future<List<SnCheckInRecord>> _getCheckInRecords() async {
List<SnCheckInRecord>? _records;
Future<void> _getCheckInRecords() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
return List.from(
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
);
final resp =
await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
setState(() {
_records = List.from(
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
);
});
} catch (err) {
if (mounted) context.showErrorDialog(err);
rethrow;
@ -98,7 +132,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
Future<void> _fetchPublishers() async {
try {
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(
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
);
@ -144,7 +179,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
'related': _account!.name,
});
if (!mounted) return;
context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
context.showSnackbar(
'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -160,9 +196,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
try {
final rel = context.read<SnRelationshipProvider>();
await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
await rel.updateRelationship(
_account!.id, 1, _accountRelationship?.permNodes ?? {});
if (!mounted) return;
context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
context.showSnackbar(
'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -188,12 +226,14 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
double _appBarBlur = 0.0;
late final _appBarWidth = MediaQuery.of(context).size.width;
late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
late final _appBarHeight =
(_appBarWidth * kBannerAspectRatio).roundToDouble();
void _updateAppBarBlur() {
if (_scrollController.offset > _appBarHeight) return;
setState(() {
_appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
_appBarBlur =
(_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
});
}
@ -205,6 +245,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
_fetchStatus();
_fetchPublishers();
_getCheckInRecords();
try {
final rel = context.read<SnRelationshipProvider>();
@ -260,18 +301,20 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
text: TextSpan(children: [
TextSpan(
text: _account!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Colors.white,
shadows: labelShadows,
),
style:
Theme.of(context).textTheme.titleLarge!.copyWith(
color: Colors.white,
shadows: labelShadows,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: '@${_account!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Colors.white,
shadows: labelShadows,
),
style:
Theme.of(context).textTheme.bodySmall!.copyWith(
color: Colors.white,
shadows: labelShadows,
),
),
]),
),
@ -280,14 +323,21 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
? Stack(
fit: StackFit.expand,
children: [
UniversalImage(
sn.getAttachmentUrl(_account!.banner),
fit: BoxFit.cover,
height: imageHeight,
width: _appBarWidth,
cacheHeight: imageHeight,
cacheWidth: _appBarWidth,
),
if (_account!.banner.isNotEmpty)
UniversalImage(
sn.getAttachmentUrl(_account!.banner),
fit: BoxFit.cover,
height: imageHeight,
width: _appBarWidth,
cacheHeight: imageHeight,
cacheWidth: _appBarWidth,
)
else
Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh,
),
Positioned(
top: 0,
left: 0,
@ -339,7 +389,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
PopupMenuButton(
padding: EdgeInsets.zero,
style: ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
),
itemBuilder: (context) => [
PopupMenuItem(
@ -389,27 +440,41 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
),
],
).padding(right: 8),
const Gap(12),
Text(_account!.description).padding(horizontal: 8),
if (_account!.profile!.description.isNotEmpty)
const Gap(12)
else
const Gap(8),
if (_account!.profile!.description.isNotEmpty)
Text(_account!.profile!.description).padding(horizontal: 8),
const Gap(4),
Card(
child: Row(
children: [
Icon(
Symbols.circle,
fill: 1,
(_status?.isDisturbable ?? true)
? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (_status?.isOnline ?? false) ? 1 : 0,
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),
const Gap(8),
Text(
_status != null
? _status!.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
? (_status!.status?.label.isNotEmpty ?? false)
? _status!.status!.label
: _status!.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(),
),
if (_status != null && !_status!.isOnline && _status!.lastSeenAt != null)
if (_status != null &&
!_status!.isOnline &&
_status!.lastSeenAt != null)
Text(
'accountStatusLastSeen'.tr(args: [
_status!.lastSeenAt != null
@ -426,27 +491,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
Wrap(
children: _account!.badges
.map(
(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,
),
),
(ele) => AccountBadge(badge: ele),
)
.toList(),
).padding(horizontal: 8),
@ -458,7 +503,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
children: [
const Icon(Symbols.calendar_add_on),
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(
@ -475,6 +522,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(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@ -491,17 +576,24 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
children: [
const Icon(Symbols.star),
const Gap(8),
Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
Text(
'Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
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),
Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 160),
child: LinearProgressIndicator(
value: calcLevelUpProgress(_account?.profile?.experience ?? 0),
value: calcLevelUpProgress(
_account?.profile?.experience ?? 0),
borderRadius: BorderRadius.circular(8),
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainer,
).alignment(Alignment.centerLeft),
),
],
@ -511,24 +603,46 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
],
).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()),
const SliverGap(12),
SliverToBoxAdapter(
child: FutureBuilder<List<SnCheckInRecord>>(
future: _getCheckInRecords(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
if (snapshot.data!.length <= 1) {
child: Builder(
builder: (context) {
if (_records == null) return const SizedBox.shrink();
if (_records!.length <= 1) {
return Text(
'accountCheckInNoRecords',
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(
width: double.infinity,
height: 240,
child: CheckInRecordChart(records: records),
child: CheckInRecordChart(records: _records!),
).padding(
right: 24,
left: 16,
@ -540,45 +654,55 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
const SliverGap(12),
SliverToBoxAdapter(child: const Divider()),
const SliverGap(12),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
SizedBox(
height: 80,
width: double.infinity,
child: ListView(
padding: EdgeInsets.symmetric(horizontal: 8),
scrollDirection: Axis.horizontal,
children: [
for (final badge in _account?.badges ?? [])
SizedBox(
width: 280,
child: Card(
child: ListTile(
leading: Icon(
kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
color: kBadgesMeta[badge.type]?.$3,
fill: 1,
if (_account?.badges.isNotEmpty ?? false)
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('accountBadge')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
SizedBox(
height: 80,
width: double.infinity,
child: ListView(
padding: EdgeInsets.symmetric(horizontal: 8),
scrollDirection: Axis.horizontal,
children: [
for (final badge in _account?.badges ?? [])
SizedBox(
width: 280,
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),
SliverToBoxAdapter(child: const Divider()),
SliverList.builder(
@ -664,7 +788,8 @@ class CheckInRecordChart extends StatelessWidget {
),
)
.toList(),
getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh,
getTooltipColor: (_) =>
Theme.of(context).colorScheme.surfaceContainerHigh,
),
),
titlesData: FlTitlesData(

View File

@ -68,16 +68,19 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
setState(() => _isBusy = true);
try {
await sn.client.put('/cgi/co/publishers/${widget.name}', data: {
'avatar': _avatar,
'banner': _banner,
'nick': _nickController.text,
'name': _nameController.text,
'description': _descriptionController.text,
});
await sn.client.put(
'/cgi/co/publishers/${widget.name}',
data: {
'avatar': _avatar,
'banner': _banner,
'nick': _nickController.text,
'name': _nameController.text,
'description': _descriptionController.text,
},
);
if (mounted) Navigator.pop(context, true);
} catch (err) {
if(mounted) context.showErrorDialog(err);
if (mounted) context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
@ -97,7 +100,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
_banner = ua.user!.banner;
_nickController.text = ua.user!.nick;
_nameController.text = ua.user!.name;
_descriptionController.text = ua.user!.description;
_descriptionController.text = ua.user!.profile!.description;
setState(() {});
}
@ -108,32 +111,42 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
if (image == null) return;
if (!mounted) return;
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
final aspectRatios =
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
? await showCupertinoImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
)
: await showMaterialImageCropper(
// ignore: use_build_context_synchronously
context,
allowedAspectRatios: aspectRatios,
imageProvider: imageProvider,
);
final skipCrop = image.path.endsWith('.gif');
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;
final attach = context.read<SnAttachmentProvider>();
setState(() => _isBusy = true);
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
try {
final attachment = await attach.directUploadOne(
rawBytes,
@ -178,10 +191,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
final sn = context.read<SnNetworkProvider>();
return AppScaffold(
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenAccountPublisherEdit').tr(),
),
appBar: AppBar(leading: PageBackButton(), title: Text('screenAccountPublisherEdit').tr()),
body: SingleChildScrollView(
child: Column(
children: [
@ -199,12 +209,10 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
aspectRatio: 16 / 9,
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: _banner != null
? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!),
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
child:
_banner != null
? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover)
: const SizedBox.shrink(),
),
),
),
@ -242,9 +250,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
const Gap(4),
TextField(
controller: _nickController,
decoration: InputDecoration(
labelText: 'fieldNickname'.tr(),
),
decoration: InputDecoration(labelText: 'fieldNickname'.tr()),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(4),
@ -252,9 +258,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
controller: _descriptionController,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
labelText: 'fieldDescription'.tr(),
),
decoration: InputDecoration(labelText: 'fieldDescription'.tr()),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
@ -275,7 +279,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
icon: const Icon(Symbols.save),
),
],
)
),
],
).padding(horizontal: 24, vertical: 12),
),

View File

@ -109,7 +109,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
_nameController.text = ua.user!.name;
_nickController.text = ua.user!.nick;
_descriptionController.text = ua.user!.description;
_descriptionController.text = ua.user!.profile!.description;
}
@override

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
@ -41,6 +42,7 @@ class _ChatScreenState extends State<ChatScreen> {
Future<void> _fetchWhatsNew() async {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/im/whats-new');
if (resp.data == null) return;
final List<dynamic> out = resp.data;
setState(() {
_unreadCounts = {for (var v in out) v['channel_id']: v['count']};
@ -72,18 +74,20 @@ class _ChatScreenState extends State<ChatScreen> {
if (!mounted) return;
final ud = context.read<UserDirectoryProvider>();
final idSet = <int>{};
for (final channel in channels) {
if (channel.type == 1) {
await ud.listAccount(
idSet.addAll(
channel.members
?.cast<SnChannelMember?>()
.map((ele) => ele?.accountId)
.where((ele) => ele != null)
.toSet() ??
{},
.cast<int>() ??
[],
);
}
}
if (idSet.isNotEmpty) await ud.listAccount(idSet);
if (mounted) setState(() => _channels = channels);
})
@ -135,9 +139,30 @@ class _ChatScreenState extends State<ChatScreen> {
_fetchWhatsNew();
}
void _onTapChannel(SnChannel channel) {
final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP);
if (doExpand) {
setState(() => _focusChannel = channel);
return;
}
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (mounted) {
_unreadCounts?[channel.id] = 0;
setState(() => _unreadCounts?[channel.id] = 0);
_refreshChannels(noRemote: true);
}
});
}
@override
Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) {
@ -179,7 +204,6 @@ class _ChatScreenState extends State<ChatScreen> {
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28),
@ -188,7 +212,6 @@ class _ChatScreenState extends State<ChatScreen> {
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
children: [
Row(
@ -240,108 +263,17 @@ class _ChatScreenState extends State<ChatScreen> {
final channel = _channels![idx];
final lastMessage = _lastMessages?[channel.id];
if (channel.type == 1) {
final otherMember =
channel.members?.cast<SnChannelMember?>().firstWhere(
(ele) => ele?.accountId != ua.user?.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)
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: () {
if (doExpand) {
setState(() => _focusChannel = channel);
return;
}
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (mounted) _refreshChannels(noRemote: true);
});
},
);
}
return ListTile(
title: Row(
children: [
Expanded(child: Text(channel.name)),
const Gap(8),
if (_unreadCounts?[channel.id] != null)
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: null,
fallbackWidget: const Icon(Symbols.chat, size: 20),
),
return _ChatChannelEntry(
channel: channel,
lastMessage: lastMessage,
unreadCount: _unreadCounts?[channel.id],
onTap: () {
if (doExpand) {
_unreadCounts?[channel.id] = 0;
setState(() => _focusChannel = channel);
return;
}
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (value == true) _refreshChannels(noRemote: true);
});
_onTapChannel(channel);
},
);
},
@ -376,3 +308,100 @@ class _ChatScreenState extends State<ChatScreen> {
return chatList;
}
}
class _ChatChannelEntry extends StatelessWidget {
final SnChannel channel;
final int? unreadCount;
final SnChatMessage? lastMessage;
final Function? onTap;
const _ChatChannelEntry({
required this.channel,
this.unreadCount,
this.lastMessage,
this.onTap,
});
@override
Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
final otherMember = channel.type == 1
? 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(),
);
}
}

View File

@ -57,10 +57,10 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/im/channels/${_channel!.keyPath}/members/me');
_profile = SnChannelMember.fromJson(resp.data);
_notifyLevel = _profile!.notify;
final ct = context.read<ChatChannelProvider>();
final resp = await ct.getChannelProfile(_channel!);
_profile = resp;
_notifyLevel = resp.notify;
if (!mounted) return;
final ud = context.read<UserDirectoryProvider>();
await ud.getAccount(_profile!.accountId);
@ -102,10 +102,12 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
if (!mounted) return;
try {
final ct = context.read<ChatChannelProvider>();
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.alias}/me',
);
await ct.removeLocalChannel(_channel!);
if (!mounted) return;
Navigator.pop(context, false);
} catch (err) {
@ -129,12 +131,15 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
setState(() => _isUpdatingNotifyLevel = true);
try {
final ct = context.read<ChatChannelProvider>();
final sn = context.read<SnNetworkProvider>();
await sn.client.put(
final resp = await sn.client.put(
'/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
data: {'notify_level': value},
);
_profile = SnChannelMember.fromJson(resp.data);
_notifyLevel = value;
await ct.updateChannelProfile(_profile!);
if (!mounted) return;
context.showSnackbar('channelNotifyLevelApplied'.tr());
} catch (err) {
@ -245,7 +250,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('channelDetailPersonalRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
Text('channelDetailPersonalRegion')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
ListTile(
leading: const Icon(Symbols.notifications),
trailing: DropdownButtonHideUnderline(
@ -284,14 +293,14 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
),
ListTile(
leading: AccountImage(
content: ud.getAccountFromCache(_profile!.accountId)?.avatar,
content: ud.getFromCache(_profile!.accountId)?.avatar,
radius: 18,
),
trailing: const Icon(Symbols.chevron_right),
title: Text('channelEditProfile').tr(),
subtitle: Text(
(_profile?.nick?.isEmpty ?? true)
? ud.getAccountFromCache(_profile!.accountId)!.nick
? ud.getFromCache(_profile!.accountId)!.nick
: _profile!.nick!,
),
contentPadding: const EdgeInsets.only(left: 20, right: 20),
@ -303,7 +312,8 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
trailing: const Icon(Symbols.chevron_right),
title: Text('channelActionLeave').tr(),
subtitle: Text('channelActionLeaveDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
onTap: _leaveChannel,
),
],
@ -311,7 +321,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('channelDetailMemberRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
Text('channelDetailMemberRegion')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
ListTile(
leading: const Icon(Symbols.group),
trailing: const Icon(Symbols.chevron_right),
@ -333,7 +347,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('channelDetailAdminRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
Text('channelDetailAdminRegion')
.bold()
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
ListTile(
leading: const Icon(Symbols.edit),
trailing: const Icon(Symbols.chevron_right),
@ -379,10 +397,12 @@ class _ChannelProfileDetailDialog extends StatefulWidget {
});
@override
State<_ChannelProfileDetailDialog> createState() => _ChannelProfileDetailDialogState();
State<_ChannelProfileDetailDialog> createState() =>
_ChannelProfileDetailDialogState();
}
class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog> {
class _ChannelProfileDetailDialogState
extends State<_ChannelProfileDetailDialog> {
bool _isBusy = false;
final TextEditingController _nickController = TextEditingController();
@ -391,11 +411,14 @@ class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog
setState(() => _isBusy = true);
try {
final ct = context.read<ChatChannelProvider>();
final sn = context.read<SnNetworkProvider>();
await sn.client.put(
final resp = await sn.client.put(
'/cgi/im/channels/${widget.channel.keyPath}/members/me',
data: {'nick': _nickController.text},
);
final out = SnChannelMember.fromJson(resp.data);
await ct.updateChannelProfile(out);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
@ -457,7 +480,8 @@ class _ChannelMemberListWidget extends StatefulWidget {
const _ChannelMemberListWidget({required this.channel});
@override
State<_ChannelMemberListWidget> createState() => _ChannelMemberListWidgetState();
State<_ChannelMemberListWidget> createState() =>
_ChannelMemberListWidgetState();
}
class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
@ -472,10 +496,12 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
try {
final ud = context.read<UserDirectoryProvider>();
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/im/channels/${widget.channel.keyPath}/members', queryParameters: {
'take': 10,
'offset': _members.length,
});
final resp = await sn.client.get(
'/cgi/im/channels/${widget.channel.keyPath}/members',
queryParameters: {
'take': 10,
'offset': _members.length,
});
final out = List<SnChannelMember>.from(
resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [],
);
@ -533,7 +559,9 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
children: [
const Icon(Symbols.group, size: 24),
const Gap(16),
Text('channelMemberManage').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
Text('channelMemberManage')
.tr()
.textStyle(Theme.of(context).textTheme.titleLarge!),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Expanded(
@ -544,7 +572,8 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
},
child: InfiniteList(
itemCount: _members.length,
hasReachedMax: _totalCount != null && _members.length >= _totalCount!,
hasReachedMax:
_totalCount != null && _members.length >= _totalCount!,
isLoading: _isBusy,
onFetchData: _fetchMembers,
itemBuilder: (context, index) {
@ -552,10 +581,10 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
return ListTile(
contentPadding: const EdgeInsets.only(right: 24, left: 16),
leading: AccountImage(
content: ud.getAccountFromCache(member.accountId)?.avatar,
content: ud.getFromCache(member.accountId)?.avatar,
),
title: Text(
ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
),
subtitle: Text(member.nick ?? 'unknown'.tr()),
trailing: SizedBox(
@ -565,7 +594,8 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
onPressed: _isUpdating ? null : () => _deleteMember(member),
onPressed:
_isUpdating ? null : () => _deleteMember(member),
icon: const Icon(Symbols.person_remove),
),
],

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
@ -13,11 +14,13 @@ import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/notification.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.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/chat_message.dart';
import 'package:surface/widgets/chat/chat_message_input.dart';
@ -57,6 +60,11 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
late final ChatMessageController _messageController;
late final NotificationProvider _nty = context.read<NotificationProvider>();
late final WebSocketProvider _ws = context.read<WebSocketProvider>();
bool _isEncrypted = false;
StreamSubscription? _wsSubscription;
// TODO fetch user identity and ask them to join the channel or not
@ -84,6 +92,20 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
orElse: () => null,
);
}
if (!mounted) return;
_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) {
if (!mounted) return;
context.showErrorDialog(err);
@ -209,8 +231,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
]);
});
final ws = context.read<WebSocketProvider>();
_wsSubscription = ws.pk.stream.listen((event) {
_wsSubscription = _ws.pk.stream.listen((event) {
switch (event.method) {
case 'calls.new':
final payload = SnChatCall.fromJson(event.payload!);
@ -232,6 +253,18 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
void dispose() {
_wsSubscription?.cancel();
_messageController.dispose();
_nty.skippableNotifyChannel = null;
if (_channel != null) {
_ws.conn?.sink.add(
jsonEncode(WebSocketPackage(
method: 'events.unsubscribe',
endpoint: 'im',
payload: {
'channel_id': _channel!.id,
},
)),
);
}
super.dispose();
}
@ -244,11 +277,19 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
appBar: AppBar(
title: Text(
_channel?.type == 1
? ud.getAccountFromCache(_otherMember?.accountId)?.nick ??
_channel!.name
? ud.getFromCache(_otherMember?.accountId)?.nick ?? _channel!.name
: _channel?.name ?? 'loading'.tr(),
),
actions: [
IconButton(
onPressed: () {
setState(() => _isEncrypted = !_isEncrypted);
_inputGlobalKey.currentState?.setEncrypted(_isEncrypted);
},
icon: _isEncrypted
? const Icon(Symbols.lock)
: const Icon(Symbols.no_encryption),
),
IconButton(
icon: _ongoingCall == null
? const Icon(Symbols.call)
@ -282,7 +323,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
builder: (context, _) {
return Column(
children: [
LoadingIndicator(isActive: _isBusy),
LoadingIndicator(
isActive: _isBusy || _messageController.isAggressiveLoading,
),
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: MaterialBanner(
@ -308,8 +351,8 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
if (_messageController.isPending)
Expanded(
child: const CircularProgressIndicator().center(),
),
if (!_messageController.isPending)
)
else
Expanded(
child: InfiniteList(
reverse: true,

View File

@ -1,4 +1,3 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.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/post/fediverse_post_item.dart';
import 'package:surface/widgets/post/post_item.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 = {
'technology': Symbols.tools_wrench,
'gaming': Symbols.gamepad,
@ -39,17 +47,17 @@ class ExploreScreen extends StatefulWidget {
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>
with SingleTickerProviderStateMixin {
late final TabController _tabController =
TabController(length: 4, vsync: this);
with TickerProviderStateMixin {
late TabController _tabController = TabController(
length: kPostChannels.length,
vsync: this,
);
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);
@ -69,14 +77,70 @@ class _ExploreScreenState extends State<ExploreScreen>
}
}
void _clearFilter() {
_selectedCategory = null;
final List<SnRealm> _realms = List.empty(growable: true);
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
void initState() {
_fetchCategories();
super.initState();
_tabListen();
_fetchCategories();
_fetchRealms();
}
@override
@ -86,11 +150,12 @@ class _ExploreScreenState extends State<ExploreScreen>
}
Future<void> refreshPosts() async {
await _listKeys[_tabController.index].currentState?.refreshPosts();
await _listKey.currentState?.refreshPosts();
}
@override
Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return AppScaffold(
floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: ExpandableFab(
@ -111,7 +176,6 @@ class _ExploreScreenState extends State<ExploreScreen>
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28),
@ -120,90 +184,39 @@ class _ExploreScreenState extends State<ExploreScreen>
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
children: [
Row(
children: [
Text('writePostTypeStory').tr(),
Text('writePost').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeStory'.tr(),
tooltip: 'writePost'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'stories',
}).then((value) {
GoRouter.of(context).pushNamed('postEditor').then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.post_rounded),
child: const Icon(Symbols.edit),
),
],
),
Row(
children: [
Text('writePostTypeArticle').tr(),
Text('postDraftBox').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeArticle'.tr(),
tooltip: 'postDraftBox'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'articles',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
GoRouter.of(context).pushNamed('postDraftBox');
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.news),
),
],
),
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),
child: const Icon(Symbols.box_edit),
),
],
),
@ -215,27 +228,91 @@ class _ExploreScreenState extends State<ExploreScreen>
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
leading: AutoAppBarLeading(),
title: Text('screenExplore').tr(),
leading:
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');
},
),
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,
snap: true,
actions: [
IconButton(
icon: const Icon(Symbols.category),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => _PostCategoryPickerPopup(
categories: _categories,
selected: _selectedCategory,
),
).then((value) {
if (value != null && context.mounted) {
_selectedCategory = value == false ? null : value;
refreshPosts();
}
});
},
style: _showCategories
? ButtonStyle(
foregroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.primary,
),
backgroundColor: MaterialStateProperty.all(
Theme.of(context).colorScheme.secondaryContainer,
),
)
: null,
onPressed: cfg.mixedFeed
? null
: () {
_toggleShowCategories();
},
),
IconButton(
icon: const Icon(Symbols.search),
@ -245,123 +322,84 @@ class _ExploreScreenState extends State<ExploreScreen>
),
const Gap(8),
],
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.globe,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelGlobal',
maxLines: 1,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
bottom: cfg.mixedFeed
? null
: TabBar(
isScrollable: _showCategories,
controller: _tabController,
tabs: _showCategories
? [
for (final category in _categories)
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Icon(
kCategoryIcons[category.alias] ??
Symbols.question_mark,
color: Theme.of(context)
.appBarTheme
.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(
controller: _tabController,
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,
),
],
body: _PostListWidget(
key: _listKey,
),
),
);
@ -369,15 +407,7 @@ class _ExploreScreenState extends State<ExploreScreen>
}
class _PostListWidget extends StatefulWidget {
final String? channel;
final bool withRealm;
final Function onClearFilter;
const _PostListWidget(
{super.key,
this.channel,
this.withRealm = false,
required this.onClearFilter});
const _PostListWidget({super.key});
@override
State<_PostListWidget> createState() => _PostListWidgetState();
@ -386,62 +416,98 @@ class _PostListWidget extends StatefulWidget {
class _PostListWidgetState extends State<_PostListWidget> {
bool _isBusy = false;
final List<SnPost> _posts = List.empty(growable: true);
final List<SnRealm> _realms = List.empty(growable: true);
SnRealm? get realm => _selectedRealm;
final List<SnFeedEntry> _feed = List.empty(growable: true);
SnRealm? _selectedRealm;
int? _postCount;
Future<void> _fetchRealms() async {
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;
}
}
String? _selectedChannel;
SnPostCategory? _selectedCategory;
bool _hasLoadedAll = false;
// Called when using regular feed
Future<void> _fetchPosts() async {
if (_postCount != null && _posts.length >= _postCount!) return;
if (_hasLoadedAll) return;
setState(() => _isBusy = true);
final pt = context.read<SnPostContentProvider>();
final result = await pt.listPosts(
take: 10,
offset: _posts.length,
offset: _feed.length,
categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
channel: widget.channel,
channel: _selectedChannel,
realm: _selectedRealm?.alias,
);
final out = result.$1;
if (!mounted) return;
_postCount = result.$2;
_posts.addAll(out);
final postCount = result.$2;
_feed.addAll(
out.map((ele) => SnFeedEntry(
type: 'interactive.post',
data: ele.toJson(),
createdAt: ele.createdAt)),
);
_hasLoadedAll = postCount >= _feed.length;
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() {
_postCount = null;
_posts.clear();
return _fetchPosts();
_hasLoadedAll = false;
_feed.clear();
final cfg = context.read<ConfigProvider>();
if (cfg.mixedFeed) {
return _fetchFeed();
} else {
return _fetchPosts();
}
}
@override
void initState() {
super.initState();
if (widget.withRealm) {
_fetchRealms().then((_) {
_fetchPosts();
});
final cfg = context.read<ConfigProvider>();
if (cfg.mixedFeed) {
_fetchFeed();
} else {
_fetchPosts();
}
@ -449,178 +515,128 @@ class _PostListWidgetState extends State<_PostListWidget> {
@override
Widget build(BuildContext context) {
return Column(
children: [
if (_selectedCategory != null)
MaterialBanner(
content: Text(
'postFilterWithCategory'.tr(args: [
'postCategory${_selectedCategory!.alias.capitalize()}'.trExists()
? 'postCategory${_selectedCategory!.alias.capitalize()}'
.tr()
: _selectedCategory!.name,
]),
),
leading: Icon(kCategoryIcons[_selectedCategory!.alias] ??
Symbols.question_mark),
actions: [
IconButton(
icon: const Icon(Symbols.clear),
onPressed: () {
widget.onClearFilter.call();
refreshPosts();
},
),
],
padding: const EdgeInsets.only(left: 20, right: 4),
),
if (widget.withRealm)
DropdownButtonHideUnderline(
child: DropdownButton2<SnRealm>(
isExpanded: true,
items: _realms
.map(
(ele) => DropdownMenuItem<SnRealm>(
value: ele,
child: Row(
children: [
AccountImage(
content: ele.avatar,
fallbackWidget: const Icon(Symbols.group, size: 16),
radius: 14,
),
const Gap(8),
Text(
ele.name,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
)
.toList(),
value: _selectedRealm,
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),
final cfg = context.watch<ConfigProvider>();
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () => refreshPosts(),
child: InfiniteList(
padding: EdgeInsets.only(top: 8),
itemCount: _feed.length,
isLoading: _isBusy,
centerLoading: true,
hasReachedMax: _hasLoadedAll,
onFetchData: cfg.mixedFeed ? _fetchFeed : _fetchPosts,
itemBuilder: (context, idx) {
final ele = _feed[idx];
switch (ele.type) {
case 'interactive.post':
return OpenablePostItem(
data: SnPost.fromJson(ele.data),
maxWidth: 640,
onChanged: (data) {
setState(() {
_feed[idx] = _feed[idx].copyWith(data: data.toJson());
});
},
onDeleted: () {
refreshPosts();
},
);
case 'fediverse.post':
return FediversePostWidget(
data: SnFediversePost.fromJson(ele.data),
maxWidth: 640,
);
case 'reader.news':
return Container(
constraints: BoxConstraints(maxWidth: 640),
child: NewsFeedEntry(data: ele),
);
default:
return Container(
constraints: BoxConstraints(maxWidth: 640),
child: FeedUnknownEntry(data: ele),
);
}
},
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
),
],
),
);
}
}
class _PostCategoryPickerPopup extends StatelessWidget {
final List<SnPostCategory> categories;
final SnPostCategory? selected;
class _PostListRealmPopup extends StatelessWidget {
final List<SnRealm>? realms;
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
Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.category, size: 24),
const Icon(Symbols.tune, size: 24),
const Gap(16),
Text('postCategory')
.tr()
.textStyle(Theme.of(context).textTheme.titleLarge!),
Text('filterFeed', style: Theme.of(context).textTheme.titleLarge)
.tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
ListTile(
leading: const Icon(Symbols.clear),
title: Text('postFilterReset').tr(),
subtitle: Text('postFilterResetDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
onTap: () {
Navigator.pop(context, false);
SwitchListTile(
secondary: const Icon(Symbols.merge_type),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text('mixedFeed').tr(),
subtitle: Text('mixedFeedDescription').tr(),
value: cfg.mixedFeed,
onChanged: (value) {
cfg.mixedFeed = value;
onMixedFeedChanged.call(value);
},
),
const Divider(height: 1),
Expanded(
child: GridView.count(
crossAxisCount: 4,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1,
children: categories
.map(
(ele) => InkWell(
onTap: () {
_selectedCategory = ele;
Navigator.pop(context, ele);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
kCategoryIcons[ele.alias] ?? Symbols.question_mark,
color: selected == ele
? Theme.of(context).colorScheme.primary
: null,
),
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(),
if (!cfg.mixedFeed)
ListTile(
leading: const Icon(Symbols.close),
title: Text('postInGlobal').tr(),
subtitle: Text('postViewInGlobalDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
onUpdate.call(null);
Navigator.pop(context);
},
),
if (!cfg.mixedFeed) const Divider(height: 1),
if (!cfg.mixedFeed)
Expanded(
child: ListView.builder(
itemCount: realms?.length ?? 0,
itemBuilder: (context, idx) {
final realm = realms![idx];
return ListTile(
title: Text(realm.name),
subtitle: Text('@${realm.alias}'),
leading: AccountImage(content: realm.avatar, radius: 18),
onTap: () {
onUpdate.call(realm);
Navigator.pop(context);
},
);
},
),
),
),
],
);
}

View File

@ -1,15 +1,11 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_app_update/flutter_app_update.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:html/parser.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
@ -23,12 +19,14 @@ import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/widget.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/news.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.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:surface/widgets/updater.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:url_launcher/url_launcher_string.dart';
class HomeScreenDashEntry {
final String name;
@ -68,7 +66,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
HomeScreenDashEntry(
name: 'dashEntryTodayNews',
child: _HomeDashTodayNews(),
child: _HomeDashServiceStatus(),
cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
),
];
@ -83,14 +81,25 @@ class _HomeScreenState extends State<HomeScreen> {
body: LayoutBuilder(
builder: (context, constraints) {
return Align(
alignment: constraints.maxWidth > 640 ? Alignment.center : Alignment.topCenter,
alignment: constraints.maxWidth > 640
? Alignment.center
: Alignment.topCenter,
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start,
mainAxisAlignment: constraints.maxWidth > 640
? MainAxisAlignment.center
: MainAxisAlignment.start,
children: [
_HomeDashUpdateWidget(padding: const EdgeInsets.only(bottom: 8, left: 8, right: 8)),
_HomeDashUpdateWidget(
padding: const EdgeInsets.only(
bottom: 8,
left: 8,
right: 8,
),
),
_HomeDashUnconfirmedWidget().padding(horizontal: 8),
_HomeDashSpecialDayWidget().padding(horizontal: 8),
StaggeredGrid.extent(
maxCrossAxisExtent: 280,
@ -115,6 +124,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 {
final EdgeInsets? padding;
@ -123,7 +190,6 @@ class _HomeDashUpdateWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final config = context.watch<ConfigProvider>();
return ListenableBuilder(
listenable: config,
builder: (context, _) {
@ -136,21 +202,15 @@ class _HomeDashUpdateWidget extends StatelessWidget {
leading: Icon(Symbols.update),
title: Text('updateAvailable').tr(),
subtitle: Text(config.updatableVersion!),
trailing: (kIsWeb || Platform.isWindows || Platform.isLinux)
? null
: IconButton(
icon: const Icon(Symbols.arrow_right_alt),
onPressed: () {
final model = UpdateModel(
'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk',
'solian-app-release-${config.updatableVersion!}.apk',
'ic_launcher',
'https://apps.apple.com/us/app/solian/id6499032345',
);
AzhonAppUpdate.update(model);
context.showSnackbar('updateOngoing'.tr());
},
),
trailing: IconButton(
icon: const Icon(Symbols.arrow_right_alt),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => VersionUpdatePopup(),
);
},
),
),
),
);
@ -166,7 +226,8 @@ class _HomeDashSpecialDayWidget extends StatefulWidget {
const _HomeDashSpecialDayWidget();
@override
State<_HomeDashSpecialDayWidget> createState() => _HomeDashSpecialDayWidgetState();
State<_HomeDashSpecialDayWidget> createState() =>
_HomeDashSpecialDayWidgetState();
}
class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
@ -208,7 +269,9 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
margin: EdgeInsets.zero,
child: ListTile(
leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24),
title: Text('pending$name').tr(args: [RelativeTime(context).format(date).replaceFirst('in', '').trim()]),
title: Text('pending$name').tr(args: [
RelativeTime(context).format(date).replaceFirst('in', '').trim()
]),
subtitle: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@ -240,21 +303,31 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
}
}
class _HomeDashTodayNews extends StatefulWidget {
const _HomeDashTodayNews();
class _HomeDashServiceStatus extends StatefulWidget {
const _HomeDashServiceStatus();
@override
State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState();
State<_HomeDashServiceStatus> createState() => _HomeDashServiceStatusState();
}
class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
SnNewsArticle? _article;
class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
Map<String, dynamic>? _statuses;
ServiceStatus? _serviceStatus;
Future<void> _fetchArticle() async {
Future<void> _fetchStatuses() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news/today');
_article = SnNewsArticle.fromJson(resp.data['data']);
final resp = await sn.client.get('/directory/status');
_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) {
if (!mounted) return;
context.showErrorDialog(err);
@ -267,7 +340,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
@override
initState() {
super.initState();
_fetchArticle();
_fetchStatuses();
}
@override
@ -279,62 +352,124 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
children: [
Row(
children: [
const Icon(Symbols.newspaper),
const Icon(Symbols.flare),
const Gap(8),
Text(
'newsToday',
style: Theme.of(context).textTheme.titleLarge,
).tr()
],
).padding(horizontal: 18, top: 12, bottom: 8),
if (_article != null)
Expanded(
child: InkWell(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
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},
);
Expanded(
child: Text(
'serviceStatus',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
),
IconButton(
icon: const Icon(Symbols.launch, size: 20),
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
onPressed: () {
launchUrlString('https://status.solsynth.dev');
},
),
)
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('serviceStatusOperational').tr(),
],
)
: switch (_serviceStatus) {
ServiceStatus.operational => Row(
children: [
const Icon(
Symbols.check,
size: 20,
),
const Gap(10),
Text('serviceStatusOperational').tr(),
],
),
ServiceStatus.failed => Tooltip(
message: 'serviceStatusFailedDescription'.tr(),
child: Row(
children: [
const Icon(
Symbols.dangerous,
size: 20,
),
const Gap(10),
Text('serviceStatusFailed').tr(),
],
),
),
_ => Row(
children: [
const Icon(
Symbols.error,
size: 20,
),
const Gap(10),
Text('serviceStatusDowngraded').tr(),
],
),
},
),
if (_statuses != null)
Expanded(
child: Center(
child: CircularProgressIndicator(),
child: SingleChildScrollView(
padding: EdgeInsets.only(top: 6),
child: Wrap(
spacing: 8,
children: [
for (final entry in _statuses!.entries)
Tooltip(
message: kServicesName[entry.key] != null
? 'serviceName${kServicesName[entry.key]}'.tr()
: 'unknown'.tr(),
child: Chip(
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,15 +521,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
}
Widget _buildDetailChunk(int index, bool positive) {
final prefix = positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint';
final mod = positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount;
final prefix =
positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint';
final mod =
positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount;
final pos = math.max(1, _todayRecord!.resultModifiers[index] % mod);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
prefix.tr(args: ['$prefix$pos'.tr()]),
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontWeight: FontWeight.bold),
),
Text(
'$prefix${pos}Description',
@ -429,7 +569,10 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
else
Text(
'dailyCheckEverythingIsNegative',
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontWeight: FontWeight.bold),
).tr(),
const Gap(8),
if (_todayRecord?.resultTier != 4)
@ -445,7 +588,10 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
else
Text(
'dailyCheckEverythingIsPositive',
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontWeight: FontWeight.bold),
).tr(),
],
),
@ -519,11 +665,26 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
'+${_todayRecord!.resultExperience} EXP',
style: Theme.of(context).textTheme.bodyLarge,
),
if (_todayRecord!.resultCoin >= 0)
if (_todayRecord!.resultCoin > 0)
Text(
'+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
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),
],
),
),
@ -571,10 +732,12 @@ class _HomeDashNotificationWidget extends StatefulWidget {
const _HomeDashNotificationWidget();
@override
State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState();
State<_HomeDashNotificationWidget> createState() =>
_HomeDashNotificationWidgetState();
}
class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget> {
class _HomeDashNotificationWidgetState
extends State<_HomeDashNotificationWidget> {
int? _count;
Future<void> _fetchNotificationCount() async {
@ -612,7 +775,9 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
style: Theme.of(context).textTheme.titleLarge,
).tr(),
Text(
_count == null ? 'loading'.tr() : 'notificationUnreadCount'.plural(_count ?? 0),
_count == null
? 'loading'.tr()
: 'notificationUnreadCount'.plural(_count ?? 0),
style: Theme.of(context).textTheme.bodyLarge,
),
],
@ -643,10 +808,12 @@ class _HomeDashRecommendationPostWidget extends StatefulWidget {
const _HomeDashRecommendationPostWidget();
@override
State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState();
State<_HomeDashRecommendationPostWidget> createState() =>
_HomeDashRecommendationPostWidgetState();
}
class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendationPostWidget> {
class _HomeDashRecommendationPostWidgetState
extends State<_HomeDashRecommendationPostWidget> {
bool _isBusy = false;
List<SnPost>? _posts;
@ -710,13 +877,15 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
).tr(),
],
),
Text('${_currentPage + 1}/${_posts?.length ?? 0}', style: GoogleFonts.robotoMono())
Text('${_currentPage + 1}/${_posts?.length ?? 0}',
style: GoogleFonts.robotoMono())
],
).padding(horizontal: 18, top: 12, bottom: 8),
Expanded(
child: PageView.builder(
controller: _pageController,
scrollBehavior: ScrollConfiguration.of(context).copyWith(dragDevices: {
scrollBehavior:
ScrollConfiguration.of(context).copyWith(dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.touch,
}),
@ -729,7 +898,8 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
showMenu: false,
).padding(bottom: 8),
onTap: () {
GoRouter.of(context).pushNamed('postDetail', pathParameters: {
GoRouter.of(context)
.pushNamed('postDetail', pathParameters: {
'slug': _posts![index].id.toString(),
});
},

167
lib/screens/logging.dart Normal file
View 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(),
),
);
},
);
},
),
);
}
}

View File

@ -1,18 +1,17 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart';
import 'package:html2md/html2md.dart' as html2md;
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/news.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:url_launcher/url_launcher_string.dart';
class NewsDetailScreen extends StatefulWidget {
@ -26,14 +25,12 @@ class NewsDetailScreen extends StatefulWidget {
class _NewsDetailScreenState extends State<NewsDetailScreen> {
SnNewsArticle? _article;
dom.Document? _articleFragment;
Future<void> _fetchArticle() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news/${widget.hash}');
_article = SnNewsArticle.fromJson(resp.data);
_articleFragment = parse(_article!.content);
} catch (err) {
if (!mounted) return;
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
void initState() {
super.initState();
@ -163,7 +62,9 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
MaterialBanner(
dividerColor: Colors.transparent,
leading: const Icon(Icons.info),
content: Text(_isReadingFromReader ? 'newsReadingFromReader'.tr() : 'newsReadingFromOriginal'.tr()),
content: Text(_isReadingFromReader
? 'newsReadingFromReader'.tr()
: 'newsReadingFromOriginal'.tr()),
actions: [
TextButton(
child: Text('newsReadingProviderSwap').tr(),
@ -173,7 +74,7 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
),
],
),
if (_articleFragment != null && _isReadingFromReader)
if (_article != null && _isReadingFromReader)
Expanded(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
@ -182,28 +83,43 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(_article!.title, style: Theme.of(context).textTheme.titleLarge),
Text(_article!.title,
style: Theme.of(context).textTheme.titleLarge),
Builder(builder: (context) {
final htmlDescription = parse(_article!.description);
return Text(
htmlDescription.children.map((ele) => ele.text.trim()).join(),
htmlDescription.children
.map((ele) => ele.text.trim())
.join(),
style: Theme.of(context).textTheme.bodyMedium,
);
}),
Builder(builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt;
final date =
_article!.publishedAt ?? _article!.createdAt;
return Row(
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!),
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);
}),
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(),
..._parseHtmlToWidgets(_articleFragment!.children),
MarkdownTextContent(
textScaler: TextScaler.linear(1.2),
content: html2md.convert(_article!.content),
),
const Divider(),
InkWell(
child: Row(
@ -211,7 +127,8 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
children: [
Text(
'Reference from original website',
style: TextStyle(decoration: TextDecoration.underline),
style: TextStyle(
decoration: TextDecoration.underline),
),
const Gap(4),
Icon(Icons.launch, size: 16),

View File

@ -29,6 +29,7 @@ const Map<String, IconData> kNotificationTopicIcons = {
'passport.security.otp': Symbols.password,
'interactive.subscription': Symbols.subscriptions,
'interactive.feedback': Symbols.add_reaction,
'interactive.reply': Symbols.reply,
'messaging.callStart': Symbols.call_received,
'wallet.transaction.new': Symbols.receipt,
};
@ -57,11 +58,12 @@ class _NotificationScreenState extends State<NotificationScreen> {
try {
final sn = context.read<SnNetworkProvider>();
final nty = context.read<NotificationProvider>();
final resp = await sn.client.get('/cgi/id/notifications?take=10');
_totalCount = resp.data['count'];
_notifications.addAll(
resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? [],
final resp = await sn.client.get(
'/cgi/id/notifications',
queryParameters: {'take': 10, 'offset': _notifications.length},
);
_totalCount = resp.data['count'];
_notifications.addAll(resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? []);
nty.updateTray();
} catch (err) {
if (!mounted) return;
@ -96,9 +98,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
nty.clear();
if (!mounted) return;
context.showSnackbar(
'notificationMarkAllReadPrompt'.plural(resp.data['count']),
);
context.showSnackbar('notificationMarkAllReadPrompt'.plural(resp.data['count']));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -122,9 +122,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
_fetchNotifications();
if (!mounted) return;
context.showSnackbar(
'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
);
context.showSnackbar('notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -145,13 +143,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
if (!ua.isAuthorized) {
return AppScaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenNotification').tr(),
),
body: Center(
child: UnauthorizedHint(),
),
appBar: AppBar(leading: AutoAppBarLeading(), title: Text('screenNotification').tr()),
body: Center(child: UnauthorizedHint()),
);
}
@ -160,10 +153,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
leading: AutoAppBarLeading(),
title: Text('screenNotification').tr(),
actions: [
IconButton(
icon: const Icon(Symbols.checklist),
onPressed: _isSubmitting ? null : _markAllAsRead,
),
IconButton(icon: const Icon(Symbols.checklist), onPressed: _isSubmitting ? null : _markAllAsRead),
const Gap(8),
],
),
@ -177,10 +167,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
return _fetchNotifications();
},
child: InfiniteList(
padding: EdgeInsets.only(
top: 16,
bottom: math.max(MediaQuery.of(context).padding.bottom, 16),
),
padding: EdgeInsets.only(top: 16, bottom: math.max(MediaQuery.of(context).padding.bottom, 16)),
itemCount: _notifications.length,
onFetchData: () {
_fetchNotifications();
@ -199,41 +186,26 @@ class _NotificationScreenState extends State<NotificationScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (nty.readAt == null)
StyledWidget(Badge(
label: Text('notificationUnread').tr(),
)).padding(bottom: 4),
Text(
nty.title,
style: Theme.of(context).textTheme.titleMedium,
),
StyledWidget(Badge(label: Text('notificationUnread').tr())).padding(bottom: 4),
Text(nty.title, style: Theme.of(context).textTheme.titleMedium),
if (nty.subtitle != null)
Text(
nty.subtitle!,
style: Theme.of(context).textTheme.titleSmall,
),
Text(nty.subtitle!, style: Theme.of(context).textTheme.titleSmall),
if (nty.subtitle != null) const Gap(4),
SelectionArea(
child: MarkdownTextContent(
content: nty.body,
isAutoWarp: true,
),
),
if (['interactive.reply', 'interactive.feedback', 'interactive.subscription']
.contains(nty.topic) &&
SelectionArea(child: MarkdownTextContent(content: nty.body, isAutoWarp: true)),
if ([
'interactive.reply',
'interactive.feedback',
'interactive.subscription',
].contains(nty.topic) &&
nty.metadata['related_post'] != null)
GestureDetector(
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
border: Border.all(color: Theme.of(context).dividerColor, width: 1),
),
child: PostItem(
data: SnPost.fromJson(
nty.metadata['related_post']!,
),
data: SnPost.fromJson(nty.metadata['related_post']!),
showComments: false,
showReactions: false,
showMenu: false,
@ -242,27 +214,18 @@ class _NotificationScreenState extends State<NotificationScreen> {
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',
pathParameters: {
'slug': nty.metadata['related_post']!['id'].toString(),
},
pathParameters: {'slug': nty.metadata['related_post']!['id'].toString()},
);
},
).padding(top: 8),
const Gap(8),
Row(
children: [
Text(
DateFormat('yy/MM/dd').format(nty.createdAt),
).fontSize(12),
Text(DateFormat('yy/MM/dd').format(nty.createdAt)).fontSize(12),
const Gap(4),
Text(
'·',
style: TextStyle(fontSize: 12),
),
Text('·', style: TextStyle(fontSize: 12)),
const Gap(4),
Text(
RelativeTime(context).format(nty.createdAt),
).fontSize(12),
Text(RelativeTime(context).format(nty.createdAt)).fontSize(12),
],
).opacity(0.75),
],

View File

@ -0,0 +1,88 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:provider/provider.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 Gap(8),
),
),
),
],
),
);
}
}

View File

@ -18,6 +18,7 @@ import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_attachment.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/post.dart';
import 'package:surface/types/realm.dart';
@ -36,7 +37,8 @@ import 'package:provider/provider.dart';
import 'package:surface/widgets/post/post_poll_editor.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 {
final String? text;
@ -53,7 +55,7 @@ class PostEditorExtra {
}
class PostEditorScreen extends StatefulWidget {
final String mode;
final String? mode;
final int? postEditId;
final int? postReplyId;
final int? postRepostId;
@ -72,7 +74,10 @@ class PostEditorScreen extends StatefulWidget {
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(
doLoadFromTemporary: widget.postEditId == null,
);
@ -95,8 +100,9 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
);
final beforeId = config.prefs.getInt('int_last_publisher_id');
_writeController
.setPublisher(_publishers?.where((ele) => ele.id == beforeId).firstOrNull ?? _publishers?.firstOrNull);
_writeController.setPublisher(
_publishers?.where((ele) => ele.id == beforeId).firstOrNull ??
_publishers?.firstOrNull);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -125,7 +131,20 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
final HotKey _pasteHotKey = HotKey(
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,
);
@ -143,6 +162,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
]);
setState(() {});
});
hotKeyManager.register(_saveDraftHotKey, keyDownHandler: (_) async {
if (mounted) {
_writeController.sendPost(context, saveAsDraft: true);
}
});
}
void _showPublisherPopup() {
@ -204,9 +228,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
@override
void dispose() {
_tabController.dispose();
_writeController.dispose();
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
hotKeyManager.unregister(_pasteHotKey);
hotKeyManager.unregister(_saveDraftHotKey);
}
super.dispose();
}
@ -215,14 +241,16 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
void initState() {
super.initState();
_registerHotKey();
if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
context.showErrorDialog('Unknown post type');
Navigator.pop(context);
} else {
_writeController.setMode(widget.mode);
}
_fetchRealms();
_fetchPublishers();
if (widget.mode != null) {
_writeController.setMode(widget.mode!);
}
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
_writeController.setMode(kPostTypeAliases[_tabController.index]);
}
});
_writeController.fetchRelatedPost(
context,
editing: widget.postEditId,
@ -232,7 +260,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
if (widget.extraProps != null) {
_writeController.contentController.text = widget.extraProps!.text ?? '';
_writeController.titleController.text = widget.extraProps!.title ?? '';
_writeController.descriptionController.text = widget.extraProps!.description ?? '';
_writeController.descriptionController.text =
widget.extraProps!.description ?? '';
_writeController.addAttachments(widget.extraProps!.attachments ?? []);
}
}
@ -249,38 +278,58 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
Navigator.pop(context);
},
),
title: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
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,
title: Text(
_writeController.title.isNotEmpty
? _writeController.title
: 'untitled'.tr(),
),
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(
icon: const Icon(Symbols.tune),
onPressed: _writeController.isBusy ? null : _updateMeta,
),
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(
children: [
if (_writeController.editingPost != null)
if (_writeController.editingPost != null &&
!_writeController.editingDraft)
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(
border: Border(
bottom: BorderSide(
@ -294,13 +343,16 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
children: [
const Icon(Icons.edit, size: 16),
const Gap(10),
Text('postEditingNotice').tr(args: ['@${_writeController.editingPost!.publisher.name}']),
Text('postEditingNotice').tr(args: [
'@${_writeController.editingPost!.publisher.name}'
]),
],
),
),
if (_writeController.replyingPost != null)
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(
border: Border(
bottom: BorderSide(
@ -314,7 +366,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
children: [
const Icon(Symbols.reply, size: 16),
const Gap(10),
Text('@${_writeController.replyingPost!.publisher.name}').bold(),
Text('@${_writeController.replyingPost!.publisher.name}')
.bold(),
const Gap(4),
Expanded(
child: Text(
@ -328,7 +381,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
if (_writeController.repostingPost != null)
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(
border: Border(
bottom: BorderSide(
@ -342,7 +396,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
children: [
const Icon(Symbols.forward, size: 16),
const Gap(10),
Text('@${_writeController.repostingPost!.publisher.name}').bold(),
Text('@${_writeController.repostingPost!.publisher.name}')
.bold(),
const Gap(4),
Expanded(
child: Text(
@ -359,7 +414,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
children: [
SingleChildScrollView(
padding: EdgeInsets.only(bottom: 160),
child: StyledWidget(switch (_writeController.mode) {
child: switch (_writeController.mode) {
'stories' => _PostStoryEditor(
controller: _writeController,
onTapPublisher: _showPublisherPopup,
@ -381,10 +436,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
onTapRealm: _showRealmPopup,
),
_ => const Placeholder(),
})
.padding(top: 8),
},
),
if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null)
if (_writeController.attachments.isNotEmpty ||
_writeController.thumbnail != null)
Positioned(
bottom: 0,
left: 0,
@ -393,16 +448,19 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
attachments: _writeController.attachments,
isBusy: _writeController.isBusy,
onUpload: (int idx) async {
await _writeController.uploadSingleAttachment(context, idx);
await _writeController.uploadSingleAttachment(
context, idx);
},
onInsertLink: (int idx) async {
_writeController.contentController.text +=
'\n![](solink://attachments/${_writeController.attachments[idx].attachment!.rid})';
},
onUpdate: (int idx, PostWriteMedia updatedMedia) async {
onUpdate:
(int idx, PostWriteMedia updatedMedia) async {
_writeController.setIsBusy(true);
try {
_writeController.setAttachmentAt(idx, updatedMedia);
_writeController.setAttachmentAt(
idx, updatedMedia);
} finally {
_writeController.setIsBusy(false);
}
@ -415,7 +473,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
_writeController.setIsBusy(false);
}
},
onUpdateBusy: (state) => _writeController.setIsBusy(state),
onUpdateBusy: (state) =>
_writeController.setIsBusy(state),
).padding(bottom: 8),
),
],
@ -426,11 +485,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_writeController.isBusy && _writeController.progress != null)
if (_writeController.isBusy &&
_writeController.progress != null)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _writeController.progress),
duration: Duration(milliseconds: 300),
builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
builder: (context, value, _) =>
LinearProgressIndicator(value: value, minHeight: 2),
)
else if (_writeController.isBusy)
const LinearProgressIndicator(value: null, minHeight: 2),
@ -439,12 +500,14 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
Container(
child: _writeController.temporaryRestored
? 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(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / MediaQuery.of(context).devicePixelRatio,
width: 1 /
MediaQuery.of(context).devicePixelRatio,
),
),
),
@ -453,7 +516,9 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
children: [
const Icon(Icons.restore, size: 20),
const Gap(8),
Expanded(child: Text('postLocalDraftRestored').tr()),
Expanded(
child:
Text('postLocalDraftRestored').tr()),
InkWell(
child: Text('dialogDismiss').tr(),
onTap: () {
@ -464,8 +529,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
))
: const SizedBox.shrink(),
)
.height(_writeController.temporaryRestored ? 32 : 0, animate: true)
.animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn),
.height(_writeController.temporaryRestored ? 32 : 0,
animate: true)
.animate(const Duration(milliseconds: 300),
Curves.fastLinearToSlowEaseIn),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -485,11 +552,18 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
if (_writeController.mode == 'stories')
IconButton(
icon: Icon(Symbols.poll, color: Theme.of(context).colorScheme.primary),
icon: Icon(Symbols.poll,
color: Theme.of(context)
.colorScheme
.primary),
style: ButtonStyle(
backgroundColor: _writeController.poll == null
? null
: WidgetStatePropertyAll(Theme.of(context).colorScheme.surfaceContainer),
backgroundColor:
_writeController.poll == null
? null
: WidgetStatePropertyAll(
Theme.of(context)
.colorScheme
.surfaceContainer),
),
onPressed: () {
_showPollEditorDialog();
@ -497,14 +571,22 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
if (_writeController.mode == 'articles')
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(
backgroundColor: _writeController.thumbnail == null
? null
: WidgetStatePropertyAll(Theme.of(context).colorScheme.surfaceContainer),
backgroundColor:
_writeController.thumbnail == null
? null
: WidgetStatePropertyAll(
Theme.of(context)
.colorScheme
.surfaceContainer),
),
onPressed: () {
if (_writeController.thumbnail != null) {
if (_writeController.thumbnail !=
null) {
_writeController.setThumbnail(null);
return;
}
@ -517,7 +599,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
),
TextButton.icon(
onPressed: (_writeController.isBusy || _writeController.publisher == null)
onPressed: (_writeController.isBusy ||
_writeController.publisher == null)
? null
: () {
_writeController.sendPost(context).then((_) {
@ -556,7 +639,8 @@ class _PostPublisherPopup extends StatelessWidget {
final List<SnPublisher>? publishers;
final Function onUpdate;
const _PostPublisherPopup({required this.controller, this.publishers, required this.onUpdate});
const _PostPublisherPopup(
{required this.controller, this.publishers, required this.onUpdate});
@override
Widget build(BuildContext context) {
@ -568,7 +652,9 @@ class _PostPublisherPopup extends StatelessWidget {
children: [
const Icon(Symbols.face, size: 24),
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),
ListTile(
@ -612,7 +698,8 @@ class _PostRealmPopup extends StatelessWidget {
final List<SnRealm>? realms;
final Function onUpdate;
const _PostRealmPopup({required this.controller, this.realms, required this.onUpdate});
const _PostRealmPopup(
{required this.controller, this.realms, required this.onUpdate});
@override
Widget build(BuildContext context) {
@ -624,7 +711,8 @@ class _PostRealmPopup extends StatelessWidget {
children: [
const Icon(Symbols.face, size: 24),
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),
ListTile(
@ -665,12 +753,13 @@ class _PostStoryEditor extends StatelessWidget {
final Function? onTapPublisher;
final Function? onTapRealm;
const _PostStoryEditor({required this.controller, this.onTapPublisher, this.onTapRealm});
const _PostStoryEditor(
{required this.controller, this.onTapPublisher, this.onTapRealm});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -717,7 +806,8 @@ class _PostStoryEditor extends StatelessWidget {
border: InputBorder.none,
),
style: Theme.of(context).textTheme.titleLarge,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(8),
TextField(
@ -732,8 +822,10 @@ class _PostStoryEditor extends StatelessWidget {
),
border: InputBorder.none,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration: controller.contentInsertionConfiguration,
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration:
controller.contentInsertionConfiguration,
),
],
),
@ -749,7 +841,8 @@ class _PostArticleEditor extends StatelessWidget {
final Function? onTapPublisher;
final Function? onTapRealm;
const _PostArticleEditor({required this.controller, this.onTapPublisher, this.onTapRealm});
const _PostArticleEditor(
{required this.controller, this.onTapPublisher, this.onTapRealm});
@override
Widget build(BuildContext context) {
@ -857,8 +950,10 @@ class _PostArticleEditor extends StatelessWidget {
),
border: InputBorder.none,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration: controller.contentInsertionConfiguration,
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration:
controller.contentInsertionConfiguration,
),
),
const Gap(8),
@ -893,7 +988,8 @@ class _PostArticleEditor extends StatelessWidget {
border: InputBorder.none,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration: controller.contentInsertionConfiguration,
contentInsertionConfiguration:
controller.contentInsertionConfiguration,
),
),
],
@ -906,12 +1002,13 @@ class _PostQuestionEditor extends StatelessWidget {
final Function? onTapPublisher;
final Function? onTapRealm;
const _PostQuestionEditor({required this.controller, this.onTapPublisher, this.onTapRealm});
const _PostQuestionEditor(
{required this.controller, this.onTapPublisher, this.onTapRealm});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -958,7 +1055,8 @@ class _PostQuestionEditor extends StatelessWidget {
border: InputBorder.none,
),
style: Theme.of(context).textTheme.titleLarge,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(8),
TextField(
@ -969,7 +1067,8 @@ class _PostQuestionEditor extends StatelessWidget {
border: InputBorder.none,
isCollapsed: true,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(8),
TextField(
@ -984,14 +1083,16 @@ class _PostQuestionEditor extends StatelessWidget {
),
border: InputBorder.none,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration: controller.contentInsertionConfiguration,
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
contentInsertionConfiguration:
controller.contentInsertionConfiguration,
),
],
),
),
],
).padding(top: 8),
),
);
}
}
@ -1001,7 +1102,8 @@ class _PostVideoEditor extends StatelessWidget {
final Function? onTapPublisher;
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 {
final video = await showDialog<SnAttachment?>(
@ -1022,7 +1124,8 @@ class _PostVideoEditor extends StatelessWidget {
final result = await showDialog<SnAttachment?>(
context: context,
builder: (context) => PendingAttachmentAltDialog(media: PostWriteMedia(controller.videoAttachment)),
builder: (context) => PendingAttachmentAltDialog(
media: PostWriteMedia(controller.videoAttachment)),
);
if (result == null) return;
@ -1034,7 +1137,8 @@ class _PostVideoEditor extends StatelessWidget {
final result = await showDialog<SnAttachmentBoost?>(
context: context,
builder: (context) => PendingAttachmentBoostDialog(media: PostWriteMedia(controller.videoAttachment)),
builder: (context) => PendingAttachmentBoostDialog(
media: PostWriteMedia(controller.videoAttachment)),
);
if (result == null) return;
@ -1077,7 +1181,8 @@ class _PostVideoEditor extends StatelessWidget {
try {
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);
} catch (err) {
if (!context.mounted) return;
@ -1087,143 +1192,159 @@ class _PostVideoEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Column(
children: [
Material(
elevation: 2,
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: GestureDetector(
onTap: () {
onTapPublisher?.call();
},
child: AccountImage(
content: controller.publisher?.avatar,
return Container(
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Material(
elevation: 2,
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: GestureDetector(
onTap: () {
onTapPublisher?.call();
},
child: AccountImage(
content: controller.publisher?.avatar,
),
),
),
),
const Gap(11),
Material(
elevation: 1,
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: GestureDetector(
onTap: () {
onTapRealm?.call();
},
child: AccountImage(
content: controller.realm?.avatar,
fallbackWidget: const Icon(Symbols.globe, size: 20),
radius: 14,
const Gap(11),
Material(
elevation: 1,
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: GestureDetector(
onTap: () {
onTapRealm?.call();
},
child: AccountImage(
content: controller.realm?.avatar,
fallbackWidget: const Icon(Symbols.globe, size: 20),
radius: 14,
),
),
),
),
],
),
const Gap(16),
TextField(
controller: controller.titleController,
decoration: InputDecoration.collapsed(
hintText: 'fieldPostTitle'.tr(),
border: InputBorder.none,
],
),
style: Theme.of(context).textTheme.titleLarge,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(8),
TextField(
controller: controller.descriptionController,
decoration: InputDecoration.collapsed(
hintText: 'fieldPostDescription'.tr(),
border: InputBorder.none,
),
maxLines: null,
keyboardType: TextInputType.multiline,
style: Theme.of(context).textTheme.bodyLarge,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(12),
Container(
margin: const EdgeInsets.only(left: 16, right: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: ContextMenuRegion(
contextMenu: ContextMenu(
entries: [
MenuItem(
label: 'attachmentSetAlt'.tr(),
icon: Symbols.description,
onSelected: () {
_setAlt(context);
},
),
MenuItem(
label: 'attachmentBoost'.tr(),
icon: Symbols.bolt,
onSelected: () {
_createBoost(context);
},
),
MenuItem(
label: 'attachmentSetThumbnail'.tr(),
icon: Symbols.image,
onSelected: () {
_setThumbnail(context);
},
),
MenuItem(
label: 'attachmentCopyRandomId'.tr(),
icon: Symbols.content_copy,
onSelected: () {
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);
},
Expanded(
child: Column(
children: [
const Gap(6),
TextField(
controller: controller.titleController,
decoration: InputDecoration.collapsed(
hintText: 'fieldPostTitle'.tr(),
border: InputBorder.none,
),
style: Theme.of(context).textTheme.titleLarge,
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(8),
TextField(
controller: controller.descriptionController,
decoration: InputDecoration.collapsed(
hintText: 'fieldPostDescription'.tr(),
border: InputBorder.none,
),
maxLines: null,
keyboardType: TextInputType.multiline,
style: Theme.of(context).textTheme.bodyLarge,
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 16),
const Gap(12),
Container(
margin: const EdgeInsets.only(left: 16, right: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: ContextMenuRegion(
contextMenu: ContextMenu(
entries: [
MenuItem(
label: 'attachmentSetAlt'.tr(),
icon: Symbols.description,
onSelected: () {
_setAlt(context);
},
),
MenuItem(
label: 'attachmentBoost'.tr(),
icon: Symbols.bolt,
onSelected: () {
_createBoost(context);
},
),
MenuItem(
label: 'attachmentSetThumbnail'.tr(),
icon: Symbols.image,
onSelected: () {
_setThumbnail(context);
},
),
MenuItem(
label: 'attachmentCopyRandomId'.tr(),
icon: Symbols.content_copy,
onSelected: () {
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(),
),
),
),
),
),
),
],
],
),
);
}
}

View File

@ -0,0 +1,132 @@
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: OpenablePostItem(
key: ValueKey(ele),
data: ele,
maxWidth: 640,
onChanged: (ele) {
_posts[idx] = ele;
setState(() {});
},
onDeleted: () {
_fetchPosts();
},
).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);
},
),
],
),
),
],
),
);
}
}

View File

@ -51,7 +51,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
Future<void> _fetchPublishers() async {
try {
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(
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
);
@ -68,7 +69,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
Future<void> _fetchChannels() async {
try {
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(
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
);
@ -98,15 +100,32 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
handle:
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
title: Text(_realm?.name ?? 'loading'.tr()),
bottom: TabBar(
tabs: [
Tab(icon: Icon(Symbols.home, color: Theme.of(context).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)),
Tab(
icon: Icon(Symbols.home,
color: Theme.of(context)
.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(
children: [
_RealmDetailHomeWidget(realm: _realm, publishers: _publishers, channels: _channels),
_RealmDetailHomeWidget(
realm: _realm, publishers: _publishers, channels: _channels),
_RealmPostListWidget(realm: _realm),
_RealmMemberListWidget(realm: _realm),
_RealmSettingsWidget(
@ -137,7 +157,8 @@ class _RealmDetailHomeWidget extends StatelessWidget {
final List<SnPublisher>? publishers;
final List<SnChannel>? channels;
const _RealmDetailHomeWidget({required this.realm, this.publishers, this.channels});
const _RealmDetailHomeWidget(
{required this.realm, this.publishers, this.channels});
@override
Widget build(BuildContext context) {
@ -168,7 +189,8 @@ class _RealmDetailHomeWidget extends StatelessWidget {
child: Container(
width: double.infinity,
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),
),
),
@ -199,7 +221,8 @@ class _RealmDetailHomeWidget extends StatelessWidget {
child: Container(
width: double.infinity,
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),
),
),
@ -323,10 +346,12 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
try {
final ud = context.read<UserDirectoryProvider>();
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: {
'take': 10,
'offset': _members.length,
});
final resp = await sn.client.get(
'/cgi/id/realms/${widget.realm!.alias}/members',
queryParameters: {
'take': 10,
'offset': _members.length,
});
final out = List<SnRealmMember>.from(
resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [],
@ -432,14 +457,14 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
return ListTile(
contentPadding: const EdgeInsets.only(right: 24, left: 16),
leading: AccountImage(
content: ud.getAccountFromCache(member.accountId)?.avatar,
content: ud.getFromCache(member.accountId)?.avatar,
fallbackWidget: const Icon(Symbols.group, size: 24),
),
title: Text(
ud.getAccountFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
ud.getFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
),
subtitle: Text(
ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
),
trailing: IconButton(
icon: const Icon(Symbols.person_remove),

View File

@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
@ -24,6 +25,7 @@ import 'package:surface/providers/theme.dart';
import 'package:surface/theme.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/updater.dart';
const Map<String, Color> kColorSchemes = {
'colorSchemeIndigo': Colors.indigo,
@ -47,6 +49,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
late final SharedPreferences _prefs;
String _docBasepath = '/';
final TextEditingController _customFontController = TextEditingController();
final TextEditingController _serverUrlController = TextEditingController();
@override
@ -61,11 +64,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
final config = context.read<ConfigProvider>();
_prefs = config.prefs;
_serverUrlController.text = config.serverUrl;
if (_prefs.getString(kAppCustomFonts) != null) {
_customFontController.text = _prefs.getString(kAppCustomFonts) ?? '';
}
}
@override
void dispose() {
_serverUrlController.dispose();
_customFontController.dispose();
super.dispose();
}
@ -329,6 +336,47 @@ class _SettingsScreenState extends State<SettingsScreen> {
setState(() {});
},
),
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(
@ -339,6 +387,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
.fontSize(17)
.tr()
.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(
secondary: const Icon(Symbols.vibration),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
@ -533,6 +593,37 @@ class _SettingsScreenState extends State<SettingsScreen> {
.fontSize(17)
.tr()
.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(
leading: const Icon(Symbols.database),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
@ -604,6 +695,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
},
),
ListTile(
title: Text('forceUpdate').tr(),
subtitle: Text('forceUpdateDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.update),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
showModalBottomSheet(
context: context,
builder: (context) => VersionUpdatePopup(),
);
},
),
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(
title: Text('settingsMiscAbout').tr(),
subtitle: Text('settingsMiscAboutDescription').tr(),

View File

@ -51,26 +51,35 @@ class _AppSharingListenerState extends State<AppSharingListener> {
child: Column(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
leading: Icon(Icons.post_add),
trailing: const Icon(Icons.chevron_right),
title: Text('shareIntentPostStory').tr(),
onTap: () {
GoRouter.of(context).pushNamed(
'postEditor',
pathParameters: {
queryParameters: {
'mode': 'stories',
},
extra: PostEditorExtra(
text: value
.where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
.where((e) => [
SharedMediaType.text,
SharedMediaType.url
].contains(e.type))
.map((e) => e.path)
.join('\n'),
attachments: value
.where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image]
.contains(e.type))
.map((e) => PostWriteMedia.fromFile(XFile(e.path)))
.where((e) => [
SharedMediaType.video,
SharedMediaType.file,
SharedMediaType.image
].contains(e.type))
.map((e) =>
PostWriteMedia.fromFile(XFile(e.path)))
.toList(),
),
);
@ -78,15 +87,18 @@ class _AppSharingListenerState extends State<AppSharingListener> {
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
leading: Icon(Icons.chat_outlined),
trailing: const Icon(Icons.chevron_right),
title: Text('shareIntentSendChannel').tr(),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => _ShareIntentChannelSelect(value: value),
builder: (context) =>
_ShareIntentChannelSelect(value: value),
).then((val) {
if (!context.mounted) return;
if (val == true) Navigator.pop(context);
@ -110,7 +122,8 @@ class _AppSharingListenerState extends State<AppSharingListener> {
}
void _initialize() async {
_shareIntentSubscription = ReceiveSharingIntent.instance.getMediaStream().listen((value) {
_shareIntentSubscription =
ReceiveSharingIntent.instance.getMediaStream().listen((value) {
if (value.isEmpty) return;
if (mounted) {
_gotoPost(value);
@ -157,7 +170,8 @@ class _ShareIntentChannelSelect extends StatefulWidget {
const _ShareIntentChannelSelect({required this.value});
@override
State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState();
State<_ShareIntentChannelSelect> createState() =>
_ShareIntentChannelSelectState();
}
class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
@ -178,8 +192,11 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
final lastMessages = await chan.getLastMessages(channels);
_lastMessages = {for (final val in lastMessages) val.channelId: val};
channels.sort((a, b) {
if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
if (_lastMessages!.containsKey(a.id) &&
_lastMessages!.containsKey(b.id)) {
return _lastMessages![b.id]!
.createdAt
.compareTo(_lastMessages![a.id]!.createdAt);
}
if (_lastMessages!.containsKey(a.id)) return -1;
if (_lastMessages!.containsKey(b.id)) return 1;
@ -232,7 +249,9 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
children: [
const Icon(Symbols.chat, size: 24),
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),
LoadingIndicator(isActive: _isBusy),
@ -249,29 +268,34 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
final lastMessage = _lastMessages?[channel.id];
if (channel.type == 1) {
final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
(ele) => ele?.accountId != ua.user?.id,
orElse: () => null,
);
final otherMember =
channel.members?.cast<SnChannelMember?>().firstWhere(
(ele) => ele?.accountId != ua.user?.id,
orElse: () => null,
);
return ListTile(
title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
title: Text(
ud.getFromCache(otherMember?.accountId)?.nick ??
channel.name),
subtitle: lastMessage != null
? 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,
overflow: TextOverflow.ellipsis,
)
: Text(
'channelDirectMessageDescription'.tr(args: [
'@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
'@${ud.getFromCache(otherMember?.accountId)?.name}',
]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(
content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
content:
ud.getFromCache(otherMember?.accountId)?.avatar,
),
onTap: () {
GoRouter.of(context).pushNamed(
@ -291,7 +315,7 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
title: Text(channel.name),
subtitle: lastMessage != null
? 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,
overflow: TextOverflow.ellipsis,
)
@ -316,13 +340,20 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
},
extra: ChatRoomScreenExtra(
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)
.join('\n'),
initialAttachments: widget.value
.where((e) =>
[SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
.map((e) => PostWriteMedia.fromFile(XFile(e.path)))
.where((e) => [
SharedMediaType.video,
SharedMediaType.file,
SharedMediaType.image
].contains(e.type))
.map(
(e) => PostWriteMedia.fromFile(XFile(e.path)))
.toList(),
),
)

View File

@ -179,7 +179,9 @@ class _StickerScreenState extends State<StickerScreen>
child: InfiniteList(
itemCount: _packs.length,
onFetchData: _fetchPacks,
hasReachedMax: _totalCount != null && _packs.length >= _totalCount!,
hasReachedMax:
(_totalCount != null && _packs.length >= _totalCount!) ||
_tabController.index == 2,
isLoading: _isBusy,
itemBuilder: (context, idx) {
final pack = _packs[idx];
@ -282,7 +284,10 @@ class _StickerPackAddPopupState extends State<_StickerPackAddPopup> {
);
if (!mounted) return;
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);
} catch (err) {
if (!mounted) return;

View File

@ -11,10 +11,19 @@ class ThemeSet {
ThemeSet({required this.light, required this.dark});
}
Future<ThemeSet> createAppThemeSet({Color? seedColorOverride, bool? useMaterial3}) async {
Future<ThemeSet> createAppThemeSet(
{Color? seedColorOverride, bool? useMaterial3, String? customFonts}) async {
return ThemeSet(
light: await createAppTheme(Brightness.light, useMaterial3: useMaterial3),
dark: await createAppTheme(Brightness.dark, useMaterial3: useMaterial3),
light: await createAppTheme(
Brightness.light,
useMaterial3: useMaterial3,
customFonts: customFonts,
),
dark: await createAppTheme(
Brightness.dark,
useMaterial3: useMaterial3,
customFonts: customFonts,
),
);
}
@ -22,24 +31,36 @@ Future<ThemeData> createAppTheme(
Brightness brightness, {
Color? seedColorOverride,
bool? useMaterial3,
String? customFonts,
}) async {
final prefs = await SharedPreferences.getInstance();
final seedColorString = prefs.getInt(kAppColorSchemeStoreKey);
final seedColor = seedColorString != null ? Color(seedColorString) : Colors.indigo;
final seedColor =
seedColorString != null ? Color(seedColorString) : Colors.indigo;
final colorScheme = ColorScheme.fromSeed(
seedColor: seedColorOverride ?? seedColor,
brightness: brightness,
);
final hasAppBarTransparent = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
final useM3 = useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true);
final hasAppBarTransparent =
prefs.getBool(kAppbarTransparentStoreKey) ?? false;
final useM3 =
useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true);
final inUseFonts = (customFonts ?? prefs.getString(kAppCustomFonts))
?.split(',')
.map((ele) => ele.trim())
.toList() ??
['Nunito'];
return ThemeData(
useMaterial3: useM3,
colorScheme: colorScheme,
brightness: brightness,
fontFamily: inUseFonts.firstOrNull,
fontFamilyFallback: inUseFonts.sublist(1),
iconTheme: IconThemeData(
fill: 0,
weight: 400,
@ -52,8 +73,10 @@ Future<ThemeData> createAppTheme(
appBarTheme: AppBarTheme(
centerTitle: true,
elevation: hasAppBarTransparent ? 0 : null,
backgroundColor: hasAppBarTransparent ? Colors.transparent : colorScheme.primary,
foregroundColor: hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary,
backgroundColor:
hasAppBarTransparent ? Colors.transparent : colorScheme.primary,
foregroundColor:
hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary,
),
pageTransitionsTheme: PageTransitionsTheme(
builders: {
@ -67,3 +90,20 @@ Future<ThemeData> createAppTheme(
),
);
}
extension HexColor on Color {
/// String is in the format "aabbcc" or "ffaabbcc" with an optional leading "#".
static Color fromHex(String hexString) {
final buffer = StringBuffer();
if (hexString.length == 6 || hexString.length == 7) buffer.write('ff');
buffer.write(hexString.replaceFirst('#', ''));
return Color(int.parse(buffer.toString(), radix: 16));
}
/// Prefixes a hash sign if [leadingHashSign] is set to `true` (default is `true`).
String toHex({bool leadingHashSign = true}) => '${leadingHashSign ? '#' : ''}'
'${alpha.toRadixString(16).padLeft(2, '0')}'
'${red.toRadixString(16).padLeft(2, '0')}'
'${green.toRadixString(16).padLeft(2, '0')}'
'${blue.toRadixString(16).padLeft(2, '0')}';
}

View File

@ -4,7 +4,7 @@ part 'account.freezed.dart';
part 'account.g.dart';
@freezed
class SnAccount with _$SnAccount {
abstract class SnAccount with _$SnAccount {
const SnAccount._();
const factory SnAccount({
@ -16,7 +16,6 @@ class SnAccount with _$SnAccount {
required List<SnAccountContact>? contacts,
@Default("") String avatar,
@Default("") String banner,
required String description,
required String name,
required String nick,
@Default({}) Map<String, dynamic> permNodes,
@ -35,7 +34,7 @@ class SnAccount with _$SnAccount {
}
@freezed
class SnAccountContact with _$SnAccountContact {
abstract class SnAccountContact with _$SnAccountContact {
const factory SnAccountContact({
required int accountId,
required String content,
@ -54,18 +53,24 @@ class SnAccountContact with _$SnAccountContact {
}
@freezed
class SnAccountProfile with _$SnAccountProfile {
abstract class SnAccountProfile with _$SnAccountProfile {
const factory SnAccountProfile({
required int id,
required int accountId,
required DateTime? birthday,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required int experience,
required String firstName,
required String lastName,
required String description,
required String timeZone,
required String location,
required String pronouns,
required String gender,
@Default({}) Map<String, String> links,
required int experience,
required DateTime? lastSeenAt,
required DateTime updatedAt,
required DateTime? birthday,
required int accountId,
}) = _SnAccountProfile;
factory SnAccountProfile.fromJson(Map<String, Object?> json) =>
@ -73,7 +78,7 @@ class SnAccountProfile with _$SnAccountProfile {
}
@freezed
class SnRelationship with _$SnRelationship {
abstract class SnRelationship with _$SnRelationship {
const factory SnRelationship({
required int id,
required DateTime createdAt,
@ -92,7 +97,7 @@ class SnRelationship with _$SnRelationship {
}
@freezed
class SnAccountBadge with _$SnAccountBadge {
abstract class SnAccountBadge with _$SnAccountBadge {
const factory SnAccountBadge({
required int id,
required DateTime createdAt,
@ -100,6 +105,7 @@ class SnAccountBadge with _$SnAccountBadge {
required dynamic deletedAt,
required String type,
required int accountId,
@Default(false) bool isActive,
@Default({}) Map<String, dynamic> metadata,
}) = _SnAccountBadge;
@ -108,12 +114,12 @@ class SnAccountBadge with _$SnAccountBadge {
}
@freezed
class SnAccountStatusInfo with _$SnAccountStatusInfo {
abstract class SnAccountStatusInfo with _$SnAccountStatusInfo {
const factory SnAccountStatusInfo({
required bool isDisturbable,
required bool isOnline,
required DateTime? lastSeenAt,
required dynamic status,
required SnAccountStatus? status,
}) = _SnAccountStatusInfo;
factory SnAccountStatusInfo.fromJson(Map<String, Object?> json) =>
@ -121,7 +127,27 @@ class SnAccountStatusInfo with _$SnAccountStatusInfo {
}
@freezed
class SnAbuseReport with _$SnAbuseReport {
abstract class SnAccountStatus with _$SnAccountStatus {
const factory SnAccountStatus({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String type,
required String label,
required int attitude,
required bool isNoDisturb,
required bool isInvisible,
required DateTime? clearAt,
required int accountId,
}) = _SnAccountStatus;
factory SnAccountStatus.fromJson(Map<String, Object?> json) =>
_$SnAccountStatusFromJson(json);
}
@freezed
abstract class SnAbuseReport with _$SnAbuseReport {
const factory SnAbuseReport({
required int id,
required DateTime createdAt,
@ -136,3 +162,25 @@ class SnAbuseReport with _$SnAbuseReport {
factory SnAbuseReport.fromJson(Map<String, Object?> json) =>
_$SnAbuseReportFromJson(json);
}
@freezed
abstract class SnActionEvent with _$SnActionEvent {
const factory SnActionEvent({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String type,
required Map<String, dynamic>? metadata,
required String? location,
required double? coordinateX,
required double? coordinateY,
required String ipAddress,
required String userAgent,
required SnAccount account,
required int accountId,
}) = _SnActionEvent;
factory SnActionEvent.fromJson(Map<String, Object?> json) =>
_$SnActionEventFromJson(json);
}

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,7 @@ part of 'account.dart';
// JsonSerializableGenerator
// **************************************************************************
_$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
_$SnAccountImpl(
_SnAccount _$SnAccountFromJson(Map<String, dynamic> json) => _SnAccount(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -22,7 +21,6 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
.toList(),
avatar: json['avatar'] as String? ?? "",
banner: json['banner'] as String? ?? "",
description: json['description'] as String,
name: json['name'] as String,
nick: json['nick'] as String,
permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {},
@ -43,7 +41,7 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
automatedId: (json['automated_id'] as num?)?.toInt(),
);
Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
Map<String, dynamic> _$SnAccountToJson(_SnAccount instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -53,7 +51,6 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
'contacts': instance.contacts?.map((e) => e.toJson()).toList(),
'avatar': instance.avatar,
'banner': instance.banner,
'description': instance.description,
'name': instance.name,
'nick': instance.nick,
'perm_nodes': instance.permNodes,
@ -67,9 +64,8 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
'automated_id': instance.automatedId,
};
_$SnAccountContactImpl _$$SnAccountContactImplFromJson(
Map<String, dynamic> json) =>
_$SnAccountContactImpl(
_SnAccountContact _$SnAccountContactFromJson(Map<String, dynamic> json) =>
_SnAccountContact(
accountId: (json['account_id'] as num).toInt(),
content: json['content'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
@ -86,8 +82,7 @@ _$SnAccountContactImpl _$$SnAccountContactImplFromJson(
: DateTime.parse(json['verified_at'] as String),
);
Map<String, dynamic> _$$SnAccountContactImplToJson(
_$SnAccountContactImpl instance) =>
Map<String, dynamic> _$SnAccountContactToJson(_SnAccountContact instance) =>
<String, dynamic>{
'account_id': instance.accountId,
'content': instance.content,
@ -101,44 +96,57 @@ Map<String, dynamic> _$$SnAccountContactImplToJson(
'verified_at': instance.verifiedAt?.toIso8601String(),
};
_$SnAccountProfileImpl _$$SnAccountProfileImplFromJson(
Map<String, dynamic> json) =>
_$SnAccountProfileImpl(
_SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) =>
_SnAccountProfile(
id: (json['id'] as num).toInt(),
accountId: (json['account_id'] as num).toInt(),
birthday: json['birthday'] == null
? null
: DateTime.parse(json['birthday'] as String),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
experience: (json['experience'] as num).toInt(),
firstName: json['first_name'] as String,
lastName: json['last_name'] as String,
description: json['description'] as String,
timeZone: json['time_zone'] as String,
location: json['location'] as String,
pronouns: json['pronouns'] as String,
gender: json['gender'] as String,
links: (json['links'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
) ??
const {},
experience: (json['experience'] as num).toInt(),
lastSeenAt: json['last_seen_at'] == null
? null
: DateTime.parse(json['last_seen_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
birthday: json['birthday'] == null
? null
: DateTime.parse(json['birthday'] as String),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$$SnAccountProfileImplToJson(
_$SnAccountProfileImpl instance) =>
Map<String, dynamic> _$SnAccountProfileToJson(_SnAccountProfile instance) =>
<String, dynamic>{
'id': instance.id,
'account_id': instance.accountId,
'birthday': instance.birthday?.toIso8601String(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'experience': instance.experience,
'first_name': instance.firstName,
'last_name': instance.lastName,
'description': instance.description,
'time_zone': instance.timeZone,
'location': instance.location,
'pronouns': instance.pronouns,
'gender': instance.gender,
'links': instance.links,
'experience': instance.experience,
'last_seen_at': instance.lastSeenAt?.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'birthday': instance.birthday?.toIso8601String(),
'account_id': instance.accountId,
};
_$SnRelationshipImpl _$$SnRelationshipImplFromJson(Map<String, dynamic> json) =>
_$SnRelationshipImpl(
_SnRelationship _$SnRelationshipFromJson(Map<String, dynamic> json) =>
_SnRelationship(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -157,8 +165,7 @@ _$SnRelationshipImpl _$$SnRelationshipImplFromJson(Map<String, dynamic> json) =>
permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {},
);
Map<String, dynamic> _$$SnRelationshipImplToJson(
_$SnRelationshipImpl instance) =>
Map<String, dynamic> _$SnRelationshipToJson(_SnRelationship instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -172,19 +179,19 @@ Map<String, dynamic> _$$SnRelationshipImplToJson(
'perm_nodes': instance.permNodes,
};
_$SnAccountBadgeImpl _$$SnAccountBadgeImplFromJson(Map<String, dynamic> json) =>
_$SnAccountBadgeImpl(
_SnAccountBadge _$SnAccountBadgeFromJson(Map<String, dynamic> json) =>
_SnAccountBadge(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'],
type: json['type'] as String,
accountId: (json['account_id'] as num).toInt(),
isActive: json['is_active'] as bool? ?? false,
metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
);
Map<String, dynamic> _$$SnAccountBadgeImplToJson(
_$SnAccountBadgeImpl instance) =>
Map<String, dynamic> _$SnAccountBadgeToJson(_SnAccountBadge instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -192,31 +199,67 @@ Map<String, dynamic> _$$SnAccountBadgeImplToJson(
'deleted_at': instance.deletedAt,
'type': instance.type,
'account_id': instance.accountId,
'is_active': instance.isActive,
'metadata': instance.metadata,
};
_$SnAccountStatusInfoImpl _$$SnAccountStatusInfoImplFromJson(
Map<String, dynamic> json) =>
_$SnAccountStatusInfoImpl(
_SnAccountStatusInfo _$SnAccountStatusInfoFromJson(Map<String, dynamic> json) =>
_SnAccountStatusInfo(
isDisturbable: json['is_disturbable'] as bool,
isOnline: json['is_online'] as bool,
lastSeenAt: json['last_seen_at'] == null
? null
: DateTime.parse(json['last_seen_at'] as String),
status: json['status'],
status: json['status'] == null
? null
: SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SnAccountStatusInfoImplToJson(
_$SnAccountStatusInfoImpl instance) =>
Map<String, dynamic> _$SnAccountStatusInfoToJson(
_SnAccountStatusInfo instance) =>
<String, dynamic>{
'is_disturbable': instance.isDisturbable,
'is_online': instance.isOnline,
'last_seen_at': instance.lastSeenAt?.toIso8601String(),
'status': instance.status,
'status': instance.status?.toJson(),
};
_$SnAbuseReportImpl _$$SnAbuseReportImplFromJson(Map<String, dynamic> json) =>
_$SnAbuseReportImpl(
_SnAccountStatus _$SnAccountStatusFromJson(Map<String, dynamic> json) =>
_SnAccountStatus(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
type: json['type'] as String,
label: json['label'] as String,
attitude: (json['attitude'] as num).toInt(),
isNoDisturb: json['is_no_disturb'] as bool,
isInvisible: json['is_invisible'] as bool,
clearAt: json['clear_at'] == null
? null
: DateTime.parse(json['clear_at'] as String),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$SnAccountStatusToJson(_SnAccountStatus instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'type': instance.type,
'label': instance.label,
'attitude': instance.attitude,
'is_no_disturb': instance.isNoDisturb,
'is_invisible': instance.isInvisible,
'clear_at': instance.clearAt?.toIso8601String(),
'account_id': instance.accountId,
};
_SnAbuseReport _$SnAbuseReportFromJson(Map<String, dynamic> json) =>
_SnAbuseReport(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -229,7 +272,7 @@ _$SnAbuseReportImpl _$$SnAbuseReportImplFromJson(Map<String, dynamic> json) =>
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$$SnAbuseReportImplToJson(_$SnAbuseReportImpl instance) =>
Map<String, dynamic> _$SnAbuseReportToJson(_SnAbuseReport instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -240,3 +283,39 @@ Map<String, dynamic> _$$SnAbuseReportImplToJson(_$SnAbuseReportImpl instance) =>
'status': instance.status,
'account_id': instance.accountId,
};
_SnActionEvent _$SnActionEventFromJson(Map<String, dynamic> json) =>
_SnActionEvent(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
type: json['type'] as String,
metadata: json['metadata'] as Map<String, dynamic>?,
location: json['location'] as String?,
coordinateX: (json['coordinate_x'] as num?)?.toDouble(),
coordinateY: (json['coordinate_y'] as num?)?.toDouble(),
ipAddress: json['ip_address'] as String,
userAgent: json['user_agent'] as String,
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$SnActionEventToJson(_SnActionEvent instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'type': instance.type,
'metadata': instance.metadata,
'location': instance.location,
'coordinate_x': instance.coordinateX,
'coordinate_y': instance.coordinateY,
'ip_address': instance.ipAddress,
'user_agent': instance.userAgent,
'account': instance.account.toJson(),
'account_id': instance.accountId,
};

View File

@ -12,7 +12,7 @@ enum SnMediaType {
}
@freezed
class SnAttachment with _$SnAttachment {
abstract class SnAttachment with _$SnAttachment {
const SnAttachment._();
const factory SnAttachment({
@ -65,7 +65,7 @@ class SnAttachment with _$SnAttachment {
}
@freezed
class SnAttachmentFragment with _$SnAttachmentFragment {
abstract class SnAttachmentFragment with _$SnAttachmentFragment {
const SnAttachmentFragment._();
const factory SnAttachmentFragment({
@ -96,7 +96,7 @@ class SnAttachmentFragment with _$SnAttachmentFragment {
}
@freezed
class SnAttachmentPool with _$SnAttachmentPool {
abstract class SnAttachmentPool with _$SnAttachmentPool {
const factory SnAttachmentPool({
required int id,
required DateTime createdAt,
@ -113,7 +113,7 @@ class SnAttachmentPool with _$SnAttachmentPool {
}
@freezed
class SnAttachmentDestination with _$SnAttachmentDestination {
abstract class SnAttachmentDestination with _$SnAttachmentDestination {
const factory SnAttachmentDestination({
@Default(0) int id,
required String type,
@ -126,7 +126,7 @@ class SnAttachmentDestination with _$SnAttachmentDestination {
}
@freezed
class SnAttachmentBoost with _$SnAttachmentBoost {
abstract class SnAttachmentBoost with _$SnAttachmentBoost {
const factory SnAttachmentBoost({
required int id,
required DateTime createdAt,
@ -143,7 +143,7 @@ class SnAttachmentBoost with _$SnAttachmentBoost {
}
@freezed
class SnSticker with _$SnSticker {
abstract class SnSticker with _$SnSticker {
const factory SnSticker({
required int id,
required DateTime createdAt,
@ -162,7 +162,7 @@ class SnSticker with _$SnSticker {
}
@freezed
class SnStickerPack with _$SnStickerPack {
abstract class SnStickerPack with _$SnStickerPack {
const factory SnStickerPack({
required int id,
required DateTime createdAt,
@ -179,7 +179,7 @@ class SnStickerPack with _$SnStickerPack {
}
@freezed
class SnAttachmentBilling with _$SnAttachmentBilling {
abstract class SnAttachmentBilling with _$SnAttachmentBilling {
const factory SnAttachmentBilling({
required int currentBytes,
required int discountFileSize,

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,8 @@ part of 'attachment.dart';
// JsonSerializableGenerator
// **************************************************************************
_$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
_$SnAttachmentImpl(
_SnAttachment _$SnAttachmentFromJson(Map<String, dynamic> json) =>
_SnAttachment(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -57,7 +57,7 @@ _$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
);
Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
Map<String, dynamic> _$SnAttachmentToJson(_SnAttachment instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -92,9 +92,9 @@ Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
'metadata': instance.metadata,
};
_$SnAttachmentFragmentImpl _$$SnAttachmentFragmentImplFromJson(
_SnAttachmentFragment _$SnAttachmentFragmentFromJson(
Map<String, dynamic> json) =>
_$SnAttachmentFragmentImpl(
_SnAttachmentFragment(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -119,8 +119,8 @@ _$SnAttachmentFragmentImpl _$$SnAttachmentFragmentImplFromJson(
const [],
);
Map<String, dynamic> _$$SnAttachmentFragmentImplToJson(
_$SnAttachmentFragmentImpl instance) =>
Map<String, dynamic> _$SnAttachmentFragmentToJson(
_SnAttachmentFragment instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -138,9 +138,8 @@ Map<String, dynamic> _$$SnAttachmentFragmentImplToJson(
'file_chunks_missing': instance.fileChunksMissing,
};
_$SnAttachmentPoolImpl _$$SnAttachmentPoolImplFromJson(
Map<String, dynamic> json) =>
_$SnAttachmentPoolImpl(
_SnAttachmentPool _$SnAttachmentPoolFromJson(Map<String, dynamic> json) =>
_SnAttachmentPool(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -154,8 +153,7 @@ _$SnAttachmentPoolImpl _$$SnAttachmentPoolImplFromJson(
accountId: (json['account_id'] as num?)?.toInt(),
);
Map<String, dynamic> _$$SnAttachmentPoolImplToJson(
_$SnAttachmentPoolImpl instance) =>
Map<String, dynamic> _$SnAttachmentPoolToJson(_SnAttachmentPool instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -168,9 +166,9 @@ Map<String, dynamic> _$$SnAttachmentPoolImplToJson(
'account_id': instance.accountId,
};
_$SnAttachmentDestinationImpl _$$SnAttachmentDestinationImplFromJson(
_SnAttachmentDestination _$SnAttachmentDestinationFromJson(
Map<String, dynamic> json) =>
_$SnAttachmentDestinationImpl(
_SnAttachmentDestination(
id: (json['id'] as num?)?.toInt() ?? 0,
type: json['type'] as String,
label: json['label'] as String,
@ -178,8 +176,8 @@ _$SnAttachmentDestinationImpl _$$SnAttachmentDestinationImplFromJson(
isBoost: json['is_boost'] as bool,
);
Map<String, dynamic> _$$SnAttachmentDestinationImplToJson(
_$SnAttachmentDestinationImpl instance) =>
Map<String, dynamic> _$SnAttachmentDestinationToJson(
_SnAttachmentDestination instance) =>
<String, dynamic>{
'id': instance.id,
'type': instance.type,
@ -188,9 +186,8 @@ Map<String, dynamic> _$$SnAttachmentDestinationImplToJson(
'is_boost': instance.isBoost,
};
_$SnAttachmentBoostImpl _$$SnAttachmentBoostImplFromJson(
Map<String, dynamic> json) =>
_$SnAttachmentBoostImpl(
_SnAttachmentBoost _$SnAttachmentBoostFromJson(Map<String, dynamic> json) =>
_SnAttachmentBoost(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -205,8 +202,7 @@ _$SnAttachmentBoostImpl _$$SnAttachmentBoostImplFromJson(
account: (json['account'] as num).toInt(),
);
Map<String, dynamic> _$$SnAttachmentBoostImplToJson(
_$SnAttachmentBoostImpl instance) =>
Map<String, dynamic> _$SnAttachmentBoostToJson(_SnAttachmentBoost instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -219,8 +215,7 @@ Map<String, dynamic> _$$SnAttachmentBoostImplToJson(
'account': instance.account,
};
_$SnStickerImpl _$$SnStickerImplFromJson(Map<String, dynamic> json) =>
_$SnStickerImpl(
_SnSticker _$SnStickerFromJson(Map<String, dynamic> json) => _SnSticker(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -237,7 +232,7 @@ _$SnStickerImpl _$$SnStickerImplFromJson(Map<String, dynamic> json) =>
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$$SnStickerImplToJson(_$SnStickerImpl instance) =>
Map<String, dynamic> _$SnStickerToJson(_SnSticker instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -252,8 +247,8 @@ Map<String, dynamic> _$$SnStickerImplToJson(_$SnStickerImpl instance) =>
'account_id': instance.accountId,
};
_$SnStickerPackImpl _$$SnStickerPackImplFromJson(Map<String, dynamic> json) =>
_$SnStickerPackImpl(
_SnStickerPack _$SnStickerPackFromJson(Map<String, dynamic> json) =>
_SnStickerPack(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -269,7 +264,7 @@ _$SnStickerPackImpl _$$SnStickerPackImplFromJson(Map<String, dynamic> json) =>
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$$SnStickerPackImplToJson(_$SnStickerPackImpl instance) =>
Map<String, dynamic> _$SnStickerPackToJson(_SnStickerPack instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -282,16 +277,15 @@ Map<String, dynamic> _$$SnStickerPackImplToJson(_$SnStickerPackImpl instance) =>
'account_id': instance.accountId,
};
_$SnAttachmentBillingImpl _$$SnAttachmentBillingImplFromJson(
Map<String, dynamic> json) =>
_$SnAttachmentBillingImpl(
_SnAttachmentBilling _$SnAttachmentBillingFromJson(Map<String, dynamic> json) =>
_SnAttachmentBilling(
currentBytes: (json['current_bytes'] as num).toInt(),
discountFileSize: (json['discount_file_size'] as num).toInt(),
includedRatio: (json['included_ratio'] as num).toDouble(),
);
Map<String, dynamic> _$$SnAttachmentBillingImplToJson(
_$SnAttachmentBillingImpl instance) =>
Map<String, dynamic> _$SnAttachmentBillingToJson(
_SnAttachmentBilling instance) =>
<String, dynamic>{
'current_bytes': instance.currentBytes,
'discount_file_size': instance.discountFileSize,

View File

@ -4,7 +4,7 @@ part 'auth.freezed.dart';
part 'auth.g.dart';
@freezed
class SnAuthResult with _$SnAuthResult {
abstract class SnAuthResult with _$SnAuthResult {
const factory SnAuthResult({
required bool isFinished,
required SnAuthTicket? ticket,
@ -15,7 +15,7 @@ class SnAuthResult with _$SnAuthResult {
}
@freezed
class SnAuthTicket with _$SnAuthTicket {
abstract class SnAuthTicket with _$SnAuthTicket {
const factory SnAuthTicket({
required int id,
required DateTime createdAt,
@ -26,7 +26,9 @@ class SnAuthTicket with _$SnAuthTicket {
required String? accessToken,
required String? refreshToken,
required String ipAddress,
required String location,
required String? location,
required double? coordinateX,
required double? coordinateY,
required String userAgent,
required DateTime? expiredAt,
required DateTime? lastGrantAt,
@ -41,7 +43,7 @@ class SnAuthTicket with _$SnAuthTicket {
}
@freezed
class SnAuthFactor with _$SnAuthFactor {
abstract class SnAuthFactor with _$SnAuthFactor {
const factory SnAuthFactor({
required int id,
required DateTime createdAt,

File diff suppressed because it is too large Load Diff

View File

@ -6,22 +6,22 @@ part of 'auth.dart';
// JsonSerializableGenerator
// **************************************************************************
_$SnAuthResultImpl _$$SnAuthResultImplFromJson(Map<String, dynamic> json) =>
_$SnAuthResultImpl(
_SnAuthResult _$SnAuthResultFromJson(Map<String, dynamic> json) =>
_SnAuthResult(
isFinished: json['is_finished'] as bool,
ticket: json['ticket'] == null
? null
: SnAuthTicket.fromJson(json['ticket'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SnAuthResultImplToJson(_$SnAuthResultImpl instance) =>
Map<String, dynamic> _$SnAuthResultToJson(_SnAuthResult instance) =>
<String, dynamic>{
'is_finished': instance.isFinished,
'ticket': instance.ticket?.toJson(),
};
_$SnAuthTicketImpl _$$SnAuthTicketImplFromJson(Map<String, dynamic> json) =>
_$SnAuthTicketImpl(
_SnAuthTicket _$SnAuthTicketFromJson(Map<String, dynamic> json) =>
_SnAuthTicket(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -33,7 +33,9 @@ _$SnAuthTicketImpl _$$SnAuthTicketImplFromJson(Map<String, dynamic> json) =>
accessToken: json['access_token'] as String?,
refreshToken: json['refresh_token'] as String?,
ipAddress: json['ip_address'] as String,
location: json['location'] as String,
location: json['location'] as String?,
coordinateX: (json['coordinate_x'] as num?)?.toDouble(),
coordinateY: (json['coordinate_y'] as num?)?.toDouble(),
userAgent: json['user_agent'] as String,
expiredAt: json['expired_at'] == null
? null
@ -52,7 +54,7 @@ _$SnAuthTicketImpl _$$SnAuthTicketImplFromJson(Map<String, dynamic> json) =>
const [],
);
Map<String, dynamic> _$$SnAuthTicketImplToJson(_$SnAuthTicketImpl instance) =>
Map<String, dynamic> _$SnAuthTicketToJson(_SnAuthTicket instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -64,6 +66,8 @@ Map<String, dynamic> _$$SnAuthTicketImplToJson(_$SnAuthTicketImpl instance) =>
'refresh_token': instance.refreshToken,
'ip_address': instance.ipAddress,
'location': instance.location,
'coordinate_x': instance.coordinateX,
'coordinate_y': instance.coordinateY,
'user_agent': instance.userAgent,
'expired_at': instance.expiredAt?.toIso8601String(),
'last_grant_at': instance.lastGrantAt?.toIso8601String(),
@ -73,8 +77,8 @@ Map<String, dynamic> _$$SnAuthTicketImplToJson(_$SnAuthTicketImpl instance) =>
'factor_trail': instance.factorTrail,
};
_$SnAuthFactorImpl _$$SnAuthFactorImplFromJson(Map<String, dynamic> json) =>
_$SnAuthFactorImpl(
_SnAuthFactor _$SnAuthFactorFromJson(Map<String, dynamic> json) =>
_SnAuthFactor(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -86,7 +90,7 @@ _$SnAuthFactorImpl _$$SnAuthFactorImplFromJson(Map<String, dynamic> json) =>
accountId: (json['account_id'] as num?)?.toInt(),
);
Map<String, dynamic> _$$SnAuthFactorImplToJson(_$SnAuthFactorImpl instance) =>
Map<String, dynamic> _$SnAuthFactorToJson(_SnAuthFactor instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),

View File

@ -8,7 +8,7 @@ part 'chat.freezed.dart';
part 'chat.g.dart';
@freezed
class SnChannel with _$SnChannel {
abstract class SnChannel with _$SnChannel {
const SnChannel._();
const factory SnChannel({
@ -37,7 +37,7 @@ class SnChannel with _$SnChannel {
}
@freezed
class SnChannelMember with _$SnChannelMember {
abstract class SnChannelMember with _$SnChannelMember {
const SnChannelMember._();
const factory SnChannelMember({
@ -61,7 +61,7 @@ class SnChannelMember with _$SnChannelMember {
}
@freezed
class SnChatMessage with _$SnChatMessage {
abstract class SnChatMessage with _$SnChatMessage {
const SnChatMessage._();
const factory SnChatMessage({
@ -86,7 +86,7 @@ class SnChatMessage with _$SnChatMessage {
}
@freezed
class SnChatMessagePreload with _$SnChatMessagePreload {
abstract class SnChatMessagePreload with _$SnChatMessagePreload {
const SnChatMessagePreload._();
const factory SnChatMessagePreload({
@ -99,7 +99,7 @@ class SnChatMessagePreload with _$SnChatMessagePreload {
}
@freezed
class SnChatCall with _$SnChatCall {
abstract class SnChatCall with _$SnChatCall {
const factory SnChatCall({
required int id,
required DateTime createdAt,

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,7 @@ part of 'chat.dart';
// JsonSerializableGenerator
// **************************************************************************
_$SnChannelImpl _$$SnChannelImplFromJson(Map<String, dynamic> json) =>
_$SnChannelImpl(
_SnChannel _$SnChannelFromJson(Map<String, dynamic> json) => _SnChannel(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -31,7 +30,7 @@ _$SnChannelImpl _$$SnChannelImplFromJson(Map<String, dynamic> json) =>
isCommunity: json['is_community'] as bool,
);
Map<String, dynamic> _$$SnChannelImplToJson(_$SnChannelImpl instance) =>
Map<String, dynamic> _$SnChannelToJson(_SnChannel instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -50,9 +49,8 @@ Map<String, dynamic> _$$SnChannelImplToJson(_$SnChannelImpl instance) =>
'is_community': instance.isCommunity,
};
_$SnChannelMemberImpl _$$SnChannelMemberImplFromJson(
Map<String, dynamic> json) =>
_$SnChannelMemberImpl(
_SnChannelMember _$SnChannelMemberFromJson(Map<String, dynamic> json) =>
_SnChannelMember(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -74,8 +72,7 @@ _$SnChannelMemberImpl _$$SnChannelMemberImplFromJson(
events: json['events'],
);
Map<String, dynamic> _$$SnChannelMemberImplToJson(
_$SnChannelMemberImpl instance) =>
Map<String, dynamic> _$SnChannelMemberToJson(_SnChannelMember instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -92,8 +89,8 @@ Map<String, dynamic> _$$SnChannelMemberImplToJson(
'events': instance.events,
};
_$SnChatMessageImpl _$$SnChatMessageImplFromJson(Map<String, dynamic> json) =>
_$SnChatMessageImpl(
_SnChatMessage _$SnChatMessageFromJson(Map<String, dynamic> json) =>
_SnChatMessage(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -115,7 +112,7 @@ _$SnChatMessageImpl _$$SnChatMessageImplFromJson(Map<String, dynamic> json) =>
json['preload'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) =>
Map<String, dynamic> _$SnChatMessageToJson(_SnChatMessage instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -133,9 +130,9 @@ Map<String, dynamic> _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) =>
'preload': instance.preload?.toJson(),
};
_$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson(
_SnChatMessagePreload _$SnChatMessagePreloadFromJson(
Map<String, dynamic> json) =>
_$SnChatMessagePreloadImpl(
_SnChatMessagePreload(
attachments: (json['attachments'] as List<dynamic>?)
?.map((e) => e == null
? null
@ -146,15 +143,14 @@ _$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson(
: SnChatMessage.fromJson(json['quote_event'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SnChatMessagePreloadImplToJson(
_$SnChatMessagePreloadImpl instance) =>
Map<String, dynamic> _$SnChatMessagePreloadToJson(
_SnChatMessagePreload instance) =>
<String, dynamic>{
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
'quote_event': instance.quoteEvent?.toJson(),
};
_$SnChatCallImpl _$$SnChatCallImplFromJson(Map<String, dynamic> json) =>
_$SnChatCallImpl(
_SnChatCall _$SnChatCallFromJson(Map<String, dynamic> json) => _SnChatCall(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -172,7 +168,7 @@ _$SnChatCallImpl _$$SnChatCallImplFromJson(Map<String, dynamic> json) =>
participants: json['participants'] as List<dynamic>? ?? const [],
);
Map<String, dynamic> _$$SnChatCallImplToJson(_$SnChatCallImpl instance) =>
Map<String, dynamic> _$SnChatCallToJson(_SnChatCall instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),

View File

@ -14,7 +14,7 @@ final List<String> kCheckInResultTierSymbols = [
].map((e) => e.tr()).toList();
@freezed
class SnCheckInRecord with _$SnCheckInRecord {
abstract class SnCheckInRecord with _$SnCheckInRecord {
const SnCheckInRecord._();
const factory SnCheckInRecord({
@ -25,11 +25,13 @@ class SnCheckInRecord with _$SnCheckInRecord {
required int resultTier,
required int resultExperience,
required double resultCoin,
@Default(0) int currentStreak,
required List<int> resultModifiers,
required int accountId,
}) = _SnCheckInRecord;
factory SnCheckInRecord.fromJson(Map<String, dynamic> json) => _$SnCheckInRecordFromJson(json);
factory SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
_$SnCheckInRecordFromJson(json);
String get symbol => kCheckInResultTierSymbols[resultTier];
}

View File

@ -1,3 +1,4 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
@ -9,128 +10,85 @@ part of 'check_in.dart';
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnCheckInRecord _$SnCheckInRecordFromJson(Map<String, dynamic> json) {
return _SnCheckInRecord.fromJson(json);
}
/// @nodoc
mixin _$SnCheckInRecord {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
int get resultTier => throw _privateConstructorUsedError;
int get resultExperience => throw _privateConstructorUsedError;
double get resultCoin => throw _privateConstructorUsedError;
List<int> get resultModifiers => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError;
/// Serializes this SnCheckInRecord to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
int get id;
DateTime get createdAt;
DateTime get updatedAt;
DateTime? get deletedAt;
int get resultTier;
int get resultExperience;
double get resultCoin;
int get currentStreak;
List<int> get resultModifiers;
int get accountId;
/// Create a copy of SnCheckInRecord
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnCheckInRecordCopyWith<SnCheckInRecord> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnCheckInRecordCopyWith<$Res> {
factory $SnCheckInRecordCopyWith(
SnCheckInRecord value, $Res Function(SnCheckInRecord) then) =
_$SnCheckInRecordCopyWithImpl<$Res, SnCheckInRecord>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
int resultTier,
int resultExperience,
double resultCoin,
List<int> resultModifiers,
int accountId});
}
/// @nodoc
class _$SnCheckInRecordCopyWithImpl<$Res, $Val extends SnCheckInRecord>
implements $SnCheckInRecordCopyWith<$Res> {
_$SnCheckInRecordCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnCheckInRecord
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
$SnCheckInRecordCopyWith<SnCheckInRecord> get copyWith =>
_$SnCheckInRecordCopyWithImpl<SnCheckInRecord>(
this as SnCheckInRecord, _$identity);
/// Serializes this SnCheckInRecord to a JSON map.
Map<String, dynamic> toJson();
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? resultTier = null,
Object? resultExperience = null,
Object? resultCoin = null,
Object? resultModifiers = null,
Object? accountId = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
resultTier: null == resultTier
? _value.resultTier
: resultTier // ignore: cast_nullable_to_non_nullable
as int,
resultExperience: null == resultExperience
? _value.resultExperience
: resultExperience // ignore: cast_nullable_to_non_nullable
as int,
resultCoin: null == resultCoin
? _value.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable
as double,
resultModifiers: null == resultModifiers
? _value.resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable
as List<int>,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
) as $Val);
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is SnCheckInRecord &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.resultTier, resultTier) ||
other.resultTier == resultTier) &&
(identical(other.resultExperience, resultExperience) ||
other.resultExperience == resultExperience) &&
(identical(other.resultCoin, resultCoin) ||
other.resultCoin == resultCoin) &&
(identical(other.currentStreak, currentStreak) ||
other.currentStreak == currentStreak) &&
const DeepCollectionEquality()
.equals(other.resultModifiers, resultModifiers) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
resultTier,
resultExperience,
resultCoin,
currentStreak,
const DeepCollectionEquality().hash(resultModifiers),
accountId);
@override
String toString() {
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, currentStreak: $currentStreak, resultModifiers: $resultModifiers, accountId: $accountId)';
}
}
/// @nodoc
abstract class _$$SnCheckInRecordImplCopyWith<$Res>
implements $SnCheckInRecordCopyWith<$Res> {
factory _$$SnCheckInRecordImplCopyWith(_$SnCheckInRecordImpl value,
$Res Function(_$SnCheckInRecordImpl) then) =
__$$SnCheckInRecordImplCopyWithImpl<$Res>;
@override
abstract mixin class $SnCheckInRecordCopyWith<$Res> {
factory $SnCheckInRecordCopyWith(
SnCheckInRecord value, $Res Function(SnCheckInRecord) _then) =
_$SnCheckInRecordCopyWithImpl;
@useResult
$Res call(
{int id,
@ -140,17 +98,18 @@ abstract class _$$SnCheckInRecordImplCopyWith<$Res>
int resultTier,
int resultExperience,
double resultCoin,
int currentStreak,
List<int> resultModifiers,
int accountId});
}
/// @nodoc
class __$$SnCheckInRecordImplCopyWithImpl<$Res>
extends _$SnCheckInRecordCopyWithImpl<$Res, _$SnCheckInRecordImpl>
implements _$$SnCheckInRecordImplCopyWith<$Res> {
__$$SnCheckInRecordImplCopyWithImpl(
_$SnCheckInRecordImpl _value, $Res Function(_$SnCheckInRecordImpl) _then)
: super(_value, _then);
class _$SnCheckInRecordCopyWithImpl<$Res>
implements $SnCheckInRecordCopyWith<$Res> {
_$SnCheckInRecordCopyWithImpl(this._self, this._then);
final SnCheckInRecord _self;
final $Res Function(SnCheckInRecord) _then;
/// Create a copy of SnCheckInRecord
/// with the given fields replaced by the non-null parameter values.
@ -164,44 +123,49 @@ class __$$SnCheckInRecordImplCopyWithImpl<$Res>
Object? resultTier = null,
Object? resultExperience = null,
Object? resultCoin = null,
Object? currentStreak = null,
Object? resultModifiers = null,
Object? accountId = null,
}) {
return _then(_$SnCheckInRecordImpl(
return _then(_self.copyWith(
id: null == id
? _value.id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
resultTier: null == resultTier
? _value.resultTier
? _self.resultTier
: resultTier // ignore: cast_nullable_to_non_nullable
as int,
resultExperience: null == resultExperience
? _value.resultExperience
? _self.resultExperience
: resultExperience // ignore: cast_nullable_to_non_nullable
as int,
resultCoin: null == resultCoin
? _value.resultCoin
? _self.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable
as double,
currentStreak: null == currentStreak
? _self.currentStreak
: currentStreak // ignore: cast_nullable_to_non_nullable
as int,
resultModifiers: null == resultModifiers
? _value._resultModifiers
? _self.resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable
as List<int>,
accountId: null == accountId
? _value.accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
));
@ -210,8 +174,8 @@ class __$$SnCheckInRecordImplCopyWithImpl<$Res>
/// @nodoc
@JsonSerializable()
class _$SnCheckInRecordImpl extends _SnCheckInRecord {
const _$SnCheckInRecordImpl(
class _SnCheckInRecord extends SnCheckInRecord {
const _SnCheckInRecord(
{required this.id,
required this.createdAt,
required this.updatedAt,
@ -219,13 +183,13 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
required this.resultTier,
required this.resultExperience,
required this.resultCoin,
this.currentStreak = 0,
required final List<int> resultModifiers,
required this.accountId})
: _resultModifiers = resultModifiers,
super._();
factory _$SnCheckInRecordImpl.fromJson(Map<String, dynamic> json) =>
_$$SnCheckInRecordImplFromJson(json);
factory _SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
_$SnCheckInRecordFromJson(json);
@override
final int id;
@ -241,6 +205,9 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
final int resultExperience;
@override
final double resultCoin;
@override
@JsonKey()
final int currentStreak;
final List<int> _resultModifiers;
@override
List<int> get resultModifiers {
@ -252,16 +219,26 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
@override
final int accountId;
/// Create a copy of SnCheckInRecord
/// with the given fields replaced by the non-null parameter values.
@override
String toString() {
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)';
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnCheckInRecordCopyWith<_SnCheckInRecord> get copyWith =>
__$SnCheckInRecordCopyWithImpl<_SnCheckInRecord>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnCheckInRecordToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnCheckInRecordImpl &&
other is _SnCheckInRecord &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
@ -275,6 +252,8 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
other.resultExperience == resultExperience) &&
(identical(other.resultCoin, resultCoin) ||
other.resultCoin == resultCoin) &&
(identical(other.currentStreak, currentStreak) ||
other.currentStreak == currentStreak) &&
const DeepCollectionEquality()
.equals(other._resultModifiers, _resultModifiers) &&
(identical(other.accountId, accountId) ||
@ -292,65 +271,104 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
resultTier,
resultExperience,
resultCoin,
currentStreak,
const DeepCollectionEquality().hash(_resultModifiers),
accountId);
/// Create a copy of SnCheckInRecord
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnCheckInRecordImplCopyWith<_$SnCheckInRecordImpl> get copyWith =>
__$$SnCheckInRecordImplCopyWithImpl<_$SnCheckInRecordImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnCheckInRecordImplToJson(
this,
);
String toString() {
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, currentStreak: $currentStreak, resultModifiers: $resultModifiers, accountId: $accountId)';
}
}
abstract class _SnCheckInRecord extends SnCheckInRecord {
const factory _SnCheckInRecord(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final int resultTier,
required final int resultExperience,
required final double resultCoin,
required final List<int> resultModifiers,
required final int accountId}) = _$SnCheckInRecordImpl;
const _SnCheckInRecord._() : super._();
/// @nodoc
abstract mixin class _$SnCheckInRecordCopyWith<$Res>
implements $SnCheckInRecordCopyWith<$Res> {
factory _$SnCheckInRecordCopyWith(
_SnCheckInRecord value, $Res Function(_SnCheckInRecord) _then) =
__$SnCheckInRecordCopyWithImpl;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
int resultTier,
int resultExperience,
double resultCoin,
int currentStreak,
List<int> resultModifiers,
int accountId});
}
factory _SnCheckInRecord.fromJson(Map<String, dynamic> json) =
_$SnCheckInRecordImpl.fromJson;
/// @nodoc
class __$SnCheckInRecordCopyWithImpl<$Res>
implements _$SnCheckInRecordCopyWith<$Res> {
__$SnCheckInRecordCopyWithImpl(this._self, this._then);
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
int get resultTier;
@override
int get resultExperience;
@override
double get resultCoin;
@override
List<int> get resultModifiers;
@override
int get accountId;
final _SnCheckInRecord _self;
final $Res Function(_SnCheckInRecord) _then;
/// Create a copy of SnCheckInRecord
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnCheckInRecordImplCopyWith<_$SnCheckInRecordImpl> get copyWith =>
throw _privateConstructorUsedError;
@pragma('vm:prefer-inline')
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? resultTier = null,
Object? resultExperience = null,
Object? resultCoin = null,
Object? currentStreak = null,
Object? resultModifiers = null,
Object? accountId = null,
}) {
return _then(_SnCheckInRecord(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
resultTier: null == resultTier
? _self.resultTier
: resultTier // ignore: cast_nullable_to_non_nullable
as int,
resultExperience: null == resultExperience
? _self.resultExperience
: resultExperience // ignore: cast_nullable_to_non_nullable
as int,
resultCoin: null == resultCoin
? _self.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable
as double,
currentStreak: null == currentStreak
? _self.currentStreak
: currentStreak // ignore: cast_nullable_to_non_nullable
as int,
resultModifiers: null == resultModifiers
? _self._resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable
as List<int>,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
// dart format on

View File

@ -6,9 +6,8 @@ part of 'check_in.dart';
// JsonSerializableGenerator
// **************************************************************************
_$SnCheckInRecordImpl _$$SnCheckInRecordImplFromJson(
Map<String, dynamic> json) =>
_$SnCheckInRecordImpl(
_SnCheckInRecord _$SnCheckInRecordFromJson(Map<String, dynamic> json) =>
_SnCheckInRecord(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -18,14 +17,14 @@ _$SnCheckInRecordImpl _$$SnCheckInRecordImplFromJson(
resultTier: (json['result_tier'] as num).toInt(),
resultExperience: (json['result_experience'] as num).toInt(),
resultCoin: (json['result_coin'] as num).toDouble(),
currentStreak: (json['current_streak'] as num?)?.toInt() ?? 0,
resultModifiers: (json['result_modifiers'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$$SnCheckInRecordImplToJson(
_$SnCheckInRecordImpl instance) =>
Map<String, dynamic> _$SnCheckInRecordToJson(_SnCheckInRecord instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
@ -34,6 +33,7 @@ Map<String, dynamic> _$$SnCheckInRecordImplToJson(
'result_tier': instance.resultTier,
'result_experience': instance.resultExperience,
'result_coin': instance.resultCoin,
'current_streak': instance.currentStreak,
'result_modifiers': instance.resultModifiers,
'account_id': instance.accountId,
};

18
lib/types/keypair.dart Normal file
View File

@ -0,0 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'keypair.freezed.dart';
part 'keypair.g.dart';
@freezed
abstract class SnKeyPair with _$SnKeyPair {
const factory SnKeyPair({
required String id,
required int accountId,
required String publicKey,
bool? isActive,
String? privateKey,
}) = _SnKeyPair;
factory SnKeyPair.fromJson(Map<String, Object?> json) =>
_$SnKeyPairFromJson(json);
}

View File

@ -0,0 +1,241 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'keypair.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnKeyPair {
String get id;
int get accountId;
String get publicKey;
bool? get isActive;
String? get privateKey;
/// Create a copy of SnKeyPair
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnKeyPairCopyWith<SnKeyPair> get copyWith =>
_$SnKeyPairCopyWithImpl<SnKeyPair>(this as SnKeyPair, _$identity);
/// Serializes this SnKeyPair to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is SnKeyPair &&
(identical(other.id, id) || other.id == id) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.publicKey, publicKey) ||
other.publicKey == publicKey) &&
(identical(other.isActive, isActive) ||
other.isActive == isActive) &&
(identical(other.privateKey, privateKey) ||
other.privateKey == privateKey));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode =>
Object.hash(runtimeType, id, accountId, publicKey, isActive, privateKey);
@override
String toString() {
return 'SnKeyPair(id: $id, accountId: $accountId, publicKey: $publicKey, isActive: $isActive, privateKey: $privateKey)';
}
}
/// @nodoc
abstract mixin class $SnKeyPairCopyWith<$Res> {
factory $SnKeyPairCopyWith(SnKeyPair value, $Res Function(SnKeyPair) _then) =
_$SnKeyPairCopyWithImpl;
@useResult
$Res call(
{String id,
int accountId,
String publicKey,
bool? isActive,
String? privateKey});
}
/// @nodoc
class _$SnKeyPairCopyWithImpl<$Res> implements $SnKeyPairCopyWith<$Res> {
_$SnKeyPairCopyWithImpl(this._self, this._then);
final SnKeyPair _self;
final $Res Function(SnKeyPair) _then;
/// Create a copy of SnKeyPair
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? accountId = null,
Object? publicKey = null,
Object? isActive = freezed,
Object? privateKey = freezed,
}) {
return _then(_self.copyWith(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as String,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
publicKey: null == publicKey
? _self.publicKey
: publicKey // ignore: cast_nullable_to_non_nullable
as String,
isActive: freezed == isActive
? _self.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool?,
privateKey: freezed == privateKey
? _self.privateKey
: privateKey // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
@JsonSerializable()
class _SnKeyPair implements SnKeyPair {
const _SnKeyPair(
{required this.id,
required this.accountId,
required this.publicKey,
this.isActive,
this.privateKey});
factory _SnKeyPair.fromJson(Map<String, dynamic> json) =>
_$SnKeyPairFromJson(json);
@override
final String id;
@override
final int accountId;
@override
final String publicKey;
@override
final bool? isActive;
@override
final String? privateKey;
/// Create a copy of SnKeyPair
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnKeyPairCopyWith<_SnKeyPair> get copyWith =>
__$SnKeyPairCopyWithImpl<_SnKeyPair>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnKeyPairToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _SnKeyPair &&
(identical(other.id, id) || other.id == id) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.publicKey, publicKey) ||
other.publicKey == publicKey) &&
(identical(other.isActive, isActive) ||
other.isActive == isActive) &&
(identical(other.privateKey, privateKey) ||
other.privateKey == privateKey));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode =>
Object.hash(runtimeType, id, accountId, publicKey, isActive, privateKey);
@override
String toString() {
return 'SnKeyPair(id: $id, accountId: $accountId, publicKey: $publicKey, isActive: $isActive, privateKey: $privateKey)';
}
}
/// @nodoc
abstract mixin class _$SnKeyPairCopyWith<$Res>
implements $SnKeyPairCopyWith<$Res> {
factory _$SnKeyPairCopyWith(
_SnKeyPair value, $Res Function(_SnKeyPair) _then) =
__$SnKeyPairCopyWithImpl;
@override
@useResult
$Res call(
{String id,
int accountId,
String publicKey,
bool? isActive,
String? privateKey});
}
/// @nodoc
class __$SnKeyPairCopyWithImpl<$Res> implements _$SnKeyPairCopyWith<$Res> {
__$SnKeyPairCopyWithImpl(this._self, this._then);
final _SnKeyPair _self;
final $Res Function(_SnKeyPair) _then;
/// Create a copy of SnKeyPair
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$Res call({
Object? id = null,
Object? accountId = null,
Object? publicKey = null,
Object? isActive = freezed,
Object? privateKey = freezed,
}) {
return _then(_SnKeyPair(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as String,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
publicKey: null == publicKey
? _self.publicKey
: publicKey // ignore: cast_nullable_to_non_nullable
as String,
isActive: freezed == isActive
? _self.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool?,
privateKey: freezed == privateKey
? _self.privateKey
: privateKey // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on

24
lib/types/keypair.g.dart Normal file
View File

@ -0,0 +1,24 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'keypair.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnKeyPair _$SnKeyPairFromJson(Map<String, dynamic> json) => _SnKeyPair(
id: json['id'] as String,
accountId: (json['account_id'] as num).toInt(),
publicKey: json['public_key'] as String,
isActive: json['is_active'] as bool?,
privateKey: json['private_key'] as String?,
);
Map<String, dynamic> _$SnKeyPairToJson(_SnKeyPair instance) =>
<String, dynamic>{
'id': instance.id,
'account_id': instance.accountId,
'public_key': instance.publicKey,
'is_active': instance.isActive,
'private_key': instance.privateKey,
};

View File

@ -4,7 +4,7 @@ part 'link.g.dart';
part 'link.freezed.dart';
@freezed
class SnLinkMeta with _$SnLinkMeta {
abstract class SnLinkMeta with _$SnLinkMeta {
const SnLinkMeta._();
const factory SnLinkMeta({

View File

@ -1,3 +1,4 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
@ -9,332 +10,41 @@ part of 'link.dart';
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnLinkMeta _$SnLinkMetaFromJson(Map<String, dynamic> json) {
return _SnLinkMeta.fromJson(json);
}
/// @nodoc
mixin _$SnLinkMeta {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
String get entryId => throw _privateConstructorUsedError;
String? get icon => throw _privateConstructorUsedError;
String get url => throw _privateConstructorUsedError;
String? get title => throw _privateConstructorUsedError;
String? get image => throw _privateConstructorUsedError;
String? get video => throw _privateConstructorUsedError;
String? get audio => throw _privateConstructorUsedError;
String? get description => throw _privateConstructorUsedError;
String? get siteName => throw _privateConstructorUsedError;
String? get type => throw _privateConstructorUsedError;
/// Serializes this SnLinkMeta to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
int get id;
DateTime get createdAt;
DateTime get updatedAt;
DateTime? get deletedAt;
String get entryId;
String? get icon;
String get url;
String? get title;
String? get image;
String? get video;
String? get audio;
String? get description;
String? get siteName;
String? get type;
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnLinkMetaCopyWith<SnLinkMeta> get copyWith =>
throw _privateConstructorUsedError;
}
_$SnLinkMetaCopyWithImpl<SnLinkMeta>(this as SnLinkMeta, _$identity);
/// @nodoc
abstract class $SnLinkMetaCopyWith<$Res> {
factory $SnLinkMetaCopyWith(
SnLinkMeta value, $Res Function(SnLinkMeta) then) =
_$SnLinkMetaCopyWithImpl<$Res, SnLinkMeta>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String entryId,
String? icon,
String url,
String? title,
String? image,
String? video,
String? audio,
String? description,
String? siteName,
String? type});
}
/// @nodoc
class _$SnLinkMetaCopyWithImpl<$Res, $Val extends SnLinkMeta>
implements $SnLinkMetaCopyWith<$Res> {
_$SnLinkMetaCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? entryId = null,
Object? icon = freezed,
Object? url = null,
Object? title = freezed,
Object? image = freezed,
Object? video = freezed,
Object? audio = freezed,
Object? description = freezed,
Object? siteName = freezed,
Object? type = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
entryId: null == entryId
? _value.entryId
: entryId // ignore: cast_nullable_to_non_nullable
as String,
icon: freezed == icon
? _value.icon
: icon // ignore: cast_nullable_to_non_nullable
as String?,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
title: freezed == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String?,
image: freezed == image
? _value.image
: image // ignore: cast_nullable_to_non_nullable
as String?,
video: freezed == video
? _value.video
: video // ignore: cast_nullable_to_non_nullable
as String?,
audio: freezed == audio
? _value.audio
: audio // ignore: cast_nullable_to_non_nullable
as String?,
description: freezed == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String?,
siteName: freezed == siteName
? _value.siteName
: siteName // ignore: cast_nullable_to_non_nullable
as String?,
type: freezed == type
? _value.type
: type // ignore: cast_nullable_to_non_nullable
as String?,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnLinkMetaImplCopyWith<$Res>
implements $SnLinkMetaCopyWith<$Res> {
factory _$$SnLinkMetaImplCopyWith(
_$SnLinkMetaImpl value, $Res Function(_$SnLinkMetaImpl) then) =
__$$SnLinkMetaImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String entryId,
String? icon,
String url,
String? title,
String? image,
String? video,
String? audio,
String? description,
String? siteName,
String? type});
}
/// @nodoc
class __$$SnLinkMetaImplCopyWithImpl<$Res>
extends _$SnLinkMetaCopyWithImpl<$Res, _$SnLinkMetaImpl>
implements _$$SnLinkMetaImplCopyWith<$Res> {
__$$SnLinkMetaImplCopyWithImpl(
_$SnLinkMetaImpl _value, $Res Function(_$SnLinkMetaImpl) _then)
: super(_value, _then);
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? entryId = null,
Object? icon = freezed,
Object? url = null,
Object? title = freezed,
Object? image = freezed,
Object? video = freezed,
Object? audio = freezed,
Object? description = freezed,
Object? siteName = freezed,
Object? type = freezed,
}) {
return _then(_$SnLinkMetaImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
entryId: null == entryId
? _value.entryId
: entryId // ignore: cast_nullable_to_non_nullable
as String,
icon: freezed == icon
? _value.icon
: icon // ignore: cast_nullable_to_non_nullable
as String?,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
title: freezed == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String?,
image: freezed == image
? _value.image
: image // ignore: cast_nullable_to_non_nullable
as String?,
video: freezed == video
? _value.video
: video // ignore: cast_nullable_to_non_nullable
as String?,
audio: freezed == audio
? _value.audio
: audio // ignore: cast_nullable_to_non_nullable
as String?,
description: freezed == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String?,
siteName: freezed == siteName
? _value.siteName
: siteName // ignore: cast_nullable_to_non_nullable
as String?,
type: freezed == type
? _value.type
: type // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnLinkMetaImpl extends _SnLinkMeta {
const _$SnLinkMetaImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.entryId,
required this.icon,
required this.url,
required this.title,
required this.image,
required this.video,
required this.audio,
required this.description,
required this.siteName,
required this.type})
: super._();
factory _$SnLinkMetaImpl.fromJson(Map<String, dynamic> json) =>
_$$SnLinkMetaImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String entryId;
@override
final String? icon;
@override
final String url;
@override
final String? title;
@override
final String? image;
@override
final String? video;
@override
final String? audio;
@override
final String? description;
@override
final String? siteName;
@override
final String? type;
@override
String toString() {
return 'SnLinkMeta(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, entryId: $entryId, icon: $icon, url: $url, title: $title, image: $image, video: $video, audio: $audio, description: $description, siteName: $siteName, type: $type)';
}
/// Serializes this SnLinkMeta to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnLinkMetaImpl &&
other is SnLinkMeta &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
@ -375,76 +85,351 @@ class _$SnLinkMetaImpl extends _SnLinkMeta {
siteName,
type);
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnLinkMetaImplCopyWith<_$SnLinkMetaImpl> get copyWith =>
__$$SnLinkMetaImplCopyWithImpl<_$SnLinkMetaImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnLinkMetaImplToJson(
this,
);
String toString() {
return 'SnLinkMeta(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, entryId: $entryId, icon: $icon, url: $url, title: $title, image: $image, video: $video, audio: $audio, description: $description, siteName: $siteName, type: $type)';
}
}
abstract class _SnLinkMeta extends SnLinkMeta {
const factory _SnLinkMeta(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final String entryId,
required final String? icon,
required final String url,
required final String? title,
required final String? image,
required final String? video,
required final String? audio,
required final String? description,
required final String? siteName,
required final String? type}) = _$SnLinkMetaImpl;
const _SnLinkMeta._() : super._();
/// @nodoc
abstract mixin class $SnLinkMetaCopyWith<$Res> {
factory $SnLinkMetaCopyWith(
SnLinkMeta value, $Res Function(SnLinkMeta) _then) =
_$SnLinkMetaCopyWithImpl;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String entryId,
String? icon,
String url,
String? title,
String? image,
String? video,
String? audio,
String? description,
String? siteName,
String? type});
}
factory _SnLinkMeta.fromJson(Map<String, dynamic> json) =
_$SnLinkMetaImpl.fromJson;
/// @nodoc
class _$SnLinkMetaCopyWithImpl<$Res> implements $SnLinkMetaCopyWith<$Res> {
_$SnLinkMetaCopyWithImpl(this._self, this._then);
final SnLinkMeta _self;
final $Res Function(SnLinkMeta) _then;
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? entryId = null,
Object? icon = freezed,
Object? url = null,
Object? title = freezed,
Object? image = freezed,
Object? video = freezed,
Object? audio = freezed,
Object? description = freezed,
Object? siteName = freezed,
Object? type = freezed,
}) {
return _then(_self.copyWith(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
entryId: null == entryId
? _self.entryId
: entryId // ignore: cast_nullable_to_non_nullable
as String,
icon: freezed == icon
? _self.icon
: icon // ignore: cast_nullable_to_non_nullable
as String?,
url: null == url
? _self.url
: url // ignore: cast_nullable_to_non_nullable
as String,
title: freezed == title
? _self.title
: title // ignore: cast_nullable_to_non_nullable
as String?,
image: freezed == image
? _self.image
: image // ignore: cast_nullable_to_non_nullable
as String?,
video: freezed == video
? _self.video
: video // ignore: cast_nullable_to_non_nullable
as String?,
audio: freezed == audio
? _self.audio
: audio // ignore: cast_nullable_to_non_nullable
as String?,
description: freezed == description
? _self.description
: description // ignore: cast_nullable_to_non_nullable
as String?,
siteName: freezed == siteName
? _self.siteName
: siteName // ignore: cast_nullable_to_non_nullable
as String?,
type: freezed == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
@JsonSerializable()
class _SnLinkMeta extends SnLinkMeta {
const _SnLinkMeta(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.entryId,
required this.icon,
required this.url,
required this.title,
required this.image,
required this.video,
required this.audio,
required this.description,
required this.siteName,
required this.type})
: super._();
factory _SnLinkMeta.fromJson(Map<String, dynamic> json) =>
_$SnLinkMetaFromJson(json);
@override
int get id;
final int id;
@override
DateTime get createdAt;
final DateTime createdAt;
@override
DateTime get updatedAt;
final DateTime updatedAt;
@override
DateTime? get deletedAt;
final DateTime? deletedAt;
@override
String get entryId;
final String entryId;
@override
String? get icon;
final String? icon;
@override
String get url;
final String url;
@override
String? get title;
final String? title;
@override
String? get image;
final String? image;
@override
String? get video;
final String? video;
@override
String? get audio;
final String? audio;
@override
String? get description;
final String? description;
@override
String? get siteName;
final String? siteName;
@override
String? get type;
final String? type;
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnLinkMetaImplCopyWith<_$SnLinkMetaImpl> get copyWith =>
throw _privateConstructorUsedError;
@pragma('vm:prefer-inline')
_$SnLinkMetaCopyWith<_SnLinkMeta> get copyWith =>
__$SnLinkMetaCopyWithImpl<_SnLinkMeta>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnLinkMetaToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _SnLinkMeta &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.entryId, entryId) || other.entryId == entryId) &&
(identical(other.icon, icon) || other.icon == icon) &&
(identical(other.url, url) || other.url == url) &&
(identical(other.title, title) || other.title == title) &&
(identical(other.image, image) || other.image == image) &&
(identical(other.video, video) || other.video == video) &&
(identical(other.audio, audio) || other.audio == audio) &&
(identical(other.description, description) ||
other.description == description) &&
(identical(other.siteName, siteName) ||
other.siteName == siteName) &&
(identical(other.type, type) || other.type == type));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
entryId,
icon,
url,
title,
image,
video,
audio,
description,
siteName,
type);
@override
String toString() {
return 'SnLinkMeta(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, entryId: $entryId, icon: $icon, url: $url, title: $title, image: $image, video: $video, audio: $audio, description: $description, siteName: $siteName, type: $type)';
}
}
/// @nodoc
abstract mixin class _$SnLinkMetaCopyWith<$Res>
implements $SnLinkMetaCopyWith<$Res> {
factory _$SnLinkMetaCopyWith(
_SnLinkMeta value, $Res Function(_SnLinkMeta) _then) =
__$SnLinkMetaCopyWithImpl;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String entryId,
String? icon,
String url,
String? title,
String? image,
String? video,
String? audio,
String? description,
String? siteName,
String? type});
}
/// @nodoc
class __$SnLinkMetaCopyWithImpl<$Res> implements _$SnLinkMetaCopyWith<$Res> {
__$SnLinkMetaCopyWithImpl(this._self, this._then);
final _SnLinkMeta _self;
final $Res Function(_SnLinkMeta) _then;
/// Create a copy of SnLinkMeta
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? entryId = null,
Object? icon = freezed,
Object? url = null,
Object? title = freezed,
Object? image = freezed,
Object? video = freezed,
Object? audio = freezed,
Object? description = freezed,
Object? siteName = freezed,
Object? type = freezed,
}) {
return _then(_SnLinkMeta(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
entryId: null == entryId
? _self.entryId
: entryId // ignore: cast_nullable_to_non_nullable
as String,
icon: freezed == icon
? _self.icon
: icon // ignore: cast_nullable_to_non_nullable
as String?,
url: null == url
? _self.url
: url // ignore: cast_nullable_to_non_nullable
as String,
title: freezed == title
? _self.title
: title // ignore: cast_nullable_to_non_nullable
as String?,
image: freezed == image
? _self.image
: image // ignore: cast_nullable_to_non_nullable
as String?,
video: freezed == video
? _self.video
: video // ignore: cast_nullable_to_non_nullable
as String?,
audio: freezed == audio
? _self.audio
: audio // ignore: cast_nullable_to_non_nullable
as String?,
description: freezed == description
? _self.description
: description // ignore: cast_nullable_to_non_nullable
as String?,
siteName: freezed == siteName
? _self.siteName
: siteName // ignore: cast_nullable_to_non_nullable
as String?,
type: freezed == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on

View File

@ -6,8 +6,7 @@ part of 'link.dart';
// JsonSerializableGenerator
// **************************************************************************
_$SnLinkMetaImpl _$$SnLinkMetaImplFromJson(Map<String, dynamic> json) =>
_$SnLinkMetaImpl(
_SnLinkMeta _$SnLinkMetaFromJson(Map<String, dynamic> json) => _SnLinkMeta(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -26,7 +25,7 @@ _$SnLinkMetaImpl _$$SnLinkMetaImplFromJson(Map<String, dynamic> json) =>
type: json['type'] as String?,
);
Map<String, dynamic> _$$SnLinkMetaImplToJson(_$SnLinkMetaImpl instance) =>
Map<String, dynamic> _$SnLinkMetaToJson(_SnLinkMeta instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),

View File

@ -4,7 +4,7 @@ part 'news.freezed.dart';
part 'news.g.dart';
@freezed
class SnNewsSource with _$SnNewsSource {
abstract class SnNewsSource with _$SnNewsSource {
const factory SnNewsSource({
required String id,
required String label,
@ -18,7 +18,7 @@ class SnNewsSource with _$SnNewsSource {
}
@freezed
class SnNewsArticle with _$SnNewsArticle {
abstract class SnNewsArticle with _$SnNewsArticle {
const factory SnNewsArticle({
required int id,
required DateTime createdAt,

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,8 @@ part of 'news.dart';
// JsonSerializableGenerator
// **************************************************************************
_$SnNewsSourceImpl _$$SnNewsSourceImplFromJson(Map<String, dynamic> json) =>
_$SnNewsSourceImpl(
_SnNewsSource _$SnNewsSourceFromJson(Map<String, dynamic> json) =>
_SnNewsSource(
id: json['id'] as String,
label: json['label'] as String,
type: json['type'] as String,
@ -16,7 +16,7 @@ _$SnNewsSourceImpl _$$SnNewsSourceImplFromJson(Map<String, dynamic> json) =>
enabled: json['enabled'] as bool,
);
Map<String, dynamic> _$$SnNewsSourceImplToJson(_$SnNewsSourceImpl instance) =>
Map<String, dynamic> _$SnNewsSourceToJson(_SnNewsSource instance) =>
<String, dynamic>{
'id': instance.id,
'label': instance.label,
@ -26,8 +26,8 @@ Map<String, dynamic> _$$SnNewsSourceImplToJson(_$SnNewsSourceImpl instance) =>
'enabled': instance.enabled,
};
_$SnNewsArticleImpl _$$SnNewsArticleImplFromJson(Map<String, dynamic> json) =>
_$SnNewsArticleImpl(
_SnNewsArticle _$SnNewsArticleFromJson(Map<String, dynamic> json) =>
_SnNewsArticle(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
@ -44,7 +44,7 @@ _$SnNewsArticleImpl _$$SnNewsArticleImplFromJson(Map<String, dynamic> json) =>
: DateTime.parse(json['published_at'] as String),
);
Map<String, dynamic> _$$SnNewsArticleImplToJson(_$SnNewsArticleImpl instance) =>
Map<String, dynamic> _$SnNewsArticleToJson(_SnNewsArticle instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),

View File

@ -4,7 +4,7 @@ part 'notification.freezed.dart';
part 'notification.g.dart';
@freezed
class SnNotification with _$SnNotification {
abstract class SnNotification with _$SnNotification {
const factory SnNotification({
required int id,
required DateTime createdAt,

View File

@ -1,3 +1,4 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
@ -9,156 +10,92 @@ part of 'notification.dart';
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnNotification _$SnNotificationFromJson(Map<String, dynamic> json) {
return _SnNotification.fromJson(json);
}
/// @nodoc
mixin _$SnNotification {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
String get topic => throw _privateConstructorUsedError;
String get title => throw _privateConstructorUsedError;
String? get subtitle => throw _privateConstructorUsedError;
String get body => throw _privateConstructorUsedError;
Map<String, dynamic> get metadata => throw _privateConstructorUsedError;
int get priority => throw _privateConstructorUsedError;
int? get senderId => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError;
DateTime? get readAt => throw _privateConstructorUsedError;
/// Serializes this SnNotification to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
int get id;
DateTime get createdAt;
DateTime get updatedAt;
DateTime? get deletedAt;
String get topic;
String get title;
String? get subtitle;
String get body;
Map<String, dynamic> get metadata;
int get priority;
int? get senderId;
int get accountId;
DateTime? get readAt;
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnNotificationCopyWith<SnNotification> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnNotificationCopyWith<$Res> {
factory $SnNotificationCopyWith(
SnNotification value, $Res Function(SnNotification) then) =
_$SnNotificationCopyWithImpl<$Res, SnNotification>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String topic,
String title,
String? subtitle,
String body,
Map<String, dynamic> metadata,
int priority,
int? senderId,
int accountId,
DateTime? readAt});
}
/// @nodoc
class _$SnNotificationCopyWithImpl<$Res, $Val extends SnNotification>
implements $SnNotificationCopyWith<$Res> {
_$SnNotificationCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
$SnNotificationCopyWith<SnNotification> get copyWith =>
_$SnNotificationCopyWithImpl<SnNotification>(
this as SnNotification, _$identity);
/// Serializes this SnNotification to a JSON map.
Map<String, dynamic> toJson();
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? topic = null,
Object? title = null,
Object? subtitle = freezed,
Object? body = null,
Object? metadata = null,
Object? priority = null,
Object? senderId = freezed,
Object? accountId = null,
Object? readAt = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
topic: null == topic
? _value.topic
: topic // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
subtitle: freezed == subtitle
? _value.subtitle
: subtitle // ignore: cast_nullable_to_non_nullable
as String?,
body: null == body
? _value.body
: body // ignore: cast_nullable_to_non_nullable
as String,
metadata: null == metadata
? _value.metadata
: metadata // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
priority: null == priority
? _value.priority
: priority // ignore: cast_nullable_to_non_nullable
as int,
senderId: freezed == senderId
? _value.senderId
: senderId // ignore: cast_nullable_to_non_nullable
as int?,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
readAt: freezed == readAt
? _value.readAt
: readAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
) as $Val);
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is SnNotification &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.topic, topic) || other.topic == topic) &&
(identical(other.title, title) || other.title == title) &&
(identical(other.subtitle, subtitle) ||
other.subtitle == subtitle) &&
(identical(other.body, body) || other.body == body) &&
const DeepCollectionEquality().equals(other.metadata, metadata) &&
(identical(other.priority, priority) ||
other.priority == priority) &&
(identical(other.senderId, senderId) ||
other.senderId == senderId) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.readAt, readAt) || other.readAt == readAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
topic,
title,
subtitle,
body,
const DeepCollectionEquality().hash(metadata),
priority,
senderId,
accountId,
readAt);
@override
String toString() {
return 'SnNotification(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, topic: $topic, title: $title, subtitle: $subtitle, body: $body, metadata: $metadata, priority: $priority, senderId: $senderId, accountId: $accountId, readAt: $readAt)';
}
}
/// @nodoc
abstract class _$$SnNotificationImplCopyWith<$Res>
implements $SnNotificationCopyWith<$Res> {
factory _$$SnNotificationImplCopyWith(_$SnNotificationImpl value,
$Res Function(_$SnNotificationImpl) then) =
__$$SnNotificationImplCopyWithImpl<$Res>;
@override
abstract mixin class $SnNotificationCopyWith<$Res> {
factory $SnNotificationCopyWith(
SnNotification value, $Res Function(SnNotification) _then) =
_$SnNotificationCopyWithImpl;
@useResult
$Res call(
{int id,
@ -177,12 +114,12 @@ abstract class _$$SnNotificationImplCopyWith<$Res>
}
/// @nodoc
class __$$SnNotificationImplCopyWithImpl<$Res>
extends _$SnNotificationCopyWithImpl<$Res, _$SnNotificationImpl>
implements _$$SnNotificationImplCopyWith<$Res> {
__$$SnNotificationImplCopyWithImpl(
_$SnNotificationImpl _value, $Res Function(_$SnNotificationImpl) _then)
: super(_value, _then);
class _$SnNotificationCopyWithImpl<$Res>
implements $SnNotificationCopyWith<$Res> {
_$SnNotificationCopyWithImpl(this._self, this._then);
final SnNotification _self;
final $Res Function(SnNotification) _then;
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@ -203,57 +140,57 @@ class __$$SnNotificationImplCopyWithImpl<$Res>
Object? accountId = null,
Object? readAt = freezed,
}) {
return _then(_$SnNotificationImpl(
return _then(_self.copyWith(
id: null == id
? _value.id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
topic: null == topic
? _value.topic
? _self.topic
: topic // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
? _self.title
: title // ignore: cast_nullable_to_non_nullable
as String,
subtitle: freezed == subtitle
? _value.subtitle
? _self.subtitle
: subtitle // ignore: cast_nullable_to_non_nullable
as String?,
body: null == body
? _value.body
? _self.body
: body // ignore: cast_nullable_to_non_nullable
as String,
metadata: null == metadata
? _value._metadata
? _self.metadata
: metadata // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
priority: null == priority
? _value.priority
? _self.priority
: priority // ignore: cast_nullable_to_non_nullable
as int,
senderId: freezed == senderId
? _value.senderId
? _self.senderId
: senderId // ignore: cast_nullable_to_non_nullable
as int?,
accountId: null == accountId
? _value.accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
readAt: freezed == readAt
? _value.readAt
? _self.readAt
: readAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
@ -262,8 +199,8 @@ class __$$SnNotificationImplCopyWithImpl<$Res>
/// @nodoc
@JsonSerializable()
class _$SnNotificationImpl implements _SnNotification {
const _$SnNotificationImpl(
class _SnNotification implements SnNotification {
const _SnNotification(
{required this.id,
required this.createdAt,
required this.updatedAt,
@ -278,9 +215,8 @@ class _$SnNotificationImpl implements _SnNotification {
required this.accountId,
required this.readAt})
: _metadata = metadata;
factory _$SnNotificationImpl.fromJson(Map<String, dynamic> json) =>
_$$SnNotificationImplFromJson(json);
factory _SnNotification.fromJson(Map<String, dynamic> json) =>
_$SnNotificationFromJson(json);
@override
final int id;
@ -316,16 +252,26 @@ class _$SnNotificationImpl implements _SnNotification {
@override
final DateTime? readAt;
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@override
String toString() {
return 'SnNotification(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, topic: $topic, title: $title, subtitle: $subtitle, body: $body, metadata: $metadata, priority: $priority, senderId: $senderId, accountId: $accountId, readAt: $readAt)';
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnNotificationCopyWith<_SnNotification> get copyWith =>
__$SnNotificationCopyWithImpl<_SnNotification>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnNotificationToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnNotificationImpl &&
other is _SnNotification &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
@ -366,73 +312,118 @@ class _$SnNotificationImpl implements _SnNotification {
accountId,
readAt);
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnNotificationImplCopyWith<_$SnNotificationImpl> get copyWith =>
__$$SnNotificationImplCopyWithImpl<_$SnNotificationImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnNotificationImplToJson(
this,
);
String toString() {
return 'SnNotification(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, topic: $topic, title: $title, subtitle: $subtitle, body: $body, metadata: $metadata, priority: $priority, senderId: $senderId, accountId: $accountId, readAt: $readAt)';
}
}
abstract class _SnNotification implements SnNotification {
const factory _SnNotification(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final String topic,
required final String title,
required final String? subtitle,
required final String body,
final Map<String, dynamic> metadata,
required final int priority,
required final int? senderId,
required final int accountId,
required final DateTime? readAt}) = _$SnNotificationImpl;
/// @nodoc
abstract mixin class _$SnNotificationCopyWith<$Res>
implements $SnNotificationCopyWith<$Res> {
factory _$SnNotificationCopyWith(
_SnNotification value, $Res Function(_SnNotification) _then) =
__$SnNotificationCopyWithImpl;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String topic,
String title,
String? subtitle,
String body,
Map<String, dynamic> metadata,
int priority,
int? senderId,
int accountId,
DateTime? readAt});
}
factory _SnNotification.fromJson(Map<String, dynamic> json) =
_$SnNotificationImpl.fromJson;
/// @nodoc
class __$SnNotificationCopyWithImpl<$Res>
implements _$SnNotificationCopyWith<$Res> {
__$SnNotificationCopyWithImpl(this._self, this._then);
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
String get topic;
@override
String get title;
@override
String? get subtitle;
@override
String get body;
@override
Map<String, dynamic> get metadata;
@override
int get priority;
@override
int? get senderId;
@override
int get accountId;
@override
DateTime? get readAt;
final _SnNotification _self;
final $Res Function(_SnNotification) _then;
/// Create a copy of SnNotification
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnNotificationImplCopyWith<_$SnNotificationImpl> get copyWith =>
throw _privateConstructorUsedError;
@pragma('vm:prefer-inline')
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? topic = null,
Object? title = null,
Object? subtitle = freezed,
Object? body = null,
Object? metadata = null,
Object? priority = null,
Object? senderId = freezed,
Object? accountId = null,
Object? readAt = freezed,
}) {
return _then(_SnNotification(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
topic: null == topic
? _self.topic
: topic // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _self.title
: title // ignore: cast_nullable_to_non_nullable
as String,
subtitle: freezed == subtitle
? _self.subtitle
: subtitle // ignore: cast_nullable_to_non_nullable
as String?,
body: null == body
? _self.body
: body // ignore: cast_nullable_to_non_nullable
as String,
metadata: null == metadata
? _self._metadata
: metadata // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
priority: null == priority
? _self.priority
: priority // ignore: cast_nullable_to_non_nullable
as int,
senderId: freezed == senderId
? _self.senderId
: senderId // ignore: cast_nullable_to_non_nullable
as int?,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
readAt: freezed == readAt
? _self.readAt
: readAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
// dart format on

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