Compare commits

..

49 Commits

Author SHA1 Message Date
d6013078bd 🚀 Launch 2.4.2+81 2025-03-17 00:38:15 +08:00
5976d61997 💄 Bunch of optimization 2025-03-17 00:36:20 +08:00
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
80 changed files with 7741 additions and 2190 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

@ -5,14 +5,14 @@ meta {
} }
put { put {
url: {{endpoint}}/cgi/id/reports/abuse/3/status url: {{endpoint}}/cgi/id/reports/abuse/6/status
body: json body: json
auth: inherit auth: inherit
} }
body:json { body:json {
{ {
"status": "processed", "status": "rejected",
"message": "相关附件已经进行评级处理,未来会将该项权限下放到帖主以及社区成员。" "message": "Not a good reason"
} }
} }

View File

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

View File

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

View File

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

View File

@ -153,6 +153,11 @@
"publisherRunBy": "Run by {}", "publisherRunBy": "Run by {}",
"fieldPublisherBelongToRealm": "Belongs to", "fieldPublisherBelongToRealm": "Belongs to",
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm", "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
"writePost": "Compose",
"postTypeStory": "Story",
"postTypeArticle": "Article",
"postTypeQuestion": "Question",
"postTypeVideo": "Video",
"writePostTypeStory": "Post a story", "writePostTypeStory": "Post a story",
"writePostTypeArticle": "Write an article", "writePostTypeArticle": "Write an article",
"writePostTypeQuestion": "Ask a question", "writePostTypeQuestion": "Ask a question",
@ -202,6 +207,7 @@
"one": "{} comment", "one": "{} comment",
"other": "{} comments" "other": "{} comments"
}, },
"postCommentExpand": "Show comments",
"settingsAppearance": "Appearance", "settingsAppearance": "Appearance",
"settingsCustomFonts": "Custom Fonts", "settingsCustomFonts": "Custom Fonts",
"settingsCustomFontsDescription": "Set custom fonts for the application.", "settingsCustomFontsDescription": "Set custom fonts for the application.",
@ -763,5 +769,82 @@
"decrypting": "Decrypting……", "decrypting": "Decrypting……",
"decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online", "decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online",
"messageUnablePreview": "Unable preview", "messageUnablePreview": "Unable preview",
"messageUnablePreviewEncrypted": "Unable preview encrypted message" "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": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所属领域", "fieldPublisherBelongToRealm": "所属领域",
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域", "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
"writePost": "撰写",
"postTypeStory": "动态",
"postTypeArticle": "文章",
"postTypeQuestion": "问题",
"postTypeVideo": "视频",
"writePostTypeStory": "发动态", "writePostTypeStory": "发动态",
"writePostTypeArticle": "写文章", "writePostTypeArticle": "写文章",
"writePostTypeQuestion": "提问题", "writePostTypeQuestion": "提问题",
@ -200,6 +205,7 @@
"one": "{} 条评论", "one": "{} 条评论",
"other": "{} 条评论" "other": "{} 条评论"
}, },
"postCommentExpand": "展开评论",
"settingsAppearance": "外观", "settingsAppearance": "外观",
"settingsCustomFonts": "自定义字体", "settingsCustomFonts": "自定义字体",
"settingsCustomFontsDescription": "设置应用程序使用的字体。", "settingsCustomFontsDescription": "设置应用程序使用的字体。",
@ -761,5 +767,82 @@
"decrypting": "解密中……", "decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线", "decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线",
"messageUnablePreview": "无法预览消息", "messageUnablePreview": "无法预览消息",
"messageUnablePreviewEncrypted": "无法预览加密消息" "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": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域", "fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題", "writePostTypeQuestion": "提問題",
@ -200,6 +205,7 @@
"one": "{} 條評論", "one": "{} 條評論",
"other": "{} 條評論" "other": "{} 條評論"
}, },
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀", "settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體", "settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。", "settingsCustomFontsDescription": "設置應用程序使用的字體。",
@ -761,5 +767,82 @@
"decrypting": "解密中……", "decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息", "messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息" "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": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域", "fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題", "writePostTypeQuestion": "提問題",
@ -200,6 +205,7 @@
"one": "{} 條評論", "one": "{} 條評論",
"other": "{} 條評論" "other": "{} 條評論"
}, },
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀", "settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體", "settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。", "settingsCustomFontsDescription": "設置應用程序使用的字體。",
@ -761,5 +767,82 @@
"decrypting": "解密中……", "decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息", "messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息" "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

@ -126,8 +126,6 @@ PODS:
- gal (1.0.0): - gal (1.0.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- geolocator_apple (1.2.0):
- Flutter
- GoogleAppMeasurement (11.8.0): - GoogleAppMeasurement (11.8.0):
- GoogleAppMeasurement/AdIdSupport (= 11.8.0) - GoogleAppMeasurement/AdIdSupport (= 11.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
@ -185,7 +183,7 @@ PODS:
- in_app_review (2.0.0): - in_app_review (2.0.0):
- Flutter - Flutter
- Kingfisher (8.2.0) - Kingfisher (8.2.0)
- livekit_client (2.4.0): - livekit_client (2.4.1):
- Flutter - Flutter
- flutter_webrtc - flutter_webrtc
- WebRTC-SDK (= 125.6422.06) - WebRTC-SDK (= 125.6422.06)
@ -278,7 +276,6 @@ DEPENDENCIES:
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gal (from `.symlinks/plugins/gal/darwin`) - gal (from `.symlinks/plugins/gal/darwin`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`) - home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`) - in_app_review (from `.symlinks/plugins/in_app_review/ios`)
@ -362,8 +359,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_webrtc/ios" :path: ".symlinks/plugins/flutter_webrtc/ios"
gal: gal:
:path: ".symlinks/plugins/gal/darwin" :path: ".symlinks/plugins/gal/darwin"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/ios"
home_widget: home_widget:
:path: ".symlinks/plugins/home_widget/ios" :path: ".symlinks/plugins/home_widget/ios"
image_picker_ios: image_picker_ios:
@ -436,7 +431,6 @@ SPEC CHECKSUMS:
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1 flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896 GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
@ -444,7 +438,7 @@ SPEC CHECKSUMS:
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011 in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
livekit_client: 9819ebc8be8ef00ed0fae7d806bf8938ec689573 livekit_client: 170022ce5f7c8c70d7f862ac9c17e11508ad5fbc
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e

View File

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

View File

@ -23,8 +23,6 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel
late final GeneratedColumn<String> alias = GeneratedColumn<String>( late final GeneratedColumn<String> alias = GeneratedColumn<String>(
'alias', aliasedName, false, 'alias', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true); type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override @override
late final GeneratedColumnWithTypeConverter<SnChannel, String> content = late final GeneratedColumnWithTypeConverter<SnChannel, String> content =
GeneratedColumn<String>('content', aliasedName, false, GeneratedColumn<String>('content', aliasedName, false,
@ -60,7 +58,6 @@ class $SnLocalChatChannelTable extends SnLocalChatChannel
} else if (isInserting) { } else if (isInserting) {
context.missing(_aliasMeta); context.missing(_aliasMeta);
} }
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) { if (data.containsKey('created_at')) {
context.handle(_createdAtMeta, context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -295,8 +292,6 @@ class $SnLocalChatMessageTable extends SnLocalChatMessage
late final GeneratedColumn<int> senderId = GeneratedColumn<int>( late final GeneratedColumn<int> senderId = GeneratedColumn<int>(
'sender_id', aliasedName, true, 'sender_id', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false); type: DriftSqlType.int, requiredDuringInsert: false);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override @override
late final GeneratedColumnWithTypeConverter<SnChatMessage, String> content = late final GeneratedColumnWithTypeConverter<SnChatMessage, String> content =
GeneratedColumn<String>('content', aliasedName, false, GeneratedColumn<String>('content', aliasedName, false,
@ -338,7 +333,6 @@ class $SnLocalChatMessageTable extends SnLocalChatMessage
context.handle(_senderIdMeta, context.handle(_senderIdMeta,
senderId.isAcceptableOrUnknown(data['sender_id']!, _senderIdMeta)); senderId.isAcceptableOrUnknown(data['sender_id']!, _senderIdMeta));
} }
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) { if (data.containsKey('created_at')) {
context.handle(_createdAtMeta, context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -604,8 +598,6 @@ class $SnLocalChannelMemberTable extends SnLocalChannelMember
late final GeneratedColumn<int> accountId = GeneratedColumn<int>( late final GeneratedColumn<int> accountId = GeneratedColumn<int>(
'account_id', aliasedName, false, 'account_id', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true); type: DriftSqlType.int, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override @override
late final GeneratedColumnWithTypeConverter<SnChannelMember, String> content = late final GeneratedColumnWithTypeConverter<SnChannelMember, String> content =
GeneratedColumn<String>('content', aliasedName, false, GeneratedColumn<String>('content', aliasedName, false,
@ -655,7 +647,6 @@ class $SnLocalChannelMemberTable extends SnLocalChannelMember
} else if (isInserting) { } else if (isInserting) {
context.missing(_accountIdMeta); context.missing(_accountIdMeta);
} }
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) { if (data.containsKey('created_at')) {
context.handle(_createdAtMeta, context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -1265,8 +1256,6 @@ class $SnLocalAccountTable extends SnLocalAccount
late final GeneratedColumn<String> name = GeneratedColumn<String>( late final GeneratedColumn<String> name = GeneratedColumn<String>(
'name', aliasedName, false, 'name', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true); type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override @override
late final GeneratedColumnWithTypeConverter<SnAccount, String> content = late final GeneratedColumnWithTypeConverter<SnAccount, String> content =
GeneratedColumn<String>('content', aliasedName, false, GeneratedColumn<String>('content', aliasedName, false,
@ -1308,7 +1297,6 @@ class $SnLocalAccountTable extends SnLocalAccount
} else if (isInserting) { } else if (isInserting) {
context.missing(_nameMeta); context.missing(_nameMeta);
} }
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) { if (data.containsKey('created_at')) {
context.handle(_createdAtMeta, context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -1582,8 +1570,6 @@ class $SnLocalAttachmentTable extends SnLocalAttachment
type: DriftSqlType.string, type: DriftSqlType.string,
requiredDuringInsert: true, requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE')); defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'));
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override @override
late final GeneratedColumnWithTypeConverter<SnAttachment, String> content = late final GeneratedColumnWithTypeConverter<SnAttachment, String> content =
GeneratedColumn<String>('content', aliasedName, false, GeneratedColumn<String>('content', aliasedName, false,
@ -1639,7 +1625,6 @@ class $SnLocalAttachmentTable extends SnLocalAttachment
} else if (isInserting) { } else if (isInserting) {
context.missing(_uuidMeta); context.missing(_uuidMeta);
} }
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('account_id')) { if (data.containsKey('account_id')) {
context.handle(_accountIdMeta, context.handle(_accountIdMeta,
accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta)); accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta));
@ -1968,8 +1953,6 @@ class $SnLocalStickerTable extends SnLocalSticker
late final GeneratedColumn<String> fullAlias = GeneratedColumn<String>( late final GeneratedColumn<String> fullAlias = GeneratedColumn<String>(
'full_alias', aliasedName, false, 'full_alias', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true); type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override @override
late final GeneratedColumnWithTypeConverter<SnSticker, String> content = late final GeneratedColumnWithTypeConverter<SnSticker, String> content =
GeneratedColumn<String>('content', aliasedName, false, GeneratedColumn<String>('content', aliasedName, false,
@ -2011,7 +1994,6 @@ class $SnLocalStickerTable extends SnLocalSticker
} else if (isInserting) { } else if (isInserting) {
context.missing(_fullAliasMeta); context.missing(_fullAliasMeta);
} }
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) { if (data.containsKey('created_at')) {
context.handle(_createdAtMeta, context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
@ -2261,8 +2243,6 @@ class $SnLocalStickerPackTable extends SnLocalStickerPack
requiredDuringInsert: false, requiredDuringInsert: false,
defaultConstraints: defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override @override
late final GeneratedColumnWithTypeConverter<SnStickerPack, String> content = late final GeneratedColumnWithTypeConverter<SnStickerPack, String> content =
GeneratedColumn<String>('content', aliasedName, false, GeneratedColumn<String>('content', aliasedName, false,
@ -2293,7 +2273,6 @@ class $SnLocalStickerPackTable extends SnLocalStickerPack
if (data.containsKey('id')) { if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} }
context.handle(_contentMeta, const VerificationResult.success());
if (data.containsKey('created_at')) { if (data.containsKey('created_at')) {
context.handle(_createdAtMeta, context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));

View File

@ -37,6 +37,7 @@ import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/sn_sticker.dart'; import 'package:surface/providers/sn_sticker.dart';
import 'package:surface/providers/special_day.dart'; import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/theme.dart'; import 'package:surface/providers/theme.dart';
import 'package:surface/providers/translation.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart'; import 'package:surface/providers/websocket.dart';
@ -44,6 +45,7 @@ import 'package:surface/providers/widget.dart';
import 'package:surface/router.dart'; import 'package:surface/router.dart';
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy; import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/menu_bar.dart';
import 'package:tray_manager/tray_manager.dart'; import 'package:tray_manager/tray_manager.dart';
import 'package:version/version.dart'; import 'package:version/version.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
@ -166,6 +168,7 @@ class SolianApp extends StatelessWidget {
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)), ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)), ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)), ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
Provider(create: (ctx) => SnTranslator()),
// Additional helper layer // Additional helper layer
Provider(create: (ctx) => SpecialDayProvider(ctx)), Provider(create: (ctx) => SpecialDayProvider(ctx)),
@ -273,7 +276,9 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
mounted) { mounted) {
final config = context.read<ConfigProvider>(); final config = context.read<ConfigProvider>();
config.setUpdate( config.setUpdate(
remoteVersionString, resp.data?['body'] ?? 'No changelog'); remoteVersionString,
resp.data?['body'] ?? 'No changelog',
);
logging.info("[Update] Update available: $remoteVersionString"); logging.info("[Update] Update available: $remoteVersionString");
} }
} catch (e) { } catch (e) {
@ -331,18 +336,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
Future<void> _hotkeyInitialization() async { Future<void> _hotkeyInitialization() async {
if (kIsWeb) return; if (kIsWeb) return;
// The quit key has been removed, and the logic of the quit key is moved to system menu bar activator.
if (Platform.isMacOS) {
HotKey quitHotKey = HotKey(
key: PhysicalKeyboardKey.keyQ,
modifiers: [HotKeyModifier.meta],
scope: HotKeyScope.inapp,
);
await hotKeyManager.register(quitHotKey, keyUpHandler: (_) {
_appLifecycleListener?.dispose();
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
});
}
} }
final Menu _appTrayMenu = Menu( final Menu _appTrayMenu = Menu(
@ -426,6 +420,15 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
return AppExitResponse.cancel; return AppExitResponse.cancel;
} }
void _quitApp() {
_appLifecycleListener?.dispose();
if (Platform.isWindows) {
appWindow.close();
} else {
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
}
}
@override @override
void onTrayIconMouseDown() { void onTrayIconMouseDown() {
if (Platform.isWindows) { if (Platform.isWindows) {
@ -460,12 +463,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
Timer(const Duration(milliseconds: 100), () => appWindow.show()); Timer(const Duration(milliseconds: 100), () => appWindow.show());
break; break;
case 'exit': case 'exit':
_appLifecycleListener?.dispose(); _quitApp();
if (Platform.isWindows) {
appWindow.close();
} else {
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
}
break; break;
} }
} }
@ -482,28 +480,31 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>(); final cfg = context.read<ConfigProvider>();
return NotificationListener<SizeChangedLayoutNotification>( return AppSystemMenuBar(
onNotification: (notification) { onQuit: _quitApp,
WidgetsBinding.instance.addPostFrameCallback((_) { child: NotificationListener<SizeChangedLayoutNotification>(
cfg.calcDrawerSize(context); onNotification: (notification) {
});
return false;
},
child: OrientationBuilder(
builder: (context, orientation) {
final cfg = context.read<ConfigProvider>();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context); cfg.calcDrawerSize(context);
}); });
Future.delayed(const Duration(milliseconds: 300), () { return false;
if (context.mounted) {
cfg.calcDrawerSize(context);
}
});
return SizeChangedLayoutNotifier(
child: widget.child,
);
}, },
child: OrientationBuilder(
builder: (context, orientation) {
final cfg = context.read<ConfigProvider>();
WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context);
});
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
cfg.calcDrawerSize(context);
}
});
return SizeChangedLayoutNotifier(
child: widget.child,
);
},
),
), ),
); );
} }

View File

@ -19,6 +19,8 @@ const kAppExpandPostLink = 'app_expand_post_link';
const kAppExpandChatLink = 'app_expand_chat_link'; const kAppExpandChatLink = 'app_expand_chat_link';
const kAppRealmCompactView = 'app_realm_compact_view'; const kAppRealmCompactView = 'app_realm_compact_view';
const kAppCustomFonts = 'app_custom_fonts'; const kAppCustomFonts = 'app_custom_fonts';
const kAppMixedFeed = 'app_mixed_feed';
const kAppAutoTranslate = 'app_auto_translate';
const Map<String, FilterQuality> kImageQualityLevel = { const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none, 'settingsImageQualityLowest': FilterQuality.none,
@ -81,8 +83,27 @@ class ConfigProvider extends ChangeNotifier {
return prefs.getBool(kAppRealmCompactView) ?? false; return prefs.getBool(kAppRealmCompactView) ?? false;
} }
bool get mixedFeed {
return prefs.getBool(kAppMixedFeed) ?? true;
}
bool get autoTranslate {
return prefs.getBool(kAppAutoTranslate) ?? false;
}
set autoTranslate(bool value) {
prefs.setBool(kAppAutoTranslate, value);
notifyListeners();
}
set mixedFeed(bool value) {
prefs.setBool(kAppMixedFeed, value);
notifyListeners();
}
set realmCompactView(bool value) { set realmCompactView(bool value) {
prefs.setBool(kAppRealmCompactView, value); prefs.setBool(kAppRealmCompactView, value);
notifyListeners();
} }
set serverUrl(String url) { set serverUrl(String url) {

View File

@ -60,16 +60,24 @@ class SnPostContentProvider {
out[i] = out[i].copyWith( out[i] = out[i].copyWith(
preload: SnPostPreload( preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out[i].body['thumbnail']).firstOrNull, thumbnail: attachments
attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(), .where((ele) => ele?.rid == out[i].body['thumbnail'])
video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull, .firstOrNull,
attachments: attachments
.where((ele) =>
out[i].body['attachments']?.contains(ele?.rid) ?? false)
.toList(),
video: attachments
.where((ele) => ele?.rid == out[i].body['video'])
.firstOrNull,
poll: poll, poll: poll,
realm: realm, realm: realm,
), ),
); );
} }
uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); uids.addAll(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
await _ud.listAccount(uids); await _ud.listAccount(uids);
return out; return out;
@ -107,15 +115,23 @@ class SnPostContentProvider {
out = out.copyWith( out = out.copyWith(
preload: SnPostPreload( preload: SnPostPreload(
thumbnail: attachments.where((ele) => ele?.rid == out.body['thumbnail']).firstOrNull, thumbnail: attachments
attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(), .where((ele) => ele?.rid == out.body['thumbnail'])
video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull, .firstOrNull,
attachments: attachments
.where(
(ele) => out.body['attachments']?.contains(ele?.rid) ?? false)
.toList(),
video: attachments
.where((ele) => ele?.rid == out.body['video'])
.firstOrNull,
poll: poll, poll: poll,
realm: realm, realm: realm,
), ),
); );
uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId)); uids.addAll(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
await _ud.listAccount(uids); await _ud.listAccount(uids);
return out; return out;
@ -129,6 +145,36 @@ class SnPostContentProvider {
return out; return out;
} }
Future<List<SnFeedEntry>> getFeed({int take = 20, DateTime? cursor}) async {
final resp =
await _sn.client.get('/cgi/co/recommendations/feed', queryParameters: {
'take': take,
if (cursor != null) 'cursor': cursor.toUtc().millisecondsSinceEpoch,
});
final List<SnFeedEntry> out =
List.from(resp.data.map((ele) => SnFeedEntry.fromJson(ele)));
List<SnPost> posts = List.empty(growable: true);
for (var idx = 0; idx < out.length; idx++) {
final ele = out[idx];
if (ele.type == 'interactive.post') {
posts.add(SnPost.fromJson(ele.data));
}
}
posts = await _preloadRelatedDataInBatch(posts);
var postsIdx = 0;
for (var idx = 0; idx < out.length; idx++) {
final ele = out[idx];
if (ele.type == 'interactive.post') {
out[idx] = ele.copyWith(data: posts[postsIdx].toJson());
postsIdx++;
}
}
return out;
}
Future<(List<SnPost>, int)> listPosts({ Future<(List<SnPost>, int)> listPosts({
int take = 10, int take = 10,
int offset = 0, int offset = 0,
@ -138,17 +184,25 @@ class SnPostContentProvider {
Iterable<String>? tags, Iterable<String>? tags,
String? realm, String? realm,
String? channel, String? channel,
bool isDraft = false,
bool isShuffle = false,
}) async { }) async {
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: { final resp = await _sn.client.get(
'take': take, isShuffle
'offset': offset, ? '/cgi/co/recommendations/shuffle'
if (type != null) 'type': type, : '/cgi/co/posts${isDraft ? '/drafts' : ''}',
if (author != null) 'author': author, queryParameters: {
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), 'take': take,
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','), 'offset': offset,
if (realm != null) 'realm': realm, if (type != null) 'type': type,
if (channel != null) 'channel': channel, if (author != null) 'author': author,
}); if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
if (categories?.isNotEmpty ?? false)
'categories': categories!.join(','),
if (realm != null) 'realm': realm,
if (channel != null) 'channel': channel,
},
);
final List<SnPost> out = await _preloadRelatedDataInBatch( final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
); );
@ -161,7 +215,8 @@ class SnPostContentProvider {
int take = 10, int take = 10,
int offset = 0, int offset = 0,
}) async { }) async {
final resp = await _sn.client.get('/cgi/co/posts/$parentId/replies', queryParameters: { final resp = await _sn.client
.get('/cgi/co/posts/$parentId/replies', queryParameters: {
'take': take, 'take': take,
'offset': offset, 'offset': offset,
}); });
@ -200,4 +255,9 @@ class SnPostContentProvider {
); );
return out; return out;
} }
Future<SnPost> completePostData(SnPost post) async {
final out = await _preloadRelatedDataSingle(post);
return out;
}
} }

View File

@ -17,6 +17,20 @@ import 'package:synchronized/synchronized.dart';
import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart'; import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart';
import 'package:talker_dio_logger/talker_dio_logger_settings.dart'; import 'package:talker_dio_logger/talker_dio_logger_settings.dart';
enum ServiceStatus { operational, downgraded, failed }
const Map<String, String> kServicesName = {
'ai': 'Insights',
'co': 'Interactive',
're': 'Reader',
'im': 'Messaging',
'ma': 'Matrix',
'uc': 'Paperclip',
'wa': 'Wallet',
'id': 'Passport',
'pusher': 'Pusher',
};
const kNetworkServerDirectory = [ const kNetworkServerDirectory = [
('Solar Network', 'https://api.sn.solsynth.dev'), ('Solar Network', 'https://api.sn.solsynth.dev'),
('Local', 'http://localhost:8001'), ('Local', 'http://localhost:8001'),

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

@ -19,6 +19,7 @@ class UserDirectoryProvider {
final Map<String, int> _idCache = {}; final Map<String, int> _idCache = {};
final Map<int, SnAccount> _cache = {}; final Map<int, SnAccount> _cache = {};
DateTime? _cacheExpiredAt;
Future<int> loadAccountCache({int max = 100}) async { Future<int> loadAccountCache({int max = 100}) async {
final out = await (_dt.db.snLocalAccount.select()..limit(max)).get(); final out = await (_dt.db.snLocalAccount.select()..limit(max)).get();
@ -26,11 +27,18 @@ class UserDirectoryProvider {
_cache[ele.id] = ele.content; _cache[ele.id] = ele.content;
_idCache[ele.name] = ele.id; _idCache[ele.name] = ele.id;
} }
_cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
return out.length; return out.length;
} }
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async { Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
// In-memory cache // In-memory cache
if (_cacheExpiredAt != null && _cacheExpiredAt!.isBefore(DateTime.now())) {
_cache.clear();
_cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
} else {
_cacheExpiredAt ??= DateTime.now().add(const Duration(hours: 1));
}
final out = List<SnAccount?>.generate(id.length, (e) => null); final out = List<SnAccount?>.generate(id.length, (e) => null);
final plannedQuery = <int>{}; final plannedQuery = <int>{};
for (var idx = 0; idx < out.length; idx++) { for (var idx = 0; idx < out.length; idx++) {
@ -62,6 +70,7 @@ class UserDirectoryProvider {
plannedQuery.remove(dbResp[idx].id); plannedQuery.remove(dbResp[idx].id);
} }
// Remote server // Remote server
_saveToLocal(out.where((ele) => ele != null).cast());
if (plannedQuery.isEmpty) return out; if (plannedQuery.isEmpty) return out;
final resp = await _sn.client final resp = await _sn.client
.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')}); .get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -35,6 +37,32 @@ class UserProvider extends ChangeNotifier {
}); });
} }
Future<Map<String, dynamic>?> get atkClaims async {
final tk = (await atk);
if (tk == null) return null;
final atkParts = tk.split('.');
if (atkParts.length != 3) {
throw Exception('invalid format of access token');
}
var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
switch (rawPayload.length % 4) {
case 0:
break;
case 2:
rawPayload += '==';
break;
case 3:
rawPayload += '=';
break;
default:
throw Exception('illegal format of access token payload');
}
final b64 = utf8.fuse(base64Url);
return jsonDecode(b64.decode(rawPayload));
}
Future<SnAccount?> refreshUser() async { Future<SnAccount?> refreshUser() async {
final resp = await _sn.client.get('/cgi/id/users/me'); final resp = await _sn.client.get('/cgi/id/users/me');
final out = SnAccount.fromJson(resp.data); final out = SnAccount.fromJson(resp.data);
@ -47,7 +75,13 @@ class UserProvider extends ChangeNotifier {
} }
void logoutUser() async { void logoutUser() async {
_sn.clearTokenPair(); atkClaims.then((value) async {
if (value != null) {
await _sn.client.delete('/cgi/id/users/me/tickets/${value['sed']}');
logging.info('[Auth] Current session has been destroyed.');
}
_sn.clearTokenPair();
});
isAuthorized = false; isAuthorized = false;
user = null; user = null;
notifyListeners(); notifyListeners();

View File

@ -4,7 +4,9 @@ import 'package:go_router/go_router.dart';
import 'package:surface/screens/abuse_report.dart'; import 'package:surface/screens/abuse_report.dart';
import 'package:surface/screens/account.dart'; import 'package:surface/screens/account.dart';
import 'package:surface/screens/account/account_settings.dart'; import 'package:surface/screens/account/account_settings.dart';
import 'package:surface/screens/account/action_events.dart';
import 'package:surface/screens/account/badges.dart'; import 'package:surface/screens/account/badges.dart';
import 'package:surface/screens/account/contact_methods.dart';
import 'package:surface/screens/account/factor_settings.dart'; import 'package:surface/screens/account/factor_settings.dart';
import 'package:surface/screens/account/keypairs.dart'; import 'package:surface/screens/account/keypairs.dart';
import 'package:surface/screens/account/profile_page.dart'; import 'package:surface/screens/account/profile_page.dart';
@ -12,6 +14,7 @@ import 'package:surface/screens/account/profile_edit.dart';
import 'package:surface/screens/account/publishers/publisher_edit.dart'; import 'package:surface/screens/account/publishers/publisher_edit.dart';
import 'package:surface/screens/account/publishers/publisher_new.dart'; import 'package:surface/screens/account/publishers/publisher_new.dart';
import 'package:surface/screens/account/publishers/publishers.dart'; import 'package:surface/screens/account/publishers/publishers.dart';
import 'package:surface/screens/account/auth_tickets.dart';
import 'package:surface/screens/album.dart'; import 'package:surface/screens/album.dart';
import 'package:surface/screens/auth/login.dart'; import 'package:surface/screens/auth/login.dart';
import 'package:surface/screens/auth/register.dart'; import 'package:surface/screens/auth/register.dart';
@ -28,7 +31,9 @@ import 'package:surface/screens/news/news_detail.dart';
import 'package:surface/screens/news/news_list.dart'; import 'package:surface/screens/news/news_list.dart';
import 'package:surface/screens/notification.dart'; import 'package:surface/screens/notification.dart';
import 'package:surface/screens/post/post_detail.dart'; import 'package:surface/screens/post/post_detail.dart';
import 'package:surface/screens/post/post_draft.dart';
import 'package:surface/screens/post/post_editor.dart'; import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/screens/post/post_shuffle.dart';
import 'package:surface/screens/post/publisher_page.dart'; import 'package:surface/screens/post/publisher_page.dart';
import 'package:surface/screens/post/post_search.dart'; import 'package:surface/screens/post/post_search.dart';
import 'package:surface/screens/realm.dart'; import 'package:surface/screens/realm.dart';
@ -66,10 +71,15 @@ final _appRoutes = [
builder: (context, state) => const ExploreScreen(), builder: (context, state) => const ExploreScreen(),
routes: [ routes: [
GoRoute( GoRoute(
path: '/write/:mode', path: '/draft',
name: 'postDraftBox',
builder: (context, state) => const PostDraftBox(),
),
GoRoute(
path: '/write',
name: 'postEditor', name: 'postEditor',
builder: (context, state) => PostEditorScreen( builder: (context, state) => PostEditorScreen(
mode: state.pathParameters['mode']!, mode: state.uri.queryParameters['mode'],
postEditId: int.tryParse( postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '', state.uri.queryParameters['editing'] ?? '',
), ),
@ -82,6 +92,11 @@ final _appRoutes = [
extraProps: state.extra as PostEditorExtra?, extraProps: state.extra as PostEditorExtra?,
), ),
), ),
GoRoute(
path: '/shuffle',
name: 'postShuffle',
builder: (context, state) => const PostShuffleScreen(),
),
GoRoute( GoRoute(
path: '/search', path: '/search',
name: 'postSearch', name: 'postSearch',
@ -112,6 +127,21 @@ final _appRoutes = [
name: 'account', name: 'account',
builder: (context, state) => const AccountScreen(), builder: (context, state) => const AccountScreen(),
routes: [ 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( GoRoute(
path: '/badges', path: '/badges',
name: 'accountBadges', name: 'accountBadges',
@ -160,7 +190,7 @@ final _appRoutes = [
), ),
), ),
GoRoute( GoRoute(
path: '/:name', path: '/profile/:name',
name: 'accountProfilePage', name: 'accountProfilePage',
pageBuilder: (context, state) => NoTransitionPage( pageBuilder: (context, state) => NoTransitionPage(
child: UserScreen(name: state.pathParameters['name']!), child: UserScreen(name: state.pathParameters['name']!),

View File

@ -11,7 +11,9 @@ import 'package:surface/providers/database.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart'; import 'package:surface/providers/websocket.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/account_status.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
@ -112,7 +114,14 @@ class _AuthorizedAccountScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AccountImage(content: ua.user!.avatar, radius: 28), Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountImage(content: ua.user!.avatar, radius: 28),
_AccountStatusWidget(account: ua.user!),
],
),
const Gap(8), const Gap(8),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.baseline, crossAxisAlignment: CrossAxisAlignment.baseline,
@ -198,6 +207,26 @@ class _AuthorizedAccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('accountKeyPairs'); 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( ListTile(
title: Text('accountSettings').tr(), title: Text('accountSettings').tr(),
subtitle: Text('accountSettingsSubtitle').tr(), subtitle: Text('accountSettingsSubtitle').tr(),
@ -290,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

@ -87,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( ListTile(
title: Text('accountProfileEdit').tr(), title: Text('accountProfileEdit').tr(),
subtitle: Text('accountProfileEditSubtitle').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,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,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

@ -18,6 +18,7 @@ import 'package:surface/types/account.dart';
import 'package:surface/types/check_in.dart'; import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import 'package:surface/theme.dart'; import 'package:surface/theme.dart';
@ -450,19 +451,25 @@ class _UserScreenState extends State<UserScreen>
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Symbols.circle, (_status?.isDisturbable ?? true)
fill: 1, ? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (_status?.isOnline ?? false) ? 1 : 0,
size: 16, size: 16,
color: (_status?.isOnline ?? false) color: (_status?.isOnline ?? false)
? Colors.green ? (_status?.isDisturbable ?? true)
? Colors.green
: Colors.red
: Colors.grey, : Colors.grey,
).padding(all: 4), ).padding(all: 4),
const Gap(8), const Gap(8),
Text( Text(
_status != null _status != null
? _status!.isOnline ? (_status!.status?.label.isNotEmpty ?? false)
? 'accountStatusOnline'.tr() ? _status!.status!.label
: 'accountStatusOffline'.tr() : _status!.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(), : 'loading'.tr(),
), ),
if (_status != null && if (_status != null &&
@ -484,34 +491,7 @@ class _UserScreenState extends State<UserScreen>
Wrap( Wrap(
children: _account!.badges children: _account!.badges
.map( .map(
(ele) => Tooltip( (ele) => AccountBadge(badge: ele),
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: ele.metadata['color'] != null
? HexColor.fromHex(ele.metadata['color']!)
: kBadgesMeta[ele.type]?.$3,
fill: 1,
),
),
) )
.toList(), .toList(),
).padding(horizontal: 8), ).padding(horizontal: 8),

View File

@ -204,7 +204,6 @@ class _ChatScreenState extends State<ChatScreen> {
Theme.of(context).floatingActionButtonTheme.foregroundColor, Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor, Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
), ),
closeButtonBuilder: DefaultFloatingActionButtonBuilder( closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28), child: const Icon(Symbols.close, size: 28),
@ -213,7 +212,6 @@ class _ChatScreenState extends State<ChatScreen> {
Theme.of(context).floatingActionButtonTheme.foregroundColor, Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor, Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
), ),
children: [ children: [
Row( Row(

View File

@ -1,4 +1,3 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
@ -6,19 +5,28 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/post.dart'; import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart'; import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart'; import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/feed/feed_news.dart';
import 'package:surface/widgets/feed/feed_unknown.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/fediverse_post_item.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
const kPostChannels = ['Global', 'Friends', 'Following'];
const kPostChannelIcons = [Symbols.globe, Symbols.group, Symbols.subscriptions];
const Map<String, IconData> kCategoryIcons = { const Map<String, IconData> kCategoryIcons = {
'technology': Symbols.tools_wrench, 'technology': Symbols.tools_wrench,
'gaming': Symbols.gamepad, 'gaming': Symbols.gamepad,
@ -39,17 +47,17 @@ class ExploreScreen extends StatefulWidget {
State<ExploreScreen> createState() => _ExploreScreenState(); State<ExploreScreen> createState() => _ExploreScreenState();
} }
// You know what? I'm not going to make this a global variable.
// Cuz the global key make the selected category not update to child widget when the category is changed.
SnPostCategory? _selectedCategory;
class _ExploreScreenState extends State<ExploreScreen> class _ExploreScreenState extends State<ExploreScreen>
with SingleTickerProviderStateMixin { with TickerProviderStateMixin {
late final TabController _tabController = late TabController _tabController = TabController(
TabController(length: 4, vsync: this); length: kPostChannels.length,
vsync: this,
);
final _fabKey = GlobalKey<ExpandableFabState>(); final _fabKey = GlobalKey<ExpandableFabState>();
final _listKeys = List.generate(4, (_) => GlobalKey<_PostListWidgetState>()); final _listKey = GlobalKey<_PostListWidgetState>();
bool _showCategories = false;
final List<SnPostCategory> _categories = List.empty(growable: true); final List<SnPostCategory> _categories = List.empty(growable: true);
@ -69,14 +77,70 @@ class _ExploreScreenState extends State<ExploreScreen>
} }
} }
void _clearFilter() { final List<SnRealm> _realms = List.empty(growable: true);
_selectedCategory = null;
Future<void> _fetchRealms() async {
try {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
final rels = context.read<SnRealmProvider>();
final out = await rels.listAvailableRealms();
setState(() {
_realms.addAll(out);
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
rethrow;
}
}
void _toggleShowCategories() {
_showCategories = !_showCategories;
if (_showCategories) {
_tabController = TabController(length: _categories.length, vsync: this);
_listKey.currentState?.setCategory(_categories[_tabController.index]);
_listKey.currentState?.refreshPosts();
} else {
_tabController = TabController(length: kPostChannels.length, vsync: this);
_listKey.currentState?.setCategory(null);
_listKey.currentState?.refreshPosts();
}
_tabListen();
setState(() {});
}
void _tabListen() {
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
if (_showCategories) {
_listKey.currentState?.setCategory(_categories[_tabController.index]);
_listKey.currentState?.refreshPosts();
return;
}
switch (_tabController.index) {
case 0:
case 3:
_listKey.currentState?.setChannel(null);
break;
case 1:
_listKey.currentState?.setChannel('friends');
break;
case 2:
_listKey.currentState?.setChannel('following');
break;
}
_listKey.currentState?.refreshPosts();
}
});
} }
@override @override
void initState() { void initState() {
_fetchCategories();
super.initState(); super.initState();
_tabListen();
_fetchCategories();
_fetchRealms();
} }
@override @override
@ -86,11 +150,12 @@ class _ExploreScreenState extends State<ExploreScreen>
} }
Future<void> refreshPosts() async { Future<void> refreshPosts() async {
await _listKeys[_tabController.index].currentState?.refreshPosts(); await _listKey.currentState?.refreshPosts();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return AppScaffold( return AppScaffold(
floatingActionButtonLocation: ExpandableFab.location, floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: ExpandableFab( floatingActionButton: ExpandableFab(
@ -111,7 +176,6 @@ class _ExploreScreenState extends State<ExploreScreen>
Theme.of(context).floatingActionButtonTheme.foregroundColor, Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor, Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
), ),
closeButtonBuilder: DefaultFloatingActionButtonBuilder( closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28), child: const Icon(Symbols.close, size: 28),
@ -120,90 +184,39 @@ class _ExploreScreenState extends State<ExploreScreen>
Theme.of(context).floatingActionButtonTheme.foregroundColor, Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor, Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
), ),
children: [ children: [
Row( Row(
children: [ children: [
Text('writePostTypeStory').tr(), Text('writePost').tr(),
const Gap(20), const Gap(20),
FloatingActionButton( FloatingActionButton(
heroTag: null, heroTag: null,
tooltip: 'writePostTypeStory'.tr(), tooltip: 'writePost'.tr(),
onPressed: () { onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: { GoRouter.of(context).pushNamed('postEditor').then((value) {
'mode': 'stories',
}).then((value) {
if (value == true) { if (value == true) {
refreshPosts(); refreshPosts();
} }
}); });
_fabKey.currentState!.toggle(); _fabKey.currentState!.toggle();
}, },
child: const Icon(Symbols.post_rounded), child: const Icon(Symbols.edit),
), ),
], ],
), ),
Row( Row(
children: [ children: [
Text('writePostTypeArticle').tr(), Text('postDraftBox').tr(),
const Gap(20), const Gap(20),
FloatingActionButton( FloatingActionButton(
heroTag: null, heroTag: null,
tooltip: 'writePostTypeArticle'.tr(), tooltip: 'postDraftBox'.tr(),
onPressed: () { onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: { GoRouter.of(context).pushNamed('postDraftBox');
'mode': 'articles',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle(); _fabKey.currentState!.toggle();
}, },
child: const Icon(Symbols.news), child: const Icon(Symbols.box_edit),
),
],
),
Row(
children: [
Text('writePostTypeQuestion').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeQuestion'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'questions',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.question_answer),
),
],
),
Row(
children: [
Text('writePostTypeVideo').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeVideo'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'videos',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.video_call),
), ),
], ],
), ),
@ -215,27 +228,91 @@ class _ExploreScreenState extends State<ExploreScreen>
SliverOverlapAbsorber( SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar( sliver: SliverAppBar(
leading: AutoAppBarLeading(), leading:
title: Text('screenExplore').tr(), ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
? AutoAppBarLeading()
: null,
titleSpacing: 0,
title: Row(
children: [
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
const Gap(8),
IconButton(
icon: const Icon(Symbols.shuffle),
onPressed: () {
GoRouter.of(context).pushNamed('postShuffle');
},
),
Expanded(
child: Center(
child: IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
icon: _listKey.currentState?.realm != null
? AccountImage(
content: _listKey.currentState!.realm!.avatar,
radius: 14,
)
: Image.asset(
'assets/icon/icon-dark.png',
width: 32,
height: 32,
color: Theme.of(context)
.appBarTheme
.foregroundColor,
),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => _PostListRealmPopup(
realms: _realms,
onUpdate: (realm) {
_listKey.currentState?.setRealm(realm);
_listKey.currentState?.refreshPosts();
Future.delayed(
const Duration(milliseconds: 100), () {
if (mounted) {
setState(() {});
}
});
},
onMixedFeedChanged: (flag) {
_listKey.currentState?.setRealm(null);
_listKey.currentState?.setCategory(null);
if (_showCategories && flag) {
_toggleShowCategories();
}
_listKey.currentState?.refreshPosts();
},
),
);
},
),
),
),
],
),
floating: true, floating: true,
snap: true, snap: true,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.category), icon: const Icon(Symbols.category),
onPressed: () { style: _showCategories
showModalBottomSheet( ? ButtonStyle(
context: context, foregroundColor: WidgetStateProperty.all(
builder: (context) => _PostCategoryPickerPopup( Theme.of(context).colorScheme.primary,
categories: _categories, ),
selected: _selectedCategory, backgroundColor: MaterialStateProperty.all(
), Theme.of(context).colorScheme.secondaryContainer,
).then((value) { ),
if (value != null && context.mounted) { )
_selectedCategory = value == false ? null : value; : null,
refreshPosts(); onPressed: cfg.mixedFeed
} ? null
}); : () {
}, _toggleShowCategories();
},
), ),
IconButton( IconButton(
icon: const Icon(Symbols.search), icon: const Icon(Symbols.search),
@ -245,123 +322,84 @@ class _ExploreScreenState extends State<ExploreScreen>
), ),
const Gap(8), const Gap(8),
], ],
bottom: TabBar( bottom: cfg.mixedFeed
controller: _tabController, ? null
tabs: [ : TabBar(
Tab( isScrollable: _showCategories,
child: Row( controller: _tabController,
mainAxisSize: MainAxisSize.min, tabs: _showCategories
crossAxisAlignment: CrossAxisAlignment.center, ? [
children: [ for (final category in _categories)
Icon(Symbols.globe, Tab(
size: 20, child: Row(
color: Theme.of(context) mainAxisSize: MainAxisSize.min,
.appBarTheme crossAxisAlignment:
.foregroundColor), CrossAxisAlignment.center,
const Gap(8), children: [
Flexible( Icon(
child: Text( kCategoryIcons[category.alias] ??
'postChannelGlobal', Symbols.question_mark,
maxLines: 1, color: Theme.of(context)
).tr().textColor( .appBarTheme
Theme.of(context).appBarTheme.foregroundColor), .foregroundColor!,
), ),
], const Gap(8),
Flexible(
child: Text(
'postCategory${category.alias.capitalize()}'
.trExists()
? 'postCategory${category.alias.capitalize()}'
.tr()
: category.name,
maxLines: 1,
).textColor(
Theme.of(context)
.appBarTheme
.foregroundColor!,
),
),
],
),
),
]
: [
for (final channel in kPostChannels)
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Icon(
kPostChannelIcons[
kPostChannels.indexOf(channel)],
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor,
),
const Gap(8),
Flexible(
child: Text(
'postChannel$channel',
maxLines: 1,
).tr().textColor(
Theme.of(context)
.appBarTheme
.foregroundColor,
),
),
],
),
),
],
), ),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.group,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelFriends',
maxLines: 1,
textAlign: TextAlign.center,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.subscriptions,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelFollowing',
maxLines: 1,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.workspaces,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelRealm',
maxLines: 1,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
],
),
), ),
), ),
]; ];
}, },
body: TabBarView( body: _PostListWidget(
controller: _tabController, key: _listKey,
children: [
_PostListWidget(
key: _listKeys[0],
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[1],
channel: 'friends',
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[2],
channel: 'following',
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[3],
withRealm: true,
onClearFilter: _clearFilter,
),
],
), ),
), ),
); );
@ -369,15 +407,7 @@ class _ExploreScreenState extends State<ExploreScreen>
} }
class _PostListWidget extends StatefulWidget { class _PostListWidget extends StatefulWidget {
final String? channel; const _PostListWidget({super.key});
final bool withRealm;
final Function onClearFilter;
const _PostListWidget(
{super.key,
this.channel,
this.withRealm = false,
required this.onClearFilter});
@override @override
State<_PostListWidget> createState() => _PostListWidgetState(); State<_PostListWidget> createState() => _PostListWidgetState();
@ -386,62 +416,98 @@ class _PostListWidget extends StatefulWidget {
class _PostListWidgetState extends State<_PostListWidget> { class _PostListWidgetState extends State<_PostListWidget> {
bool _isBusy = false; bool _isBusy = false;
final List<SnPost> _posts = List.empty(growable: true); SnRealm? get realm => _selectedRealm;
final List<SnRealm> _realms = List.empty(growable: true);
final List<SnFeedEntry> _feed = List.empty(growable: true);
SnRealm? _selectedRealm; SnRealm? _selectedRealm;
int? _postCount; String? _selectedChannel;
SnPostCategory? _selectedCategory;
Future<void> _fetchRealms() async { bool _hasLoadedAll = false;
try {
final rels = context.read<SnRealmProvider>();
final out = await rels.listAvailableRealms();
setState(() {
_realms.addAll(out);
_selectedRealm = out.firstOrNull;
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
rethrow;
}
}
// Called when using regular feed
Future<void> _fetchPosts() async { Future<void> _fetchPosts() async {
if (_postCount != null && _posts.length >= _postCount!) return; if (_hasLoadedAll) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
final pt = context.read<SnPostContentProvider>(); final pt = context.read<SnPostContentProvider>();
final result = await pt.listPosts( final result = await pt.listPosts(
take: 10, take: 10,
offset: _posts.length, offset: _feed.length,
categories: _selectedCategory != null ? [_selectedCategory!.alias] : null, categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
channel: widget.channel, channel: _selectedChannel,
realm: _selectedRealm?.alias, realm: _selectedRealm?.alias,
); );
final out = result.$1; final out = result.$1;
if (!mounted) return; if (!mounted) return;
_postCount = result.$2; final postCount = result.$2;
_posts.addAll(out); _feed.addAll(
out.map((ele) => SnFeedEntry(
type: 'interactive.post',
data: ele.toJson(),
createdAt: ele.createdAt)),
);
_hasLoadedAll = postCount >= _feed.length;
if (mounted) setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
} }
// Called when mixed feed is enabled
Future<void> _fetchFeed() async {
if (_hasLoadedAll) return;
setState(() => _isBusy = true);
final pt = context.read<SnPostContentProvider>();
final result = await pt.getFeed(
cursor: _feed
.where((ele) => !['reader.news'].contains(ele.type))
.lastOrNull
?.createdAt,
);
if (!mounted) return;
_feed.addAll(result);
_hasLoadedAll = result.isEmpty;
if (mounted) setState(() => _isBusy = false);
}
void setChannel(String? channel) {
_selectedChannel = channel;
setState(() {});
}
void setRealm(SnRealm? realm) {
_selectedRealm = realm;
setState(() {});
}
void setCategory(SnPostCategory? category) {
_selectedCategory = category;
setState(() {});
}
Future<void> refreshPosts() { Future<void> refreshPosts() {
_postCount = null; _hasLoadedAll = false;
_posts.clear(); _feed.clear();
return _fetchPosts(); final cfg = context.read<ConfigProvider>();
if (cfg.mixedFeed) {
return _fetchFeed();
} else {
return _fetchPosts();
}
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.withRealm) { final cfg = context.read<ConfigProvider>();
_fetchRealms().then((_) { if (cfg.mixedFeed) {
_fetchPosts(); _fetchFeed();
});
} else { } else {
_fetchPosts(); _fetchPosts();
} }
@ -449,178 +515,130 @@ class _PostListWidgetState extends State<_PostListWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( final cfg = context.watch<ConfigProvider>();
children: [ return MediaQuery.removePadding(
if (_selectedCategory != null) context: context,
MaterialBanner( removeTop: true,
content: Text( child: RefreshIndicator(
'postFilterWithCategory'.tr(args: [ displacement: 40 + MediaQuery.of(context).padding.top,
'postCategory${_selectedCategory!.alias.capitalize()}'.trExists() onRefresh: () => refreshPosts(),
? 'postCategory${_selectedCategory!.alias.capitalize()}' child: InfiniteList(
.tr() padding: EdgeInsets.only(top: 8),
: _selectedCategory!.name, itemCount: _feed.length,
]), isLoading: _isBusy,
), centerLoading: true,
leading: Icon(kCategoryIcons[_selectedCategory!.alias] ?? hasReachedMax: _hasLoadedAll,
Symbols.question_mark), onFetchData: cfg.mixedFeed ? _fetchFeed : _fetchPosts,
actions: [ itemBuilder: (context, idx) {
IconButton( final ele = _feed[idx];
icon: const Icon(Symbols.clear), switch (ele.type) {
onPressed: () { case 'interactive.post':
widget.onClearFilter.call(); return OpenablePostItem(
refreshPosts(); data: SnPost.fromJson(ele.data),
}, maxWidth: 640,
), onChanged: (data) {
], setState(() {
padding: const EdgeInsets.only(left: 20, right: 4), _feed[idx] = _feed[idx].copyWith(data: data.toJson());
), });
if (widget.withRealm) },
DropdownButtonHideUnderline( onDeleted: () {
child: DropdownButton2<SnRealm>( refreshPosts();
isExpanded: true, },
items: _realms );
.map( case 'fediverse.post':
(ele) => DropdownMenuItem<SnRealm>( return FediversePostWidget(
value: ele, data: SnFediversePost.fromJson(ele.data),
child: Row( maxWidth: 640,
children: [ );
AccountImage( case 'reader.news':
content: ele.avatar, return Center(
fallbackWidget: const Icon(Symbols.group, size: 16), child: Container(
radius: 14, constraints: BoxConstraints(maxWidth: 640),
), child: NewsFeedEntry(data: ele),
const Gap(8), ),
Text( );
ele.name, default:
style: Theme.of(context).textTheme.bodyMedium, return Container(
), constraints: BoxConstraints(maxWidth: 640),
], child: FeedUnknownEntry(data: ele),
), );
), }
) },
.toList(), separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
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),
), ),
], ),
); );
} }
} }
class _PostCategoryPickerPopup extends StatelessWidget { class _PostListRealmPopup extends StatelessWidget {
final List<SnPostCategory> categories; final List<SnRealm>? realms;
final SnPostCategory? selected; final Function(SnRealm?) onUpdate;
final Function(bool) onMixedFeedChanged;
const _PostCategoryPickerPopup({required this.categories, this.selected}); const _PostListRealmPopup({
required this.realms,
required this.onUpdate,
required this.onMixedFeedChanged,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Symbols.category, size: 24), const Icon(Symbols.tune, size: 24),
const Gap(16), const Gap(16),
Text('postCategory') Text('filterFeed', style: Theme.of(context).textTheme.titleLarge)
.tr() .tr(),
.textStyle(Theme.of(context).textTheme.titleLarge!),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
ListTile( SwitchListTile(
leading: const Icon(Symbols.clear), secondary: const Icon(Symbols.merge_type),
title: Text('postFilterReset').tr(), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
subtitle: Text('postFilterResetDescription').tr(), title: Text('mixedFeed').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 20), subtitle: Text('mixedFeedDescription').tr(),
onTap: () { value: cfg.mixedFeed,
Navigator.pop(context, false); onChanged: (value) {
cfg.mixedFeed = value;
onMixedFeedChanged.call(value);
}, },
), ),
const Divider(height: 1), if (!cfg.mixedFeed)
Expanded( ListTile(
child: GridView.count( leading: const Icon(Symbols.close),
crossAxisCount: 4, title: Text('postInGlobal').tr(),
shrinkWrap: true, subtitle: Text('postViewInGlobalDescription').tr(),
physics: const NeverScrollableScrollPhysics(), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
childAspectRatio: 1, onTap: () {
children: categories onUpdate.call(null);
.map( Navigator.pop(context);
(ele) => InkWell( },
onTap: () { ),
_selectedCategory = ele; if (!cfg.mixedFeed) const Divider(height: 1),
Navigator.pop(context, ele); if (!cfg.mixedFeed)
}, Expanded(
child: Column( child: ListView.builder(
crossAxisAlignment: CrossAxisAlignment.center, itemCount: realms?.length ?? 0,
mainAxisAlignment: MainAxisAlignment.center, itemBuilder: (context, idx) {
mainAxisSize: MainAxisSize.min, final realm = realms![idx];
children: [ return ListTile(
Icon( title: Text(realm.name),
kCategoryIcons[ele.alias] ?? Symbols.question_mark, subtitle: Text('@${realm.alias}'),
color: selected == ele leading: AccountImage(content: realm.avatar, radius: 18),
? Theme.of(context).colorScheme.primary onTap: () {
: null, onUpdate.call(realm);
), Navigator.pop(context);
const Gap(4), },
Text( );
'postCategory${ele.alias.capitalize()}'.trExists() },
? 'postCategory${ele.alias.capitalize()}'.tr() ),
: ele.name,
)
.textStyle(Theme.of(context).textTheme.titleMedium!)
.textColor(selected == ele
? Theme.of(context).colorScheme.primary
: null),
],
),
),
)
.toList(),
), ),
),
], ],
); );
} }

View File

@ -6,7 +6,6 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:html/parser.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
@ -20,13 +19,14 @@ import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/widget.dart'; import 'package:surface/providers/widget.dart';
import 'package:surface/types/check_in.dart'; import 'package:surface/types/check_in.dart';
import 'package:surface/types/news.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/updater.dart'; import 'package:surface/widgets/updater.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:url_launcher/url_launcher_string.dart';
class HomeScreenDashEntry { class HomeScreenDashEntry {
final String name; final String name;
@ -66,7 +66,7 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
HomeScreenDashEntry( HomeScreenDashEntry(
name: 'dashEntryTodayNews', name: 'dashEntryTodayNews',
child: _HomeDashTodayNews(), child: _HomeDashServiceStatus(),
cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2, cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
), ),
]; ];
@ -99,6 +99,7 @@ class _HomeScreenState extends State<HomeScreen> {
right: 8, right: 8,
), ),
), ),
_HomeDashUnconfirmedWidget().padding(horizontal: 8),
_HomeDashSpecialDayWidget().padding(horizontal: 8), _HomeDashSpecialDayWidget().padding(horizontal: 8),
StaggeredGrid.extent( StaggeredGrid.extent(
maxCrossAxisExtent: 280, maxCrossAxisExtent: 280,
@ -123,6 +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 { class _HomeDashUpdateWidget extends StatelessWidget {
final EdgeInsets? padding; final EdgeInsets? padding;
@ -131,7 +190,6 @@ class _HomeDashUpdateWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final config = context.watch<ConfigProvider>(); final config = context.watch<ConfigProvider>();
return ListenableBuilder( return ListenableBuilder(
listenable: config, listenable: config,
builder: (context, _) { builder: (context, _) {
@ -245,21 +303,31 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
} }
} }
class _HomeDashTodayNews extends StatefulWidget { class _HomeDashServiceStatus extends StatefulWidget {
const _HomeDashTodayNews(); const _HomeDashServiceStatus();
@override @override
State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState(); State<_HomeDashServiceStatus> createState() => _HomeDashServiceStatusState();
} }
class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> { class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
SnNewsArticle? _article; Map<String, dynamic>? _statuses;
ServiceStatus? _serviceStatus;
Future<void> _fetchArticle() async { Future<void> _fetchStatuses() async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news/today'); final resp = await sn.client.get('/directory/status');
_article = SnNewsArticle.fromJson(resp.data['data']); _statuses = resp.data;
if (_statuses!.values.contains(false)) {
if (_statuses!.values.contains(true)) {
_serviceStatus = ServiceStatus.downgraded;
} else {
_serviceStatus = ServiceStatus.failed;
}
} else {
_serviceStatus = ServiceStatus.operational;
}
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -272,7 +340,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
@override @override
initState() { initState() {
super.initState(); super.initState();
_fetchArticle(); _fetchStatuses();
} }
@override @override
@ -284,73 +352,127 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
children: [ children: [
Row( Row(
children: [ children: [
const Icon(Symbols.newspaper), const Icon(Symbols.flare),
const Gap(8), const Gap(8),
Text( Expanded(
'newsToday', child: Text(
style: Theme.of(context).textTheme.titleLarge, 'serviceStatus',
).tr() style: Theme.of(context).textTheme.titleLarge,
], ).tr(),
).padding(horizontal: 18, top: 12, bottom: 8), ),
if (_article != null) IconButton(
Expanded( icon: const Icon(Symbols.launch, size: 20),
child: InkWell( visualDensity: VisualDensity(horizontal: -4, vertical: -4),
borderRadius: BorderRadius.all(Radius.circular(8)), constraints: const BoxConstraints(),
child: Column( padding: EdgeInsets.zero,
crossAxisAlignment: CrossAxisAlignment.start, onPressed: () {
spacing: 4, launchUrlString('https://status.solsynth.dev');
children: [
Text(
_article!.title,
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontSize: 18),
maxLines:
MediaQuery.of(context).size.width >= 640 ? 2 : 1,
overflow: TextOverflow.ellipsis,
),
Text(
parse(_article!.description)
.children
.map((e) => e.text.trim())
.join(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Builder(builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 2,
children: [
Text(DateFormat().format(date)).textStyle(
Theme.of(context).textTheme.bodySmall!),
Text(' · ')
.textStyle(Theme.of(context).textTheme.bodySmall!)
.bold(),
Text(RelativeTime(context).format(date)).textStyle(
Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75);
}),
],
).padding(horizontal: 16),
onTap: () {
GoRouter.of(context).pushNamed(
'newsDetail',
pathParameters: {'hash': _article!.hash},
);
}, },
), ),
) ],
else ).padding(horizontal: 18, top: 12, bottom: 8),
Container(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 6),
width: double.infinity,
color: _serviceStatus == null
? Theme.of(context).colorScheme.surfaceContainerHigh
: switch (_serviceStatus) {
ServiceStatus.operational => Colors.green[300],
ServiceStatus.failed => Colors.red[300],
_ => Colors.orange[300],
},
child: _serviceStatus == null
? Row(
children: [
const Icon(
Symbols.more_horiz,
size: 20,
),
const Gap(10),
Text('loading').tr(),
],
)
: switch (_serviceStatus) {
ServiceStatus.operational => Row(
children: [
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( Expanded(
child: Center( child: SingleChildScrollView(
child: CircularProgressIndicator(), padding: EdgeInsets.only(top: 6),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final entry in _statuses!.entries)
Tooltip(
message: kServicesName[entry.key] != null
? 'serviceName${kServicesName[entry.key]}'.tr()
: 'unknown'.tr(),
child: Chip(
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
avatar: entry.value
? const Icon(
Symbols.circle,
color: Colors.green,
fill: 1,
size: 16,
)
: AnimateWidgetExtensions(const Icon(
Symbols.error,
color: Colors.red,
fill: 1,
size: 16,
))
.animate(onPlay: (e) => e.repeat())
.fadeIn(
duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
label: Text(kServicesName[entry.key] ?? entry.key),
),
),
],
).padding(horizontal: 12),
), ),
) ),
], ],
), ),
); );
@ -546,11 +668,26 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
'+${_todayRecord!.resultExperience} EXP', '+${_todayRecord!.resultExperience} EXP',
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
if (_todayRecord!.resultCoin >= 0) if (_todayRecord!.resultCoin > 0)
Text( Text(
'+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}', '+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
) ),
if (_todayRecord!.currentStreak > 0)
Row(
children: [
const Icon(
Symbols.local_fire_department,
size: 14,
).padding(bottom: 2),
const Gap(4),
Text(
'checkInStreak'
.plural(_todayRecord!.currentStreak),
style: Theme.of(context).textTheme.bodySmall,
),
],
).padding(top: 4),
], ],
), ),
), ),
@ -743,8 +880,10 @@ class _HomeDashRecommendationPostWidgetState
).tr(), ).tr(),
], ],
), ),
Text('${_currentPage + 1}/${_posts?.length ?? 0}', Text(
style: GoogleFonts.robotoMono()) '${_currentPage + 1}/${_posts?.length ?? 0}',
style: GoogleFonts.robotoMono(),
)
], ],
).padding(horizontal: 18, top: 12, bottom: 8), ).padding(horizontal: 18, top: 12, bottom: 8),
Expanded( Expanded(
@ -762,6 +901,7 @@ class _HomeDashRecommendationPostWidgetState
child: PostItem( child: PostItem(
data: _posts![index], data: _posts![index],
showMenu: false, showMenu: false,
showFullPost: true,
).padding(bottom: 8), ).padding(bottom: 8),
onTap: () { onTap: () {
GoRouter.of(context) GoRouter.of(context)

View File

@ -1,18 +1,17 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:html2md/html2md.dart' as html2md;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/news.dart'; import 'package:surface/types/news.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class NewsDetailScreen extends StatefulWidget { class NewsDetailScreen extends StatefulWidget {
@ -26,14 +25,12 @@ class NewsDetailScreen extends StatefulWidget {
class _NewsDetailScreenState extends State<NewsDetailScreen> { class _NewsDetailScreenState extends State<NewsDetailScreen> {
SnNewsArticle? _article; SnNewsArticle? _article;
dom.Document? _articleFragment;
Future<void> _fetchArticle() async { Future<void> _fetchArticle() async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news/${widget.hash}'); final resp = await sn.client.get('/cgi/re/news/${widget.hash}');
_article = SnNewsArticle.fromJson(resp.data); _article = SnNewsArticle.fromJson(resp.data);
_articleFragment = parse(_article!.content);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err).then((_) { context.showErrorDialog(err).then((_) {
@ -45,104 +42,6 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
} }
} }
List<Widget> _parseHtmlToWidgets(Iterable<dom.Element>? elements) {
if (elements == null) return [];
final List<Widget> widgets = [];
for (final node in elements) {
switch (node.localName) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
widgets.add(Text(node.text.trim(), style: Theme.of(context).textTheme.titleMedium));
break;
case 'p':
if (node.text.trim().isEmpty) continue;
widgets.add(
Text.rich(
TextSpan(
text: node.text.trim(),
children: [
for (final child in node.children)
switch (child.localName) {
'a' => TextSpan(
text: child.text.trim(),
style: const TextStyle(decoration: TextDecoration.underline),
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrlString(child.attributes['href']!);
},
),
_ => TextSpan(text: child.text.trim()),
},
],
),
style: Theme.of(context).textTheme.bodyLarge,
),
);
break;
case 'a':
// drop single link
break;
case 'div':
// ignore div text, normally it is not meaningful
widgets.addAll(_parseHtmlToWidgets(node.children));
break;
case 'hr':
widgets.add(const Divider());
break;
case 'img':
var src = node.attributes['src'];
if (src == null) break;
final width = double.tryParse(node.attributes['width'] ?? 'null');
final height = double.tryParse(node.attributes['height'] ?? 'null');
final ratio = width != null && height != null ? width / height : 1.0;
if (src.startsWith('//')) {
src = 'https:$src';
} else if (!src.startsWith('http')) {
final baseUri = Uri.parse(_article!.url);
final baseUrl = '${baseUri.scheme}://${baseUri.host}';
src = '$baseUrl/$src';
}
widgets.add(
AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
),
height: height ?? double.infinity,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: AutoResizeUniversalImage(
src,
fit: width != null && height != null ? BoxFit.cover : BoxFit.contain,
),
),
),
),
),
);
break;
default:
widgets.addAll(_parseHtmlToWidgets(node.children));
break;
}
}
return widgets;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -163,7 +62,9 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
MaterialBanner( MaterialBanner(
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
leading: const Icon(Icons.info), leading: const Icon(Icons.info),
content: Text(_isReadingFromReader ? 'newsReadingFromReader'.tr() : 'newsReadingFromOriginal'.tr()), content: Text(_isReadingFromReader
? 'newsReadingFromReader'.tr()
: 'newsReadingFromOriginal'.tr()),
actions: [ actions: [
TextButton( TextButton(
child: Text('newsReadingProviderSwap').tr(), child: Text('newsReadingProviderSwap').tr(),
@ -173,7 +74,7 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
), ),
], ],
), ),
if (_articleFragment != null && _isReadingFromReader) if (_article != null && _isReadingFromReader)
Expanded( Expanded(
child: Container( child: Container(
constraints: BoxConstraints(maxWidth: 640), constraints: BoxConstraints(maxWidth: 640),
@ -182,28 +83,43 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8, spacing: 8,
children: [ children: [
Text(_article!.title, style: Theme.of(context).textTheme.titleLarge), Text(_article!.title,
style: Theme.of(context).textTheme.titleLarge),
Builder(builder: (context) { Builder(builder: (context) {
final htmlDescription = parse(_article!.description); final htmlDescription = parse(_article!.description);
return Text( return Text(
htmlDescription.children.map((ele) => ele.text.trim()).join(), htmlDescription.children
.map((ele) => ele.text.trim())
.join(),
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
); );
}), }),
Builder(builder: (context) { Builder(builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt; final date =
_article!.publishedAt ?? _article!.createdAt;
return Row( return Row(
spacing: 2, spacing: 2,
children: [ children: [
Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!), Text(DateFormat().format(date)).textStyle(
Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(), Theme.of(context).textTheme.bodySmall!),
Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!), Text(' · ')
.textStyle(
Theme.of(context).textTheme.bodySmall!)
.bold(),
Text(RelativeTime(context).format(date)).textStyle(
Theme.of(context).textTheme.bodySmall!),
], ],
).opacity(0.75); ).opacity(0.75);
}), }),
Text('newsDisclaimer').tr().textStyle(Theme.of(context).textTheme.bodySmall!).opacity(0.75), Text('newsDisclaimer')
.tr()
.textStyle(Theme.of(context).textTheme.bodySmall!)
.opacity(0.75),
const Divider(), const Divider(),
..._parseHtmlToWidgets(_articleFragment!.children), MarkdownTextContent(
textScaler: TextScaler.linear(1.2),
content: html2md.convert(_article!.content),
),
const Divider(), const Divider(),
InkWell( InkWell(
child: Row( child: Row(
@ -211,7 +127,8 @@ class _NewsDetailScreenState extends State<NewsDetailScreen> {
children: [ children: [
Text( Text(
'Reference from original website', 'Reference from original website',
style: TextStyle(decoration: TextDecoration.underline), style: TextStyle(
decoration: TextDecoration.underline),
), ),
const Gap(4), const Gap(4),
Icon(Icons.launch, size: 16), Icon(Icons.launch, size: 16),

View File

@ -63,7 +63,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
queryParameters: {'take': 10, 'offset': _notifications.length}, queryParameters: {'take': 10, 'offset': _notifications.length},
); );
_totalCount = resp.data['count']; _totalCount = resp.data['count'];
_notifications.addAll(resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? []); _notifications.addAll(resp.data['data']
?.map((e) => SnNotification.fromJson(e))
.cast<SnNotification>() ??
[]);
nty.updateTray(); nty.updateTray();
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
@ -98,7 +101,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
nty.clear(); nty.clear();
if (!mounted) return; if (!mounted) return;
context.showSnackbar('notificationMarkAllReadPrompt'.plural(resp.data['count'])); context.showSnackbar(
'notificationMarkAllReadPrompt'.plural(resp.data['count']));
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -122,7 +126,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
_fetchNotifications(); _fetchNotifications();
if (!mounted) return; if (!mounted) return;
context.showSnackbar('notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}'])); context.showSnackbar(
'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']));
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -143,7 +148,9 @@ class _NotificationScreenState extends State<NotificationScreen> {
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
return AppScaffold( return AppScaffold(
appBar: AppBar(leading: AutoAppBarLeading(), title: Text('screenNotification').tr()), appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenNotification').tr()),
body: Center(child: UnauthorizedHint()), body: Center(child: UnauthorizedHint()),
); );
} }
@ -153,7 +160,9 @@ class _NotificationScreenState extends State<NotificationScreen> {
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text('screenNotification').tr(), title: Text('screenNotification').tr(),
actions: [ actions: [
IconButton(icon: const Icon(Symbols.checklist), onPressed: _isSubmitting ? null : _markAllAsRead), IconButton(
icon: const Icon(Symbols.checklist),
onPressed: _isSubmitting ? null : _markAllAsRead),
const Gap(8), const Gap(8),
], ],
), ),
@ -167,13 +176,17 @@ class _NotificationScreenState extends State<NotificationScreen> {
return _fetchNotifications(); return _fetchNotifications();
}, },
child: InfiniteList( 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, itemCount: _notifications.length,
onFetchData: () { onFetchData: () {
_fetchNotifications(); _fetchNotifications();
}, },
isLoading: _isBusy, isLoading: _isBusy,
hasReachedMax: _totalCount != null && _notifications.length >= _totalCount!, hasReachedMax: _totalCount != null &&
_notifications.length >= _totalCount!,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
final nty = _notifications[idx]; final nty = _notifications[idx];
return Row( return Row(
@ -186,12 +199,19 @@ class _NotificationScreenState extends State<NotificationScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (nty.readAt == null) if (nty.readAt == null)
StyledWidget(Badge(label: Text('notificationUnread').tr())).padding(bottom: 4), StyledWidget(Badge(
Text(nty.title, style: Theme.of(context).textTheme.titleMedium), label: Text('notificationUnread').tr()))
.padding(bottom: 4),
Text(nty.title,
style: Theme.of(context).textTheme.titleMedium),
if (nty.subtitle != null) 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), if (nty.subtitle != null) const Gap(4),
SelectionArea(child: MarkdownTextContent(content: nty.body, isAutoWarp: true)), SelectionArea(
child: MarkdownTextContent(
content: nty.body, isAutoWarp: true)),
if ([ if ([
'interactive.reply', 'interactive.reply',
'interactive.feedback', 'interactive.feedback',
@ -201,31 +221,43 @@ class _NotificationScreenState extends State<NotificationScreen> {
GestureDetector( GestureDetector(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(
border: Border.all(color: Theme.of(context).dividerColor, width: 1), Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1),
), ),
child: PostItem( child: PostItem(
data: SnPost.fromJson(nty.metadata['related_post']!), data: SnPost.fromJson(
nty.metadata['related_post']!),
showComments: false, showComments: false,
showReactions: false, showReactions: false,
showMenu: false, showMenu: false,
), ).padding(vertical: 4),
), ),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postDetail', 'postDetail',
pathParameters: {'slug': nty.metadata['related_post']!['id'].toString()}, pathParameters: {
'slug': nty
.metadata['related_post']!['id']
.toString()
},
); );
}, },
).padding(top: 8), ).padding(top: 8),
const Gap(8), const Gap(8),
Row( Row(
children: [ children: [
Text(DateFormat('yy/MM/dd').format(nty.createdAt)).fontSize(12), Text(DateFormat('yy/MM/dd')
.format(nty.createdAt))
.fontSize(12),
const Gap(4), const Gap(4),
Text('·', style: TextStyle(fontSize: 12)), Text('·', style: TextStyle(fontSize: 12)),
const Gap(4), const Gap(4),
Text(RelativeTime(context).format(nty.createdAt)).fontSize(12), Text(RelativeTime(context)
.format(nty.createdAt))
.fontSize(12),
], ],
).opacity(0.75), ).opacity(0.75),
], ],
@ -235,8 +267,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
IconButton( IconButton(
icon: const Icon(Symbols.check), icon: const Icon(Symbols.check),
padding: EdgeInsets.all(0), padding: EdgeInsets.all(0),
visualDensity: const VisualDensity(horizontal: -4, vertical: -4), visualDensity:
onPressed: _isSubmitting ? null : () => _markOneAsRead(nty), const VisualDensity(horizontal: -4, vertical: -4),
onPressed:
_isSubmitting ? null : () => _markOneAsRead(nty),
), ),
], ],
).padding(horizontal: 16); ).padding(horizontal: 16);

View File

@ -22,7 +22,8 @@ class PostDetailScreen extends StatefulWidget {
final SnPost? preload; final SnPost? preload;
final Function? onBack; final Function? onBack;
const PostDetailScreen({super.key, required this.slug, this.preload, this.onBack}); const PostDetailScreen(
{super.key, required this.slug, this.preload, this.onBack});
@override @override
State<PostDetailScreen> createState() => _PostDetailScreenState(); State<PostDetailScreen> createState() => _PostDetailScreenState();
@ -88,14 +89,16 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
TextSpan( TextSpan(
text: _data?.body['title'] ?? 'postNoun'.tr(), text: _data?.body['title'] ?? 'postNoun'.tr(),
style: Theme.of(context).textTheme.titleLarge!.copyWith( style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!, color:
Theme.of(context).appBarTheme.foregroundColor!,
), ),
), ),
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: 'postDetail'.tr(), text: 'postDetail'.tr(),
style: Theme.of(context).textTheme.bodySmall!.copyWith( style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!, color:
Theme.of(context).appBarTheme.foregroundColor!,
), ),
), ),
]), ]),
@ -124,8 +127,11 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
}, },
), ),
), ),
if (_data != null && _data!.type != 'video') const SliverToBoxAdapter(child: Divider(height: 1)), if (_data != null)
if (_data != null && _data!.type != 'video') SliverToBoxAdapter(
child: Divider(height: 1).padding(top: 8),
),
if (_data != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: Container(
constraints: BoxConstraints(maxWidth: maxWidth), constraints: BoxConstraints(maxWidth: maxWidth),
@ -141,7 +147,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
).padding(horizontal: 20, vertical: 12).center(), ).padding(horizontal: 20, vertical: 12).center(),
), ),
), ),
if (_data != null && ua.isAuthorized && _data!.type != 'video') if (_data != null && ua.isAuthorized)
SliverToBoxAdapter( SliverToBoxAdapter(
child: PostCommentQuickAction( child: PostCommentQuickAction(
parentPost: _data!, parentPost: _data!,
@ -158,13 +164,15 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
}, },
), ),
), ),
if (_data != null && _data!.type != 'video') if (_data != null) SliverGap(8),
if (_data != null)
PostCommentSliverList( PostCommentSliverList(
key: _childListKey, key: _childListKey,
parentPost: _data!, parentPost: _data!,
maxWidth: maxWidth, maxWidth: maxWidth,
), ),
if (_data != null && _data!.type == 'video') SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), if (_data != null)
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
], ],
), ),
), ),

View File

@ -0,0 +1,89 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostDraftBox extends StatefulWidget {
const PostDraftBox({super.key});
@override
State<PostDraftBox> createState() => _PostDraftBoxState();
}
class _PostDraftBoxState extends State<PostDraftBox> {
bool _isBusy = false;
final List<SnPost> _posts = List.empty(growable: true);
int? _totalCount;
Future<void> _fetchPosts() async {
setState(() => _isBusy = true);
try {
final pt = context.read<SnPostContentProvider>();
final resp = await pt.listPosts(
take: 10,
offset: _posts.length,
isDraft: true,
);
final out = resp.$1;
_totalCount = resp.$2;
if (!mounted) return;
_posts.addAll(out);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text('postDraftBox').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_posts.clear();
return _fetchPosts();
},
child: InfiniteList(
padding: EdgeInsets.only(top: 8),
hasReachedMax:
_totalCount != null && _posts.length >= _totalCount!,
itemCount: _posts.length,
onFetchData: () => _fetchPosts(),
itemBuilder: (context, idx) {
final ele = _posts[idx];
return OpenablePostItem(
data: ele,
onChanged: (data) {
_posts[idx] = data;
},
onDeleted: () {
_posts.clear();
_fetchPosts();
},
);
},
separatorBuilder: (_, __) =>
const Divider().padding(vertical: 2),
),
),
),
],
),
);
}
}

View File

@ -18,6 +18,7 @@ import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart'; import 'package:surface/types/realm.dart';
@ -36,7 +37,8 @@ import 'package:provider/provider.dart';
import 'package:surface/widgets/post/post_poll_editor.dart'; import 'package:surface/widgets/post/post_poll_editor.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../../providers/sn_realm.dart'; const kPostTypes = ['Story', 'Article', 'Question', 'Video'];
const kPostTypeAliases = ['stories', 'articles', 'questions', 'videos'];
class PostEditorExtra { class PostEditorExtra {
final String? text; final String? text;
@ -53,7 +55,7 @@ class PostEditorExtra {
} }
class PostEditorScreen extends StatefulWidget { class PostEditorScreen extends StatefulWidget {
final String mode; final String? mode;
final int? postEditId; final int? postEditId;
final int? postReplyId; final int? postReplyId;
final int? postRepostId; final int? postRepostId;
@ -72,7 +74,10 @@ class PostEditorScreen extends StatefulWidget {
State<PostEditorScreen> createState() => _PostEditorScreenState(); State<PostEditorScreen> createState() => _PostEditorScreenState();
} }
class _PostEditorScreenState extends State<PostEditorScreen> { class _PostEditorScreenState extends State<PostEditorScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController =
TabController(length: 4, vsync: this);
late final PostWriteController _writeController = PostWriteController( late final PostWriteController _writeController = PostWriteController(
doLoadFromTemporary: widget.postEditId == null, doLoadFromTemporary: widget.postEditId == null,
); );
@ -133,6 +138,15 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
], ],
scope: HotKeyScope.inapp, scope: HotKeyScope.inapp,
); );
final HotKey _saveDraftHotKey = HotKey(
key: PhysicalKeyboardKey.keyS,
modifiers: [
(!kIsWeb && Platform.isMacOS)
? HotKeyModifier.meta
: HotKeyModifier.control
],
scope: HotKeyScope.inapp,
);
void _registerHotKey() { void _registerHotKey() {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
@ -148,6 +162,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
]); ]);
setState(() {}); setState(() {});
}); });
hotKeyManager.register(_saveDraftHotKey, keyDownHandler: (_) async {
if (mounted) {
_writeController.sendPost(context, saveAsDraft: true);
}
});
} }
void _showPublisherPopup() { void _showPublisherPopup() {
@ -209,9 +228,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
@override @override
void dispose() { void dispose() {
_tabController.dispose();
_writeController.dispose(); _writeController.dispose();
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
hotKeyManager.unregister(_pasteHotKey); hotKeyManager.unregister(_pasteHotKey);
hotKeyManager.unregister(_saveDraftHotKey);
} }
super.dispose(); super.dispose();
} }
@ -220,14 +241,16 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_registerHotKey(); _registerHotKey();
if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
context.showErrorDialog('Unknown post type');
Navigator.pop(context);
} else {
_writeController.setMode(widget.mode);
}
_fetchRealms(); _fetchRealms();
_fetchPublishers(); _fetchPublishers();
if (widget.mode != null) {
_writeController.setMode(widget.mode!);
}
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
_writeController.setMode(kPostTypeAliases[_tabController.index]);
}
});
_writeController.fetchRelatedPost( _writeController.fetchRelatedPost(
context, context,
editing: widget.postEditId, editing: widget.postEditId,
@ -255,38 +278,55 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
title: RichText( title: Text(
textAlign: TextAlign.center, _writeController.title.isNotEmpty
text: TextSpan(children: [ ? _writeController.title
TextSpan( : 'untitled'.tr(),
text: _writeController.title.isNotEmpty
? _writeController.title
: 'untitled'.tr(),
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: PostWriteController.kTitleMap[widget.mode]!.tr(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
]),
maxLines: 2,
), ),
actions: [ actions: [
IconButton(
icon: _writeController.editingDraft
? const Icon(Icons.save)
: const Icon(Symbols.save_as),
onPressed: () {
_writeController.sendPost(context, saveAsDraft: true).then(
(_) {
if (!context.mounted) return;
context.showSnackbar('postDraftSaved'.tr());
HapticFeedback.mediumImpact();
},
);
},
),
IconButton( IconButton(
icon: const Icon(Symbols.tune), icon: const Icon(Symbols.tune),
onPressed: _writeController.isBusy ? null : _updateMeta, onPressed: _writeController.isBusy ? null : _updateMeta,
), ),
const Gap(8), const Gap(8),
], ],
bottom: _writeController.isNotEmpty || widget.mode != null
? null
: TabBar(
controller: _tabController,
tabs: [
for (final type in kPostTypes)
Tab(
child: Text(
'postType$type'.tr(),
style: TextStyle(
color: Theme.of(context)
.appBarTheme
.foregroundColor!,
),
),
),
],
),
), ),
body: Column( body: Column(
children: [ children: [
if (_writeController.editingPost != null) if (_writeController.editingPost != null &&
!_writeController.editingDraft)
Container( Container(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: 4, bottom: 4, left: 20, right: 20), top: 4, bottom: 4, left: 20, right: 20),
@ -374,7 +414,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
children: [ children: [
SingleChildScrollView( SingleChildScrollView(
padding: EdgeInsets.only(bottom: 160), padding: EdgeInsets.only(bottom: 160),
child: StyledWidget(switch (_writeController.mode) { child: switch (_writeController.mode) {
'stories' => _PostStoryEditor( 'stories' => _PostStoryEditor(
controller: _writeController, controller: _writeController,
onTapPublisher: _showPublisherPopup, onTapPublisher: _showPublisherPopup,
@ -396,8 +436,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
onTapRealm: _showRealmPopup, onTapRealm: _showRealmPopup,
), ),
_ => const Placeholder(), _ => const Placeholder(),
}) },
.padding(top: 8),
), ),
if (_writeController.attachments.isNotEmpty || if (_writeController.attachments.isNotEmpty ||
_writeController.thumbnail != null) _writeController.thumbnail != null)
@ -720,7 +759,7 @@ class _PostStoryEditor extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640), constraints: const BoxConstraints(maxWidth: 640),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -969,7 +1008,7 @@ class _PostQuestionEditor extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640), constraints: const BoxConstraints(maxWidth: 640),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -1053,7 +1092,7 @@ class _PostQuestionEditor extends StatelessWidget {
), ),
), ),
], ],
).padding(top: 8), ),
); );
} }
} }
@ -1154,7 +1193,7 @@ class _PostVideoEditor extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640), constraints: const BoxConstraints(maxWidth: 640),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -45,7 +45,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
} }
Future<void> _fetchPosts() async { Future<void> _fetchPosts() async {
if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return; if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty)
return;
if (_postCount != null && _posts.length >= _postCount!) return; if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -152,7 +153,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
}, },
); );
}, },
separatorBuilder: (_, __) => const Gap(8), separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
), ),
Positioned( Positioned(
top: 16, top: 16,
@ -166,7 +167,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
padding: const WidgetStatePropertyAll( padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 24), EdgeInsets.symmetric(horizontal: 24),
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (value) { onChanged: (value) {
_searchTerm = value; _searchTerm = value;
}, },

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

@ -34,9 +34,11 @@ class PostPublisherScreen extends StatefulWidget {
State<PostPublisherScreen> createState() => _PostPublisherScreenState(); State<PostPublisherScreen> createState() => _PostPublisherScreenState();
} }
class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTickerProviderStateMixin { class _PostPublisherScreenState extends State<PostPublisherScreen>
with SingleTickerProviderStateMixin {
late final ScrollController _scrollController = ScrollController(); late final ScrollController _scrollController = ScrollController();
late final TabController _tabController = TabController(length: 3, vsync: this); late final TabController _tabController =
TabController(length: 3, vsync: this);
SnPublisher? _publisher; SnPublisher? _publisher;
SnAccount? _account; SnAccount? _account;
@ -66,7 +68,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
_account = await ud.getAccount(_publisher?.accountId); _account = await ud.getAccount(_publisher?.accountId);
_accountRelationship = await rel.getRelationship(_account!.id); _accountRelationship = await rel.getRelationship(_account!.id);
if (_publisher?.realmId != null && _publisher!.realmId != 0) { if (_publisher?.realmId != null && _publisher!.realmId != 0) {
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}'); final resp =
await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
_realm = SnRealm.fromJson(resp.data); _realm = SnRealm.fromJson(resp.data);
} }
} catch (_) { } catch (_) {
@ -133,12 +136,14 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
double _appBarBlur = 0.0; double _appBarBlur = 0.0;
late final _appBarWidth = MediaQuery.of(context).size.width; late final _appBarWidth = MediaQuery.of(context).size.width;
late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble(); late final _appBarHeight =
(_appBarWidth * kBannerAspectRatio).roundToDouble();
void _updateAppBarBlur() { void _updateAppBarBlur() {
if (_scrollController.offset > _appBarHeight) return; if (_scrollController.offset > _appBarHeight) return;
setState(() { setState(() {
_appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0); _appBarBlur =
(_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
}); });
} }
@ -193,7 +198,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
'related': _account!.name, 'related': _account!.name,
}); });
if (!mounted) return; if (!mounted) return;
context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}'])); context.showSnackbar(
'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -209,9 +215,11 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
try { try {
final rel = context.read<SnRelationshipProvider>(); final rel = context.read<SnRelationshipProvider>();
await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {}); await rel.updateRelationship(
_account!.id, 1, _accountRelationship?.permNodes ?? {});
if (!mounted) return; if (!mounted) return;
context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}'])); context.showSnackbar(
'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -299,7 +307,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
text: TextSpan(children: [ text: TextSpan(children: [
TextSpan( TextSpan(
text: _publisher!.nick, text: _publisher!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith( style: Theme.of(context)
.textTheme
.titleLarge!
.copyWith(
color: Colors.white, color: Colors.white,
shadows: labelShadows, shadows: labelShadows,
), ),
@ -307,7 +318,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: '@${_publisher!.name}', text: '@${_publisher!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith( style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(
color: Colors.white, color: Colors.white,
shadows: labelShadows, shadows: labelShadows,
), ),
@ -330,13 +344,16 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
) )
else else
Container( Container(
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context)
.colorScheme
.surfaceContainer,
), ),
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
height: 56 + MediaQuery.of(context).padding.top, height:
56 + MediaQuery.of(context).padding.top,
child: ClipRect( child: ClipRect(
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur( filter: ImageFilter.blur(
@ -345,7 +362,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
), ),
child: Container( child: Container(
color: Colors.black.withOpacity( color: Colors.black.withOpacity(
clampDouble(_appBarBlur * 0.1, 0, 0.5), clampDouble(
_appBarBlur * 0.1, 0, 0.5),
), ),
), ),
), ),
@ -372,11 +390,14 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
const Gap(16), const Gap(16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text( Text(
_publisher!.nick, _publisher!.nick,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context)
.textTheme
.titleMedium,
).bold(), ).bold(),
Text('@${_publisher!.name}').fontSize(13), Text('@${_publisher!.name}').fontSize(13),
], ],
@ -387,7 +408,9 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
style: ButtonStyle( style: ButtonStyle(
elevation: WidgetStatePropertyAll(0), elevation: WidgetStatePropertyAll(0),
), ),
onPressed: _isSubscribing ? null : _toggleSubscription, onPressed: _isSubscribing
? null
: _toggleSubscription,
label: Text('subscribe').tr(), label: Text('subscribe').tr(),
icon: const Icon(Symbols.add), icon: const Icon(Symbols.add),
) )
@ -396,14 +419,17 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
style: ButtonStyle( style: ButtonStyle(
elevation: WidgetStatePropertyAll(0), elevation: WidgetStatePropertyAll(0),
), ),
onPressed: _isSubscribing ? null : _toggleSubscription, onPressed: _isSubscribing
? null
: _toggleSubscription,
label: Text('unsubscribe').tr(), label: Text('unsubscribe').tr(),
icon: const Icon(Symbols.remove), icon: const Icon(Symbols.remove),
), ),
PopupMenuButton( PopupMenuButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
style: ButtonStyle( style: ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4), visualDensity: VisualDensity(
horizontal: -4, vertical: -4),
), ),
itemBuilder: (BuildContext context) => [ itemBuilder: (BuildContext context) => [
PopupMenuItem( PopupMenuItem(
@ -443,7 +469,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
], ],
), ),
const Gap(12), const Gap(12),
Text(_publisher!.description).padding(horizontal: 8), Text(_publisher!.description)
.padding(horizontal: 8),
const Gap(12), const Gap(12),
Column( Column(
children: [ children: [
@ -451,8 +478,10 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
children: [ children: [
const Icon(Symbols.calendar_add_on), const Icon(Symbols.calendar_add_on),
const Gap(8), const Gap(8),
Text('publisherJoinedAt') Text('publisherJoinedAt').tr(args: [
.tr(args: [DateFormat('y/M/d').format(_publisher!.createdAt)]), DateFormat('y/M/d')
.format(_publisher!.createdAt)
]),
], ],
), ),
Row( Row(
@ -460,7 +489,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
const Icon(Symbols.trending_up), const Icon(Symbols.trending_up),
const Gap(8), const Gap(8),
Text('publisherSocialPointTotal').plural( Text('publisherSocialPointTotal').plural(
_publisher!.totalUpvote - _publisher!.totalDownvote, _publisher!.totalUpvote -
_publisher!.totalDownvote,
), ),
], ],
), ),
@ -470,18 +500,22 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
const Icon(Symbols.group_work), const Icon(Symbols.group_work),
const Gap(8), const Gap(8),
InkWell( InkWell(
child: Text('publisherAffiliatedBy').tr(args: [ child: Text('publisherAffiliatedBy')
.tr(args: [
'@${_realm?.alias ?? 'unknown'}', '@${_realm?.alias ?? 'unknown'}',
]), ]),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'realmDetail', 'realmDetail',
pathParameters: {'alias': _realm!.alias}, pathParameters: {
'alias': _realm!.alias
},
); );
}, },
), ),
const Gap(8), const Gap(8),
AccountImage(content: _realm?.avatar, radius: 8), AccountImage(
content: _realm?.avatar, radius: 8),
], ],
), ),
Row( Row(
@ -502,7 +536,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
}, },
), ),
const Gap(8), const Gap(8),
AccountImage(content: _account?.avatar, radius: 8), AccountImage(
content: _account?.avatar, radius: 8),
], ],
), ),
], ],
@ -606,7 +641,7 @@ class _PublisherPostList extends StatelessWidget {
onDeleted: onDeleted, onDeleted: onDeleted,
); );
}, },
separatorBuilder: (_, __) => const Gap(8), separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
); );
} }
} }

View File

@ -387,6 +387,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
.fontSize(17) .fontSize(17)
.tr() .tr()
.padding(horizontal: 20, bottom: 4), .padding(horizontal: 20, bottom: 4),
CheckboxListTile(
secondary: const Icon(Symbols.translate),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
title: Text('settingsAutoTranslate').tr(),
subtitle: Text('settingsAutoTranslateDescription').tr(),
value: _prefs.getBool(kAppAutoTranslate) ?? false,
onChanged: (value) {
setState(() {
_prefs.setBool(kAppAutoTranslate, value ?? false);
});
},
),
CheckboxListTile( CheckboxListTile(
secondary: const Icon(Symbols.vibration), secondary: const Icon(Symbols.vibration),
contentPadding: const EdgeInsets.only(left: 24, right: 17), contentPadding: const EdgeInsets.only(left: 24, right: 17),

View File

@ -61,7 +61,7 @@ class _AppSharingListenerState extends State<AppSharingListener> {
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postEditor', 'postEditor',
pathParameters: { queryParameters: {
'mode': 'stories', 'mode': 'stories',
}, },
extra: PostEditorExtra( extra: PostEditorExtra(

View File

@ -119,13 +119,33 @@ abstract class SnAccountStatusInfo with _$SnAccountStatusInfo {
required bool isDisturbable, required bool isDisturbable,
required bool isOnline, required bool isOnline,
required DateTime? lastSeenAt, required DateTime? lastSeenAt,
required dynamic status, required SnAccountStatus? status,
}) = _SnAccountStatusInfo; }) = _SnAccountStatusInfo;
factory SnAccountStatusInfo.fromJson(Map<String, Object?> json) => factory SnAccountStatusInfo.fromJson(Map<String, Object?> json) =>
_$SnAccountStatusInfoFromJson(json); _$SnAccountStatusInfoFromJson(json);
} }
@freezed
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 @freezed
abstract class SnAbuseReport with _$SnAbuseReport { abstract class SnAbuseReport with _$SnAbuseReport {
const factory SnAbuseReport({ const factory SnAbuseReport({
@ -142,3 +162,25 @@ abstract class SnAbuseReport with _$SnAbuseReport {
factory SnAbuseReport.fromJson(Map<String, Object?> json) => factory SnAbuseReport.fromJson(Map<String, Object?> json) =>
_$SnAbuseReportFromJson(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);
}

View File

@ -2139,7 +2139,7 @@ mixin _$SnAccountStatusInfo {
bool get isDisturbable; bool get isDisturbable;
bool get isOnline; bool get isOnline;
DateTime? get lastSeenAt; DateTime? get lastSeenAt;
dynamic get status; SnAccountStatus? get status;
/// Create a copy of SnAccountStatusInfo /// Create a copy of SnAccountStatusInfo
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -2163,13 +2163,13 @@ mixin _$SnAccountStatusInfo {
other.isOnline == isOnline) && other.isOnline == isOnline) &&
(identical(other.lastSeenAt, lastSeenAt) || (identical(other.lastSeenAt, lastSeenAt) ||
other.lastSeenAt == lastSeenAt) && other.lastSeenAt == lastSeenAt) &&
const DeepCollectionEquality().equals(other.status, status)); (identical(other.status, status) || other.status == status));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType, isDisturbable, isOnline, int get hashCode =>
lastSeenAt, const DeepCollectionEquality().hash(status)); Object.hash(runtimeType, isDisturbable, isOnline, lastSeenAt, status);
@override @override
String toString() { String toString() {
@ -2187,7 +2187,9 @@ abstract mixin class $SnAccountStatusInfoCopyWith<$Res> {
{bool isDisturbable, {bool isDisturbable,
bool isOnline, bool isOnline,
DateTime? lastSeenAt, DateTime? lastSeenAt,
dynamic status}); SnAccountStatus? status});
$SnAccountStatusCopyWith<$Res>? get status;
} }
/// @nodoc /// @nodoc
@ -2224,9 +2226,23 @@ class _$SnAccountStatusInfoCopyWithImpl<$Res>
status: freezed == status status: freezed == status
? _self.status ? _self.status
: status // ignore: cast_nullable_to_non_nullable : status // ignore: cast_nullable_to_non_nullable
as dynamic, as SnAccountStatus?,
)); ));
} }
/// Create a copy of SnAccountStatusInfo
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountStatusCopyWith<$Res>? get status {
if (_self.status == null) {
return null;
}
return $SnAccountStatusCopyWith<$Res>(_self.status!, (value) {
return _then(_self.copyWith(status: value));
});
}
} }
/// @nodoc /// @nodoc
@ -2247,7 +2263,7 @@ class _SnAccountStatusInfo implements SnAccountStatusInfo {
@override @override
final DateTime? lastSeenAt; final DateTime? lastSeenAt;
@override @override
final dynamic status; final SnAccountStatus? status;
/// Create a copy of SnAccountStatusInfo /// Create a copy of SnAccountStatusInfo
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@ -2276,13 +2292,13 @@ class _SnAccountStatusInfo implements SnAccountStatusInfo {
other.isOnline == isOnline) && other.isOnline == isOnline) &&
(identical(other.lastSeenAt, lastSeenAt) || (identical(other.lastSeenAt, lastSeenAt) ||
other.lastSeenAt == lastSeenAt) && other.lastSeenAt == lastSeenAt) &&
const DeepCollectionEquality().equals(other.status, status)); (identical(other.status, status) || other.status == status));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType, isDisturbable, isOnline, int get hashCode =>
lastSeenAt, const DeepCollectionEquality().hash(status)); Object.hash(runtimeType, isDisturbable, isOnline, lastSeenAt, status);
@override @override
String toString() { String toString() {
@ -2302,7 +2318,10 @@ abstract mixin class _$SnAccountStatusInfoCopyWith<$Res>
{bool isDisturbable, {bool isDisturbable,
bool isOnline, bool isOnline,
DateTime? lastSeenAt, DateTime? lastSeenAt,
dynamic status}); SnAccountStatus? status});
@override
$SnAccountStatusCopyWith<$Res>? get status;
} }
/// @nodoc /// @nodoc
@ -2339,7 +2358,386 @@ class __$SnAccountStatusInfoCopyWithImpl<$Res>
status: freezed == status status: freezed == status
? _self.status ? _self.status
: status // ignore: cast_nullable_to_non_nullable : status // ignore: cast_nullable_to_non_nullable
as dynamic, as SnAccountStatus?,
));
}
/// Create a copy of SnAccountStatusInfo
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountStatusCopyWith<$Res>? get status {
if (_self.status == null) {
return null;
}
return $SnAccountStatusCopyWith<$Res>(_self.status!, (value) {
return _then(_self.copyWith(status: value));
});
}
}
/// @nodoc
mixin _$SnAccountStatus {
int get id;
DateTime get createdAt;
DateTime get updatedAt;
DateTime? get deletedAt;
String get type;
String get label;
int get attitude;
bool get isNoDisturb;
bool get isInvisible;
DateTime? get clearAt;
int get accountId;
/// Create a copy of SnAccountStatus
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnAccountStatusCopyWith<SnAccountStatus> get copyWith =>
_$SnAccountStatusCopyWithImpl<SnAccountStatus>(
this as SnAccountStatus, _$identity);
/// Serializes this SnAccountStatus to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is SnAccountStatus &&
(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.type, type) || other.type == type) &&
(identical(other.label, label) || other.label == label) &&
(identical(other.attitude, attitude) ||
other.attitude == attitude) &&
(identical(other.isNoDisturb, isNoDisturb) ||
other.isNoDisturb == isNoDisturb) &&
(identical(other.isInvisible, isInvisible) ||
other.isInvisible == isInvisible) &&
(identical(other.clearAt, clearAt) || other.clearAt == clearAt) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
type,
label,
attitude,
isNoDisturb,
isInvisible,
clearAt,
accountId);
@override
String toString() {
return 'SnAccountStatus(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, label: $label, attitude: $attitude, isNoDisturb: $isNoDisturb, isInvisible: $isInvisible, clearAt: $clearAt, accountId: $accountId)';
}
}
/// @nodoc
abstract mixin class $SnAccountStatusCopyWith<$Res> {
factory $SnAccountStatusCopyWith(
SnAccountStatus value, $Res Function(SnAccountStatus) _then) =
_$SnAccountStatusCopyWithImpl;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String type,
String label,
int attitude,
bool isNoDisturb,
bool isInvisible,
DateTime? clearAt,
int accountId});
}
/// @nodoc
class _$SnAccountStatusCopyWithImpl<$Res>
implements $SnAccountStatusCopyWith<$Res> {
_$SnAccountStatusCopyWithImpl(this._self, this._then);
final SnAccountStatus _self;
final $Res Function(SnAccountStatus) _then;
/// Create a copy of SnAccountStatus
/// 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? type = null,
Object? label = null,
Object? attitude = null,
Object? isNoDisturb = null,
Object? isInvisible = null,
Object? clearAt = freezed,
Object? accountId = null,
}) {
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?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as String,
label: null == label
? _self.label
: label // ignore: cast_nullable_to_non_nullable
as String,
attitude: null == attitude
? _self.attitude
: attitude // ignore: cast_nullable_to_non_nullable
as int,
isNoDisturb: null == isNoDisturb
? _self.isNoDisturb
: isNoDisturb // ignore: cast_nullable_to_non_nullable
as bool,
isInvisible: null == isInvisible
? _self.isInvisible
: isInvisible // ignore: cast_nullable_to_non_nullable
as bool,
clearAt: freezed == clearAt
? _self.clearAt
: clearAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
@JsonSerializable()
class _SnAccountStatus implements SnAccountStatus {
const _SnAccountStatus(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.type,
required this.label,
required this.attitude,
required this.isNoDisturb,
required this.isInvisible,
required this.clearAt,
required this.accountId});
factory _SnAccountStatus.fromJson(Map<String, dynamic> json) =>
_$SnAccountStatusFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String type;
@override
final String label;
@override
final int attitude;
@override
final bool isNoDisturb;
@override
final bool isInvisible;
@override
final DateTime? clearAt;
@override
final int accountId;
/// Create a copy of SnAccountStatus
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnAccountStatusCopyWith<_SnAccountStatus> get copyWith =>
__$SnAccountStatusCopyWithImpl<_SnAccountStatus>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnAccountStatusToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _SnAccountStatus &&
(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.type, type) || other.type == type) &&
(identical(other.label, label) || other.label == label) &&
(identical(other.attitude, attitude) ||
other.attitude == attitude) &&
(identical(other.isNoDisturb, isNoDisturb) ||
other.isNoDisturb == isNoDisturb) &&
(identical(other.isInvisible, isInvisible) ||
other.isInvisible == isInvisible) &&
(identical(other.clearAt, clearAt) || other.clearAt == clearAt) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
type,
label,
attitude,
isNoDisturb,
isInvisible,
clearAt,
accountId);
@override
String toString() {
return 'SnAccountStatus(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, label: $label, attitude: $attitude, isNoDisturb: $isNoDisturb, isInvisible: $isInvisible, clearAt: $clearAt, accountId: $accountId)';
}
}
/// @nodoc
abstract mixin class _$SnAccountStatusCopyWith<$Res>
implements $SnAccountStatusCopyWith<$Res> {
factory _$SnAccountStatusCopyWith(
_SnAccountStatus value, $Res Function(_SnAccountStatus) _then) =
__$SnAccountStatusCopyWithImpl;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String type,
String label,
int attitude,
bool isNoDisturb,
bool isInvisible,
DateTime? clearAt,
int accountId});
}
/// @nodoc
class __$SnAccountStatusCopyWithImpl<$Res>
implements _$SnAccountStatusCopyWith<$Res> {
__$SnAccountStatusCopyWithImpl(this._self, this._then);
final _SnAccountStatus _self;
final $Res Function(_SnAccountStatus) _then;
/// Create a copy of SnAccountStatus
/// 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? type = null,
Object? label = null,
Object? attitude = null,
Object? isNoDisturb = null,
Object? isInvisible = null,
Object? clearAt = freezed,
Object? accountId = null,
}) {
return _then(_SnAccountStatus(
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?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as String,
label: null == label
? _self.label
: label // ignore: cast_nullable_to_non_nullable
as String,
attitude: null == attitude
? _self.attitude
: attitude // ignore: cast_nullable_to_non_nullable
as int,
isNoDisturb: null == isNoDisturb
? _self.isNoDisturb
: isNoDisturb // ignore: cast_nullable_to_non_nullable
as bool,
isInvisible: null == isInvisible
? _self.isInvisible
: isInvisible // ignore: cast_nullable_to_non_nullable
as bool,
clearAt: freezed == clearAt
? _self.clearAt
: clearAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
)); ));
} }
} }
@ -2629,4 +3027,447 @@ class __$SnAbuseReportCopyWithImpl<$Res>
} }
} }
/// @nodoc
mixin _$SnActionEvent {
int get id;
DateTime get createdAt;
DateTime get updatedAt;
DateTime? get deletedAt;
String get type;
Map<String, dynamic>? get metadata;
String? get location;
double? get coordinateX;
double? get coordinateY;
String get ipAddress;
String get userAgent;
SnAccount get account;
int get accountId;
/// Create a copy of SnActionEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnActionEventCopyWith<SnActionEvent> get copyWith =>
_$SnActionEventCopyWithImpl<SnActionEvent>(
this as SnActionEvent, _$identity);
/// Serializes this SnActionEvent to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is SnActionEvent &&
(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.type, type) || other.type == type) &&
const DeepCollectionEquality().equals(other.metadata, metadata) &&
(identical(other.location, location) ||
other.location == location) &&
(identical(other.coordinateX, coordinateX) ||
other.coordinateX == coordinateX) &&
(identical(other.coordinateY, coordinateY) ||
other.coordinateY == coordinateY) &&
(identical(other.ipAddress, ipAddress) ||
other.ipAddress == ipAddress) &&
(identical(other.userAgent, userAgent) ||
other.userAgent == userAgent) &&
(identical(other.account, account) || other.account == account) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
type,
const DeepCollectionEquality().hash(metadata),
location,
coordinateX,
coordinateY,
ipAddress,
userAgent,
account,
accountId);
@override
String toString() {
return 'SnActionEvent(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, metadata: $metadata, location: $location, coordinateX: $coordinateX, coordinateY: $coordinateY, ipAddress: $ipAddress, userAgent: $userAgent, account: $account, accountId: $accountId)';
}
}
/// @nodoc
abstract mixin class $SnActionEventCopyWith<$Res> {
factory $SnActionEventCopyWith(
SnActionEvent value, $Res Function(SnActionEvent) _then) =
_$SnActionEventCopyWithImpl;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String type,
Map<String, dynamic>? metadata,
String? location,
double? coordinateX,
double? coordinateY,
String ipAddress,
String userAgent,
SnAccount account,
int accountId});
$SnAccountCopyWith<$Res> get account;
}
/// @nodoc
class _$SnActionEventCopyWithImpl<$Res>
implements $SnActionEventCopyWith<$Res> {
_$SnActionEventCopyWithImpl(this._self, this._then);
final SnActionEvent _self;
final $Res Function(SnActionEvent) _then;
/// Create a copy of SnActionEvent
/// 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? type = null,
Object? metadata = freezed,
Object? location = freezed,
Object? coordinateX = freezed,
Object? coordinateY = freezed,
Object? ipAddress = null,
Object? userAgent = null,
Object? account = null,
Object? accountId = null,
}) {
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?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as String,
metadata: freezed == metadata
? _self.metadata
: metadata // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
location: freezed == location
? _self.location
: location // ignore: cast_nullable_to_non_nullable
as String?,
coordinateX: freezed == coordinateX
? _self.coordinateX
: coordinateX // ignore: cast_nullable_to_non_nullable
as double?,
coordinateY: freezed == coordinateY
? _self.coordinateY
: coordinateY // ignore: cast_nullable_to_non_nullable
as double?,
ipAddress: null == ipAddress
? _self.ipAddress
: ipAddress // ignore: cast_nullable_to_non_nullable
as String,
userAgent: null == userAgent
? _self.userAgent
: userAgent // ignore: cast_nullable_to_non_nullable
as String,
account: null == account
? _self.account
: account // ignore: cast_nullable_to_non_nullable
as SnAccount,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of SnActionEvent
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
return _then(_self.copyWith(account: value));
});
}
}
/// @nodoc
@JsonSerializable()
class _SnActionEvent implements SnActionEvent {
const _SnActionEvent(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.type,
required final Map<String, dynamic>? metadata,
required this.location,
required this.coordinateX,
required this.coordinateY,
required this.ipAddress,
required this.userAgent,
required this.account,
required this.accountId})
: _metadata = metadata;
factory _SnActionEvent.fromJson(Map<String, dynamic> json) =>
_$SnActionEventFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String type;
final Map<String, dynamic>? _metadata;
@override
Map<String, dynamic>? get metadata {
final value = _metadata;
if (value == null) return null;
if (_metadata is EqualUnmodifiableMapView) return _metadata;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(value);
}
@override
final String? location;
@override
final double? coordinateX;
@override
final double? coordinateY;
@override
final String ipAddress;
@override
final String userAgent;
@override
final SnAccount account;
@override
final int accountId;
/// Create a copy of SnActionEvent
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnActionEventCopyWith<_SnActionEvent> get copyWith =>
__$SnActionEventCopyWithImpl<_SnActionEvent>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnActionEventToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _SnActionEvent &&
(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.type, type) || other.type == type) &&
const DeepCollectionEquality().equals(other._metadata, _metadata) &&
(identical(other.location, location) ||
other.location == location) &&
(identical(other.coordinateX, coordinateX) ||
other.coordinateX == coordinateX) &&
(identical(other.coordinateY, coordinateY) ||
other.coordinateY == coordinateY) &&
(identical(other.ipAddress, ipAddress) ||
other.ipAddress == ipAddress) &&
(identical(other.userAgent, userAgent) ||
other.userAgent == userAgent) &&
(identical(other.account, account) || other.account == account) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
type,
const DeepCollectionEquality().hash(_metadata),
location,
coordinateX,
coordinateY,
ipAddress,
userAgent,
account,
accountId);
@override
String toString() {
return 'SnActionEvent(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, metadata: $metadata, location: $location, coordinateX: $coordinateX, coordinateY: $coordinateY, ipAddress: $ipAddress, userAgent: $userAgent, account: $account, accountId: $accountId)';
}
}
/// @nodoc
abstract mixin class _$SnActionEventCopyWith<$Res>
implements $SnActionEventCopyWith<$Res> {
factory _$SnActionEventCopyWith(
_SnActionEvent value, $Res Function(_SnActionEvent) _then) =
__$SnActionEventCopyWithImpl;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String type,
Map<String, dynamic>? metadata,
String? location,
double? coordinateX,
double? coordinateY,
String ipAddress,
String userAgent,
SnAccount account,
int accountId});
@override
$SnAccountCopyWith<$Res> get account;
}
/// @nodoc
class __$SnActionEventCopyWithImpl<$Res>
implements _$SnActionEventCopyWith<$Res> {
__$SnActionEventCopyWithImpl(this._self, this._then);
final _SnActionEvent _self;
final $Res Function(_SnActionEvent) _then;
/// Create a copy of SnActionEvent
/// 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? type = null,
Object? metadata = freezed,
Object? location = freezed,
Object? coordinateX = freezed,
Object? coordinateY = freezed,
Object? ipAddress = null,
Object? userAgent = null,
Object? account = null,
Object? accountId = null,
}) {
return _then(_SnActionEvent(
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?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as String,
metadata: freezed == metadata
? _self._metadata
: metadata // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
location: freezed == location
? _self.location
: location // ignore: cast_nullable_to_non_nullable
as String?,
coordinateX: freezed == coordinateX
? _self.coordinateX
: coordinateX // ignore: cast_nullable_to_non_nullable
as double?,
coordinateY: freezed == coordinateY
? _self.coordinateY
: coordinateY // ignore: cast_nullable_to_non_nullable
as double?,
ipAddress: null == ipAddress
? _self.ipAddress
: ipAddress // ignore: cast_nullable_to_non_nullable
as String,
userAgent: null == userAgent
? _self.userAgent
: userAgent // ignore: cast_nullable_to_non_nullable
as String,
account: null == account
? _self.account
: account // ignore: cast_nullable_to_non_nullable
as SnAccount,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of SnActionEvent
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
return _then(_self.copyWith(account: value));
});
}
}
// dart format on // dart format on

View File

@ -210,7 +210,9 @@ _SnAccountStatusInfo _$SnAccountStatusInfoFromJson(Map<String, dynamic> json) =>
lastSeenAt: json['last_seen_at'] == null lastSeenAt: json['last_seen_at'] == null
? null ? null
: DateTime.parse(json['last_seen_at'] as String), : 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> _$SnAccountStatusInfoToJson( Map<String, dynamic> _$SnAccountStatusInfoToJson(
@ -219,7 +221,41 @@ Map<String, dynamic> _$SnAccountStatusInfoToJson(
'is_disturbable': instance.isDisturbable, 'is_disturbable': instance.isDisturbable,
'is_online': instance.isOnline, 'is_online': instance.isOnline,
'last_seen_at': instance.lastSeenAt?.toIso8601String(), 'last_seen_at': instance.lastSeenAt?.toIso8601String(),
'status': instance.status, 'status': instance.status?.toJson(),
};
_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 _$SnAbuseReportFromJson(Map<String, dynamic> json) =>
@ -247,3 +283,39 @@ Map<String, dynamic> _$SnAbuseReportToJson(_SnAbuseReport instance) =>
'status': instance.status, 'status': instance.status,
'account_id': instance.accountId, '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

@ -26,7 +26,9 @@ abstract class SnAuthTicket with _$SnAuthTicket {
required String? accessToken, required String? accessToken,
required String? refreshToken, required String? refreshToken,
required String ipAddress, required String ipAddress,
required String location, required String? location,
required double? coordinateX,
required double? coordinateY,
required String userAgent, required String userAgent,
required DateTime? expiredAt, required DateTime? expiredAt,
required DateTime? lastGrantAt, required DateTime? lastGrantAt,

View File

@ -217,7 +217,9 @@ mixin _$SnAuthTicket {
String? get accessToken; String? get accessToken;
String? get refreshToken; String? get refreshToken;
String get ipAddress; String get ipAddress;
String get location; String? get location;
double? get coordinateX;
double? get coordinateY;
String get userAgent; String get userAgent;
DateTime? get expiredAt; DateTime? get expiredAt;
DateTime? get lastGrantAt; DateTime? get lastGrantAt;
@ -261,6 +263,10 @@ mixin _$SnAuthTicket {
other.ipAddress == ipAddress) && other.ipAddress == ipAddress) &&
(identical(other.location, location) || (identical(other.location, location) ||
other.location == location) && other.location == location) &&
(identical(other.coordinateX, coordinateX) ||
other.coordinateX == coordinateX) &&
(identical(other.coordinateY, coordinateY) ||
other.coordinateY == coordinateY) &&
(identical(other.userAgent, userAgent) || (identical(other.userAgent, userAgent) ||
other.userAgent == userAgent) && other.userAgent == userAgent) &&
(identical(other.expiredAt, expiredAt) || (identical(other.expiredAt, expiredAt) ||
@ -278,29 +284,32 @@ mixin _$SnAuthTicket {
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash( int get hashCode => Object.hashAll([
runtimeType, runtimeType,
id, id,
createdAt, createdAt,
updatedAt, updatedAt,
deletedAt, deletedAt,
stepRemain, stepRemain,
grantToken, grantToken,
accessToken, accessToken,
refreshToken, refreshToken,
ipAddress, ipAddress,
location, location,
userAgent, coordinateX,
expiredAt, coordinateY,
lastGrantAt, userAgent,
availableAt, expiredAt,
nonce, lastGrantAt,
accountId, availableAt,
const DeepCollectionEquality().hash(factorTrail)); nonce,
accountId,
const DeepCollectionEquality().hash(factorTrail)
]);
@override @override
String toString() { String toString() {
return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)'; return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, coordinateX: $coordinateX, coordinateY: $coordinateY, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)';
} }
} }
@ -320,7 +329,9 @@ abstract mixin class $SnAuthTicketCopyWith<$Res> {
String? accessToken, String? accessToken,
String? refreshToken, String? refreshToken,
String ipAddress, String ipAddress,
String location, String? location,
double? coordinateX,
double? coordinateY,
String userAgent, String userAgent,
DateTime? expiredAt, DateTime? expiredAt,
DateTime? lastGrantAt, DateTime? lastGrantAt,
@ -351,7 +362,9 @@ class _$SnAuthTicketCopyWithImpl<$Res> implements $SnAuthTicketCopyWith<$Res> {
Object? accessToken = freezed, Object? accessToken = freezed,
Object? refreshToken = freezed, Object? refreshToken = freezed,
Object? ipAddress = null, Object? ipAddress = null,
Object? location = null, Object? location = freezed,
Object? coordinateX = freezed,
Object? coordinateY = freezed,
Object? userAgent = null, Object? userAgent = null,
Object? expiredAt = freezed, Object? expiredAt = freezed,
Object? lastGrantAt = freezed, Object? lastGrantAt = freezed,
@ -397,10 +410,18 @@ class _$SnAuthTicketCopyWithImpl<$Res> implements $SnAuthTicketCopyWith<$Res> {
? _self.ipAddress ? _self.ipAddress
: ipAddress // ignore: cast_nullable_to_non_nullable : ipAddress // ignore: cast_nullable_to_non_nullable
as String, as String,
location: null == location location: freezed == location
? _self.location ? _self.location
: location // ignore: cast_nullable_to_non_nullable : location // ignore: cast_nullable_to_non_nullable
as String, as String?,
coordinateX: freezed == coordinateX
? _self.coordinateX
: coordinateX // ignore: cast_nullable_to_non_nullable
as double?,
coordinateY: freezed == coordinateY
? _self.coordinateY
: coordinateY // ignore: cast_nullable_to_non_nullable
as double?,
userAgent: null == userAgent userAgent: null == userAgent
? _self.userAgent ? _self.userAgent
: userAgent // ignore: cast_nullable_to_non_nullable : userAgent // ignore: cast_nullable_to_non_nullable
@ -447,6 +468,8 @@ class _SnAuthTicket implements SnAuthTicket {
required this.refreshToken, required this.refreshToken,
required this.ipAddress, required this.ipAddress,
required this.location, required this.location,
required this.coordinateX,
required this.coordinateY,
required this.userAgent, required this.userAgent,
required this.expiredAt, required this.expiredAt,
required this.lastGrantAt, required this.lastGrantAt,
@ -477,7 +500,11 @@ class _SnAuthTicket implements SnAuthTicket {
@override @override
final String ipAddress; final String ipAddress;
@override @override
final String location; final String? location;
@override
final double? coordinateX;
@override
final double? coordinateY;
@override @override
final String userAgent; final String userAgent;
@override @override
@ -538,6 +565,10 @@ class _SnAuthTicket implements SnAuthTicket {
other.ipAddress == ipAddress) && other.ipAddress == ipAddress) &&
(identical(other.location, location) || (identical(other.location, location) ||
other.location == location) && other.location == location) &&
(identical(other.coordinateX, coordinateX) ||
other.coordinateX == coordinateX) &&
(identical(other.coordinateY, coordinateY) ||
other.coordinateY == coordinateY) &&
(identical(other.userAgent, userAgent) || (identical(other.userAgent, userAgent) ||
other.userAgent == userAgent) && other.userAgent == userAgent) &&
(identical(other.expiredAt, expiredAt) || (identical(other.expiredAt, expiredAt) ||
@ -555,29 +586,32 @@ class _SnAuthTicket implements SnAuthTicket {
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash( int get hashCode => Object.hashAll([
runtimeType, runtimeType,
id, id,
createdAt, createdAt,
updatedAt, updatedAt,
deletedAt, deletedAt,
stepRemain, stepRemain,
grantToken, grantToken,
accessToken, accessToken,
refreshToken, refreshToken,
ipAddress, ipAddress,
location, location,
userAgent, coordinateX,
expiredAt, coordinateY,
lastGrantAt, userAgent,
availableAt, expiredAt,
nonce, lastGrantAt,
accountId, availableAt,
const DeepCollectionEquality().hash(_factorTrail)); nonce,
accountId,
const DeepCollectionEquality().hash(_factorTrail)
]);
@override @override
String toString() { String toString() {
return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)'; return 'SnAuthTicket(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, stepRemain: $stepRemain, grantToken: $grantToken, accessToken: $accessToken, refreshToken: $refreshToken, ipAddress: $ipAddress, location: $location, coordinateX: $coordinateX, coordinateY: $coordinateY, userAgent: $userAgent, expiredAt: $expiredAt, lastGrantAt: $lastGrantAt, availableAt: $availableAt, nonce: $nonce, accountId: $accountId, factorTrail: $factorTrail)';
} }
} }
@ -599,7 +633,9 @@ abstract mixin class _$SnAuthTicketCopyWith<$Res>
String? accessToken, String? accessToken,
String? refreshToken, String? refreshToken,
String ipAddress, String ipAddress,
String location, String? location,
double? coordinateX,
double? coordinateY,
String userAgent, String userAgent,
DateTime? expiredAt, DateTime? expiredAt,
DateTime? lastGrantAt, DateTime? lastGrantAt,
@ -631,7 +667,9 @@ class __$SnAuthTicketCopyWithImpl<$Res>
Object? accessToken = freezed, Object? accessToken = freezed,
Object? refreshToken = freezed, Object? refreshToken = freezed,
Object? ipAddress = null, Object? ipAddress = null,
Object? location = null, Object? location = freezed,
Object? coordinateX = freezed,
Object? coordinateY = freezed,
Object? userAgent = null, Object? userAgent = null,
Object? expiredAt = freezed, Object? expiredAt = freezed,
Object? lastGrantAt = freezed, Object? lastGrantAt = freezed,
@ -677,10 +715,18 @@ class __$SnAuthTicketCopyWithImpl<$Res>
? _self.ipAddress ? _self.ipAddress
: ipAddress // ignore: cast_nullable_to_non_nullable : ipAddress // ignore: cast_nullable_to_non_nullable
as String, as String,
location: null == location location: freezed == location
? _self.location ? _self.location
: location // ignore: cast_nullable_to_non_nullable : location // ignore: cast_nullable_to_non_nullable
as String, as String?,
coordinateX: freezed == coordinateX
? _self.coordinateX
: coordinateX // ignore: cast_nullable_to_non_nullable
as double?,
coordinateY: freezed == coordinateY
? _self.coordinateY
: coordinateY // ignore: cast_nullable_to_non_nullable
as double?,
userAgent: null == userAgent userAgent: null == userAgent
? _self.userAgent ? _self.userAgent
: userAgent // ignore: cast_nullable_to_non_nullable : userAgent // ignore: cast_nullable_to_non_nullable

View File

@ -33,7 +33,9 @@ _SnAuthTicket _$SnAuthTicketFromJson(Map<String, dynamic> json) =>
accessToken: json['access_token'] as String?, accessToken: json['access_token'] as String?,
refreshToken: json['refresh_token'] as String?, refreshToken: json['refresh_token'] as String?,
ipAddress: json['ip_address'] 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, userAgent: json['user_agent'] as String,
expiredAt: json['expired_at'] == null expiredAt: json['expired_at'] == null
? null ? null
@ -64,6 +66,8 @@ Map<String, dynamic> _$SnAuthTicketToJson(_SnAuthTicket instance) =>
'refresh_token': instance.refreshToken, 'refresh_token': instance.refreshToken,
'ip_address': instance.ipAddress, 'ip_address': instance.ipAddress,
'location': instance.location, 'location': instance.location,
'coordinate_x': instance.coordinateX,
'coordinate_y': instance.coordinateY,
'user_agent': instance.userAgent, 'user_agent': instance.userAgent,
'expired_at': instance.expiredAt?.toIso8601String(), 'expired_at': instance.expiredAt?.toIso8601String(),
'last_grant_at': instance.lastGrantAt?.toIso8601String(), 'last_grant_at': instance.lastGrantAt?.toIso8601String(),

View File

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

View File

@ -22,6 +22,7 @@ mixin _$SnCheckInRecord {
int get resultTier; int get resultTier;
int get resultExperience; int get resultExperience;
double get resultCoin; double get resultCoin;
int get currentStreak;
List<int> get resultModifiers; List<int> get resultModifiers;
int get accountId; int get accountId;
@ -54,6 +55,8 @@ mixin _$SnCheckInRecord {
other.resultExperience == resultExperience) && other.resultExperience == resultExperience) &&
(identical(other.resultCoin, resultCoin) || (identical(other.resultCoin, resultCoin) ||
other.resultCoin == resultCoin) && other.resultCoin == resultCoin) &&
(identical(other.currentStreak, currentStreak) ||
other.currentStreak == currentStreak) &&
const DeepCollectionEquality() const DeepCollectionEquality()
.equals(other.resultModifiers, resultModifiers) && .equals(other.resultModifiers, resultModifiers) &&
(identical(other.accountId, accountId) || (identical(other.accountId, accountId) ||
@ -71,12 +74,13 @@ mixin _$SnCheckInRecord {
resultTier, resultTier,
resultExperience, resultExperience,
resultCoin, resultCoin,
currentStreak,
const DeepCollectionEquality().hash(resultModifiers), const DeepCollectionEquality().hash(resultModifiers),
accountId); accountId);
@override @override
String toString() { String toString() {
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)'; return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, currentStreak: $currentStreak, resultModifiers: $resultModifiers, accountId: $accountId)';
} }
} }
@ -94,6 +98,7 @@ abstract mixin class $SnCheckInRecordCopyWith<$Res> {
int resultTier, int resultTier,
int resultExperience, int resultExperience,
double resultCoin, double resultCoin,
int currentStreak,
List<int> resultModifiers, List<int> resultModifiers,
int accountId}); int accountId});
} }
@ -118,6 +123,7 @@ class _$SnCheckInRecordCopyWithImpl<$Res>
Object? resultTier = null, Object? resultTier = null,
Object? resultExperience = null, Object? resultExperience = null,
Object? resultCoin = null, Object? resultCoin = null,
Object? currentStreak = null,
Object? resultModifiers = null, Object? resultModifiers = null,
Object? accountId = null, Object? accountId = null,
}) { }) {
@ -150,6 +156,10 @@ class _$SnCheckInRecordCopyWithImpl<$Res>
? _self.resultCoin ? _self.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable : resultCoin // ignore: cast_nullable_to_non_nullable
as double, as double,
currentStreak: null == currentStreak
? _self.currentStreak
: currentStreak // ignore: cast_nullable_to_non_nullable
as int,
resultModifiers: null == resultModifiers resultModifiers: null == resultModifiers
? _self.resultModifiers ? _self.resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable : resultModifiers // ignore: cast_nullable_to_non_nullable
@ -173,6 +183,7 @@ class _SnCheckInRecord extends SnCheckInRecord {
required this.resultTier, required this.resultTier,
required this.resultExperience, required this.resultExperience,
required this.resultCoin, required this.resultCoin,
this.currentStreak = 0,
required final List<int> resultModifiers, required final List<int> resultModifiers,
required this.accountId}) required this.accountId})
: _resultModifiers = resultModifiers, : _resultModifiers = resultModifiers,
@ -194,6 +205,9 @@ class _SnCheckInRecord extends SnCheckInRecord {
final int resultExperience; final int resultExperience;
@override @override
final double resultCoin; final double resultCoin;
@override
@JsonKey()
final int currentStreak;
final List<int> _resultModifiers; final List<int> _resultModifiers;
@override @override
List<int> get resultModifiers { List<int> get resultModifiers {
@ -238,6 +252,8 @@ class _SnCheckInRecord extends SnCheckInRecord {
other.resultExperience == resultExperience) && other.resultExperience == resultExperience) &&
(identical(other.resultCoin, resultCoin) || (identical(other.resultCoin, resultCoin) ||
other.resultCoin == resultCoin) && other.resultCoin == resultCoin) &&
(identical(other.currentStreak, currentStreak) ||
other.currentStreak == currentStreak) &&
const DeepCollectionEquality() const DeepCollectionEquality()
.equals(other._resultModifiers, _resultModifiers) && .equals(other._resultModifiers, _resultModifiers) &&
(identical(other.accountId, accountId) || (identical(other.accountId, accountId) ||
@ -255,12 +271,13 @@ class _SnCheckInRecord extends SnCheckInRecord {
resultTier, resultTier,
resultExperience, resultExperience,
resultCoin, resultCoin,
currentStreak,
const DeepCollectionEquality().hash(_resultModifiers), const DeepCollectionEquality().hash(_resultModifiers),
accountId); accountId);
@override @override
String toString() { String toString() {
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)'; return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, currentStreak: $currentStreak, resultModifiers: $resultModifiers, accountId: $accountId)';
} }
} }
@ -280,6 +297,7 @@ abstract mixin class _$SnCheckInRecordCopyWith<$Res>
int resultTier, int resultTier,
int resultExperience, int resultExperience,
double resultCoin, double resultCoin,
int currentStreak,
List<int> resultModifiers, List<int> resultModifiers,
int accountId}); int accountId});
} }
@ -304,6 +322,7 @@ class __$SnCheckInRecordCopyWithImpl<$Res>
Object? resultTier = null, Object? resultTier = null,
Object? resultExperience = null, Object? resultExperience = null,
Object? resultCoin = null, Object? resultCoin = null,
Object? currentStreak = null,
Object? resultModifiers = null, Object? resultModifiers = null,
Object? accountId = null, Object? accountId = null,
}) { }) {
@ -336,6 +355,10 @@ class __$SnCheckInRecordCopyWithImpl<$Res>
? _self.resultCoin ? _self.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable : resultCoin // ignore: cast_nullable_to_non_nullable
as double, as double,
currentStreak: null == currentStreak
? _self.currentStreak
: currentStreak // ignore: cast_nullable_to_non_nullable
as int,
resultModifiers: null == resultModifiers resultModifiers: null == resultModifiers
? _self._resultModifiers ? _self._resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable : resultModifiers // ignore: cast_nullable_to_non_nullable

View File

@ -17,6 +17,7 @@ _SnCheckInRecord _$SnCheckInRecordFromJson(Map<String, dynamic> json) =>
resultTier: (json['result_tier'] as num).toInt(), resultTier: (json['result_tier'] as num).toInt(),
resultExperience: (json['result_experience'] as num).toInt(), resultExperience: (json['result_experience'] as num).toInt(),
resultCoin: (json['result_coin'] as num).toDouble(), resultCoin: (json['result_coin'] as num).toDouble(),
currentStreak: (json['current_streak'] as num?)?.toInt() ?? 0,
resultModifiers: (json['result_modifiers'] as List<dynamic>) resultModifiers: (json['result_modifiers'] as List<dynamic>)
.map((e) => (e as num).toInt()) .map((e) => (e as num).toInt())
.toList(), .toList(),
@ -32,6 +33,7 @@ Map<String, dynamic> _$SnCheckInRecordToJson(_SnCheckInRecord instance) =>
'result_tier': instance.resultTier, 'result_tier': instance.resultTier,
'result_experience': instance.resultExperience, 'result_experience': instance.resultExperience,
'result_coin': instance.resultCoin, 'result_coin': instance.resultCoin,
'current_streak': instance.currentStreak,
'result_modifiers': instance.resultModifiers, 'result_modifiers': instance.resultModifiers,
'account_id': instance.accountId, 'account_id': instance.accountId,
}; };

View File

@ -166,3 +166,53 @@ abstract class SnSubscription with _$SnSubscription {
factory SnSubscription.fromJson(Map<String, Object?> json) => factory SnSubscription.fromJson(Map<String, Object?> json) =>
_$SnSubscriptionFromJson(json); _$SnSubscriptionFromJson(json);
} }
@freezed
abstract class SnFeedEntry with _$SnFeedEntry {
const factory SnFeedEntry({
required String type,
required dynamic data,
required DateTime createdAt,
}) = _SnFeedEntry;
factory SnFeedEntry.fromJson(Map<String, dynamic> json) =>
_$SnFeedEntryFromJson(json);
}
@freezed
abstract class SnFediversePost with _$SnFediversePost {
const factory SnFediversePost({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String identifier,
required String origin,
required String content,
required String language,
required List<String> images,
required SnFediverseUser user,
required int userId,
}) = _SnFediversePost;
factory SnFediversePost.fromJson(Map<String, Object?> json) =>
_$SnFediversePostFromJson(json);
}
@freezed
abstract class SnFediverseUser with _$SnFediverseUser {
const factory SnFediverseUser({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String identifier,
required String origin,
required String avatar,
required String name,
required String nick,
}) = _SnFediverseUser;
factory SnFediverseUser.fromJson(Map<String, dynamic> json) =>
_$SnFediverseUserFromJson(json);
}

View File

@ -3120,4 +3120,874 @@ class __$SnSubscriptionCopyWithImpl<$Res>
} }
} }
/// @nodoc
mixin _$SnFeedEntry {
String get type;
dynamic get data;
DateTime get createdAt;
/// Create a copy of SnFeedEntry
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnFeedEntryCopyWith<SnFeedEntry> get copyWith =>
_$SnFeedEntryCopyWithImpl<SnFeedEntry>(this as SnFeedEntry, _$identity);
/// Serializes this SnFeedEntry to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is SnFeedEntry &&
(identical(other.type, type) || other.type == type) &&
const DeepCollectionEquality().equals(other.data, data) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType, type, const DeepCollectionEquality().hash(data), createdAt);
@override
String toString() {
return 'SnFeedEntry(type: $type, data: $data, createdAt: $createdAt)';
}
}
/// @nodoc
abstract mixin class $SnFeedEntryCopyWith<$Res> {
factory $SnFeedEntryCopyWith(
SnFeedEntry value, $Res Function(SnFeedEntry) _then) =
_$SnFeedEntryCopyWithImpl;
@useResult
$Res call({String type, dynamic data, DateTime createdAt});
}
/// @nodoc
class _$SnFeedEntryCopyWithImpl<$Res> implements $SnFeedEntryCopyWith<$Res> {
_$SnFeedEntryCopyWithImpl(this._self, this._then);
final SnFeedEntry _self;
final $Res Function(SnFeedEntry) _then;
/// Create a copy of SnFeedEntry
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? type = null,
Object? data = freezed,
Object? createdAt = null,
}) {
return _then(_self.copyWith(
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as String,
data: freezed == data
? _self.data
: data // ignore: cast_nullable_to_non_nullable
as dynamic,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
));
}
}
/// @nodoc
@JsonSerializable()
class _SnFeedEntry implements SnFeedEntry {
const _SnFeedEntry(
{required this.type, required this.data, required this.createdAt});
factory _SnFeedEntry.fromJson(Map<String, dynamic> json) =>
_$SnFeedEntryFromJson(json);
@override
final String type;
@override
final dynamic data;
@override
final DateTime createdAt;
/// Create a copy of SnFeedEntry
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnFeedEntryCopyWith<_SnFeedEntry> get copyWith =>
__$SnFeedEntryCopyWithImpl<_SnFeedEntry>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnFeedEntryToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _SnFeedEntry &&
(identical(other.type, type) || other.type == type) &&
const DeepCollectionEquality().equals(other.data, data) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType, type, const DeepCollectionEquality().hash(data), createdAt);
@override
String toString() {
return 'SnFeedEntry(type: $type, data: $data, createdAt: $createdAt)';
}
}
/// @nodoc
abstract mixin class _$SnFeedEntryCopyWith<$Res>
implements $SnFeedEntryCopyWith<$Res> {
factory _$SnFeedEntryCopyWith(
_SnFeedEntry value, $Res Function(_SnFeedEntry) _then) =
__$SnFeedEntryCopyWithImpl;
@override
@useResult
$Res call({String type, dynamic data, DateTime createdAt});
}
/// @nodoc
class __$SnFeedEntryCopyWithImpl<$Res> implements _$SnFeedEntryCopyWith<$Res> {
__$SnFeedEntryCopyWithImpl(this._self, this._then);
final _SnFeedEntry _self;
final $Res Function(_SnFeedEntry) _then;
/// Create a copy of SnFeedEntry
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$Res call({
Object? type = null,
Object? data = freezed,
Object? createdAt = null,
}) {
return _then(_SnFeedEntry(
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as String,
data: freezed == data
? _self.data
: data // ignore: cast_nullable_to_non_nullable
as dynamic,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
));
}
}
/// @nodoc
mixin _$SnFediversePost {
int get id;
DateTime get createdAt;
DateTime get updatedAt;
DateTime? get deletedAt;
String get identifier;
String get origin;
String get content;
String get language;
List<String> get images;
SnFediverseUser get user;
int get userId;
/// Create a copy of SnFediversePost
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnFediversePostCopyWith<SnFediversePost> get copyWith =>
_$SnFediversePostCopyWithImpl<SnFediversePost>(
this as SnFediversePost, _$identity);
/// Serializes this SnFediversePost to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is SnFediversePost &&
(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.identifier, identifier) ||
other.identifier == identifier) &&
(identical(other.origin, origin) || other.origin == origin) &&
(identical(other.content, content) || other.content == content) &&
(identical(other.language, language) ||
other.language == language) &&
const DeepCollectionEquality().equals(other.images, images) &&
(identical(other.user, user) || other.user == user) &&
(identical(other.userId, userId) || other.userId == userId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
identifier,
origin,
content,
language,
const DeepCollectionEquality().hash(images),
user,
userId);
@override
String toString() {
return 'SnFediversePost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, identifier: $identifier, origin: $origin, content: $content, language: $language, images: $images, user: $user, userId: $userId)';
}
}
/// @nodoc
abstract mixin class $SnFediversePostCopyWith<$Res> {
factory $SnFediversePostCopyWith(
SnFediversePost value, $Res Function(SnFediversePost) _then) =
_$SnFediversePostCopyWithImpl;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String identifier,
String origin,
String content,
String language,
List<String> images,
SnFediverseUser user,
int userId});
$SnFediverseUserCopyWith<$Res> get user;
}
/// @nodoc
class _$SnFediversePostCopyWithImpl<$Res>
implements $SnFediversePostCopyWith<$Res> {
_$SnFediversePostCopyWithImpl(this._self, this._then);
final SnFediversePost _self;
final $Res Function(SnFediversePost) _then;
/// Create a copy of SnFediversePost
/// 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? identifier = null,
Object? origin = null,
Object? content = null,
Object? language = null,
Object? images = null,
Object? user = null,
Object? userId = null,
}) {
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?,
identifier: null == identifier
? _self.identifier
: identifier // ignore: cast_nullable_to_non_nullable
as String,
origin: null == origin
? _self.origin
: origin // ignore: cast_nullable_to_non_nullable
as String,
content: null == content
? _self.content
: content // ignore: cast_nullable_to_non_nullable
as String,
language: null == language
? _self.language
: language // ignore: cast_nullable_to_non_nullable
as String,
images: null == images
? _self.images
: images // ignore: cast_nullable_to_non_nullable
as List<String>,
user: null == user
? _self.user
: user // ignore: cast_nullable_to_non_nullable
as SnFediverseUser,
userId: null == userId
? _self.userId
: userId // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of SnFediversePost
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnFediverseUserCopyWith<$Res> get user {
return $SnFediverseUserCopyWith<$Res>(_self.user, (value) {
return _then(_self.copyWith(user: value));
});
}
}
/// @nodoc
@JsonSerializable()
class _SnFediversePost implements SnFediversePost {
const _SnFediversePost(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.identifier,
required this.origin,
required this.content,
required this.language,
required final List<String> images,
required this.user,
required this.userId})
: _images = images;
factory _SnFediversePost.fromJson(Map<String, dynamic> json) =>
_$SnFediversePostFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String identifier;
@override
final String origin;
@override
final String content;
@override
final String language;
final List<String> _images;
@override
List<String> get images {
if (_images is EqualUnmodifiableListView) return _images;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_images);
}
@override
final SnFediverseUser user;
@override
final int userId;
/// Create a copy of SnFediversePost
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnFediversePostCopyWith<_SnFediversePost> get copyWith =>
__$SnFediversePostCopyWithImpl<_SnFediversePost>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnFediversePostToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _SnFediversePost &&
(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.identifier, identifier) ||
other.identifier == identifier) &&
(identical(other.origin, origin) || other.origin == origin) &&
(identical(other.content, content) || other.content == content) &&
(identical(other.language, language) ||
other.language == language) &&
const DeepCollectionEquality().equals(other._images, _images) &&
(identical(other.user, user) || other.user == user) &&
(identical(other.userId, userId) || other.userId == userId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
identifier,
origin,
content,
language,
const DeepCollectionEquality().hash(_images),
user,
userId);
@override
String toString() {
return 'SnFediversePost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, identifier: $identifier, origin: $origin, content: $content, language: $language, images: $images, user: $user, userId: $userId)';
}
}
/// @nodoc
abstract mixin class _$SnFediversePostCopyWith<$Res>
implements $SnFediversePostCopyWith<$Res> {
factory _$SnFediversePostCopyWith(
_SnFediversePost value, $Res Function(_SnFediversePost) _then) =
__$SnFediversePostCopyWithImpl;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String identifier,
String origin,
String content,
String language,
List<String> images,
SnFediverseUser user,
int userId});
@override
$SnFediverseUserCopyWith<$Res> get user;
}
/// @nodoc
class __$SnFediversePostCopyWithImpl<$Res>
implements _$SnFediversePostCopyWith<$Res> {
__$SnFediversePostCopyWithImpl(this._self, this._then);
final _SnFediversePost _self;
final $Res Function(_SnFediversePost) _then;
/// Create a copy of SnFediversePost
/// 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? identifier = null,
Object? origin = null,
Object? content = null,
Object? language = null,
Object? images = null,
Object? user = null,
Object? userId = null,
}) {
return _then(_SnFediversePost(
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?,
identifier: null == identifier
? _self.identifier
: identifier // ignore: cast_nullable_to_non_nullable
as String,
origin: null == origin
? _self.origin
: origin // ignore: cast_nullable_to_non_nullable
as String,
content: null == content
? _self.content
: content // ignore: cast_nullable_to_non_nullable
as String,
language: null == language
? _self.language
: language // ignore: cast_nullable_to_non_nullable
as String,
images: null == images
? _self._images
: images // ignore: cast_nullable_to_non_nullable
as List<String>,
user: null == user
? _self.user
: user // ignore: cast_nullable_to_non_nullable
as SnFediverseUser,
userId: null == userId
? _self.userId
: userId // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of SnFediversePost
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnFediverseUserCopyWith<$Res> get user {
return $SnFediverseUserCopyWith<$Res>(_self.user, (value) {
return _then(_self.copyWith(user: value));
});
}
}
/// @nodoc
mixin _$SnFediverseUser {
int get id;
DateTime get createdAt;
DateTime get updatedAt;
DateTime? get deletedAt;
String get identifier;
String get origin;
String get avatar;
String get name;
String get nick;
/// Create a copy of SnFediverseUser
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnFediverseUserCopyWith<SnFediverseUser> get copyWith =>
_$SnFediverseUserCopyWithImpl<SnFediverseUser>(
this as SnFediverseUser, _$identity);
/// Serializes this SnFediverseUser to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is SnFediverseUser &&
(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.identifier, identifier) ||
other.identifier == identifier) &&
(identical(other.origin, origin) || other.origin == origin) &&
(identical(other.avatar, avatar) || other.avatar == avatar) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.nick, nick) || other.nick == nick));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, identifier, origin, avatar, name, nick);
@override
String toString() {
return 'SnFediverseUser(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, identifier: $identifier, origin: $origin, avatar: $avatar, name: $name, nick: $nick)';
}
}
/// @nodoc
abstract mixin class $SnFediverseUserCopyWith<$Res> {
factory $SnFediverseUserCopyWith(
SnFediverseUser value, $Res Function(SnFediverseUser) _then) =
_$SnFediverseUserCopyWithImpl;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String identifier,
String origin,
String avatar,
String name,
String nick});
}
/// @nodoc
class _$SnFediverseUserCopyWithImpl<$Res>
implements $SnFediverseUserCopyWith<$Res> {
_$SnFediverseUserCopyWithImpl(this._self, this._then);
final SnFediverseUser _self;
final $Res Function(SnFediverseUser) _then;
/// Create a copy of SnFediverseUser
/// 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? identifier = null,
Object? origin = null,
Object? avatar = null,
Object? name = null,
Object? nick = null,
}) {
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?,
identifier: null == identifier
? _self.identifier
: identifier // ignore: cast_nullable_to_non_nullable
as String,
origin: null == origin
? _self.origin
: origin // ignore: cast_nullable_to_non_nullable
as String,
avatar: null == avatar
? _self.avatar
: avatar // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _self.name
: name // ignore: cast_nullable_to_non_nullable
as String,
nick: null == nick
? _self.nick
: nick // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _SnFediverseUser implements SnFediverseUser {
const _SnFediverseUser(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.identifier,
required this.origin,
required this.avatar,
required this.name,
required this.nick});
factory _SnFediverseUser.fromJson(Map<String, dynamic> json) =>
_$SnFediverseUserFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String identifier;
@override
final String origin;
@override
final String avatar;
@override
final String name;
@override
final String nick;
/// Create a copy of SnFediverseUser
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnFediverseUserCopyWith<_SnFediverseUser> get copyWith =>
__$SnFediverseUserCopyWithImpl<_SnFediverseUser>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnFediverseUserToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _SnFediverseUser &&
(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.identifier, identifier) ||
other.identifier == identifier) &&
(identical(other.origin, origin) || other.origin == origin) &&
(identical(other.avatar, avatar) || other.avatar == avatar) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.nick, nick) || other.nick == nick));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, identifier, origin, avatar, name, nick);
@override
String toString() {
return 'SnFediverseUser(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, identifier: $identifier, origin: $origin, avatar: $avatar, name: $name, nick: $nick)';
}
}
/// @nodoc
abstract mixin class _$SnFediverseUserCopyWith<$Res>
implements $SnFediverseUserCopyWith<$Res> {
factory _$SnFediverseUserCopyWith(
_SnFediverseUser value, $Res Function(_SnFediverseUser) _then) =
__$SnFediverseUserCopyWithImpl;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String identifier,
String origin,
String avatar,
String name,
String nick});
}
/// @nodoc
class __$SnFediverseUserCopyWithImpl<$Res>
implements _$SnFediverseUserCopyWith<$Res> {
__$SnFediverseUserCopyWithImpl(this._self, this._then);
final _SnFediverseUser _self;
final $Res Function(_SnFediverseUser) _then;
/// Create a copy of SnFediverseUser
/// 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? identifier = null,
Object? origin = null,
Object? avatar = null,
Object? name = null,
Object? nick = null,
}) {
return _then(_SnFediverseUser(
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?,
identifier: null == identifier
? _self.identifier
: identifier // ignore: cast_nullable_to_non_nullable
as String,
origin: null == origin
? _self.origin
: origin // ignore: cast_nullable_to_non_nullable
as String,
avatar: null == avatar
? _self.avatar
: avatar // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _self.name
: name // ignore: cast_nullable_to_non_nullable
as String,
nick: null == nick
? _self.nick
: nick // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on // dart format on

View File

@ -282,3 +282,77 @@ Map<String, dynamic> _$SnSubscriptionToJson(_SnSubscription instance) =>
'follower_id': instance.followerId, 'follower_id': instance.followerId,
'account_id': instance.accountId, 'account_id': instance.accountId,
}; };
_SnFeedEntry _$SnFeedEntryFromJson(Map<String, dynamic> json) => _SnFeedEntry(
type: json['type'] as String,
data: json['data'],
createdAt: DateTime.parse(json['created_at'] as String),
);
Map<String, dynamic> _$SnFeedEntryToJson(_SnFeedEntry instance) =>
<String, dynamic>{
'type': instance.type,
'data': instance.data,
'created_at': instance.createdAt.toIso8601String(),
};
_SnFediversePost _$SnFediversePostFromJson(Map<String, dynamic> json) =>
_SnFediversePost(
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),
identifier: json['identifier'] as String,
origin: json['origin'] as String,
content: json['content'] as String,
language: json['language'] as String,
images:
(json['images'] as List<dynamic>).map((e) => e as String).toList(),
user: SnFediverseUser.fromJson(json['user'] as Map<String, dynamic>),
userId: (json['user_id'] as num).toInt(),
);
Map<String, dynamic> _$SnFediversePostToJson(_SnFediversePost instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'identifier': instance.identifier,
'origin': instance.origin,
'content': instance.content,
'language': instance.language,
'images': instance.images,
'user': instance.user.toJson(),
'user_id': instance.userId,
};
_SnFediverseUser _$SnFediverseUserFromJson(Map<String, dynamic> json) =>
_SnFediverseUser(
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),
identifier: json['identifier'] as String,
origin: json['origin'] as String,
avatar: json['avatar'] as String,
name: json['name'] as String,
nick: json['nick'] as String,
);
Map<String, dynamic> _$SnFediverseUserToJson(_SnFediverseUser instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'identifier': instance.identifier,
'origin': instance.origin,
'avatar': instance.avatar,
'name': instance.name,
'nick': instance.nick,
};

View File

@ -13,6 +13,8 @@ class AccountImage extends StatelessWidget {
final double? borderRadius; final double? borderRadius;
final Widget? fallbackWidget; final Widget? fallbackWidget;
final Widget? badge; final Widget? badge;
final Offset? badgeOffset;
final FilterQuality? filterQuality;
const AccountImage({ const AccountImage({
super.key, super.key,
@ -23,6 +25,8 @@ class AccountImage extends StatelessWidget {
this.borderRadius, this.borderRadius,
this.fallbackWidget, this.fallbackWidget,
this.badge, this.badge,
this.badgeOffset,
this.filterQuality,
}); });
@override @override
@ -40,7 +44,8 @@ class AccountImage extends StatelessWidget {
borderRadius: BorderRadius.circular(borderRadius ?? radius ?? 20), borderRadius: BorderRadius.circular(borderRadius ?? radius ?? 20),
child: (content?.isEmpty ?? true) child: (content?.isEmpty ?? true)
? Container( ? Container(
color: backgroundColor ?? Theme.of(context).colorScheme.primaryContainer, color: backgroundColor ??
Theme.of(context).colorScheme.primaryContainer,
child: (fallbackWidget ?? child: (fallbackWidget ??
Icon( Icon(
Symbols.account_circle, Symbols.account_circle,
@ -51,6 +56,7 @@ class AccountImage extends StatelessWidget {
) )
: AutoResizeUniversalImage( : AutoResizeUniversalImage(
sn.getAttachmentUrl(url), sn.getAttachmentUrl(url),
filterQuality: filterQuality,
key: Key('attachment-${content.hashCode}'), key: Key('attachment-${content.hashCode}'),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
@ -58,8 +64,8 @@ class AccountImage extends StatelessWidget {
), ),
if (badge != null) if (badge != null)
Positioned( Positioned(
right: -4, right: badgeOffset?.dx ?? -4,
bottom: -2, bottom: badgeOffset?.dy ?? -2,
child: badge!, child: badge!,
), ),
], ],

View File

@ -8,9 +8,9 @@ import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/experience.dart'; import 'package:surface/providers/experience.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/types/account.dart'; import 'package:surface/types/account.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
class AccountPopoverCard extends StatelessWidget { class AccountPopoverCard extends StatelessWidget {
@ -72,37 +72,21 @@ class AccountPopoverCard extends StatelessWidget {
const Gap(8) const Gap(8)
], ],
).padding(horizontal: 16), ).padding(horizontal: 16),
if (data.badges.isNotEmpty) const Gap(12),
if (data.badges.isNotEmpty) if (data.badges.isNotEmpty)
Wrap( Wrap(
spacing: 4, spacing: 4,
children: data.badges children: data.badges
.map( .map(
(ele) => Tooltip( (ele) => AccountBadge(badge: ele),
richMessage: TextSpan(
children: [
TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
if (ele.metadata['title'] != null)
TextSpan(
text: '\n${ele.metadata['title']}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: '\n'),
TextSpan(
text: DateFormat.yMEd().format(ele.createdAt),
),
],
),
child: Icon(
kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
color: kBadgesMeta[ele.type]?.$3,
fill: 1,
),
),
) )
.toList(), .toList(),
).padding(horizontal: 24), ).padding(horizontal: 24, bottom: 12, top: 12),
const Gap(8), if (data.profile?.description.isNotEmpty ?? false)
Text(
data.profile?.description ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
).padding(horizontal: 26, bottom: 8),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@ -110,7 +94,9 @@ class AccountPopoverCard extends StatelessWidget {
const Gap(8), const Gap(8),
Text('Lv${getLevelFromExp(data.profile?.experience ?? 0)}'), Text('Lv${getLevelFromExp(data.profile?.experience ?? 0)}'),
const Gap(8), const Gap(8),
Text(calcLevelUpProgressLevel(data.profile?.experience ?? 0)).fontSize(11).opacity(0.5), Text(calcLevelUpProgressLevel(data.profile?.experience ?? 0))
.fontSize(11)
.opacity(0.5),
const Gap(8), const Gap(8),
Container( Container(
width: double.infinity, width: double.infinity,
@ -126,25 +112,36 @@ class AccountPopoverCard extends StatelessWidget {
FutureBuilder( FutureBuilder(
future: sn.client.get('/cgi/id/users/${data.name}/status'), future: sn.client.get('/cgi/id/users/${data.name}/status'),
builder: (context, snapshot) { builder: (context, snapshot) {
final SnAccountStatusInfo? status = final SnAccountStatusInfo? status = snapshot.hasData
snapshot.hasData ? SnAccountStatusInfo.fromJson(snapshot.data!.data) : null; ? SnAccountStatusInfo.fromJson(snapshot.data!.data)
: null;
return Row( return Row(
children: [ children: [
Icon( Icon(
Symbols.circle, (status?.isDisturbable ?? true)
fill: 1, ? Symbols.circle
: Symbols.do_not_disturb_on,
fill: (status?.isOnline ?? false) ? 1 : 0,
size: 16, size: 16,
color: (status?.isOnline ?? false) ? Colors.green : Colors.grey, color: (status?.isOnline ?? false)
? (status?.isDisturbable ?? true)
? Colors.green
: Colors.red
: Colors.grey,
).padding(all: 4), ).padding(all: 4),
const Gap(8), const Gap(8),
Text( Text(
status != null status != null
? status.isOnline ? (status.status?.label.isNotEmpty ?? false)
? 'accountStatusOnline'.tr() ? status.status!.label
: 'accountStatusOffline'.tr() : status.isOnline
? 'accountStatusOnline'.tr()
: 'accountStatusOffline'.tr()
: 'loading'.tr(), : 'loading'.tr(),
), ),
if (status != null && !status.isOnline && status.lastSeenAt != null) if (status != null &&
!status.isOnline &&
status.lastSeenAt != null)
Text( Text(
'accountStatusLastSeen'.tr(args: [ 'accountStatusLastSeen'.tr(args: [
status.lastSeenAt != null status.lastSeenAt != null

View File

@ -0,0 +1,391 @@
import 'package:dio/dio.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';
final Map<String, (Widget, String, String?)> kPresetStatus = {
'online': (
const Icon(Symbols.circle, color: Colors.green, fill: 1),
'accountStatusOnline'.tr(),
null,
),
'silent': (
const Icon(Symbols.do_not_disturb_on, color: Colors.red),
'accountStatusSilent'.tr(),
'accountStatusSilentDesc'.tr(),
),
'invisible': (
const Icon(Symbols.circle, color: Colors.grey),
'accountStatusInvisible'.tr(),
'accountStatusInvisibleDesc'.tr(),
),
};
class AccountStatusActionPopup extends StatefulWidget {
final SnAccountStatusInfo? currentStatus;
const AccountStatusActionPopup({super.key, this.currentStatus});
@override
State<AccountStatusActionPopup> createState() =>
_AccountStatusActionPopupState();
}
class _AccountStatusActionPopupState extends State<AccountStatusActionPopup> {
bool _isBusy = false;
Future<void> setStatus(
String type,
String? label,
int attitude, {
bool isUpdate = false,
bool isSilent = false,
bool isInvisible = false,
DateTime? clearAt,
}) async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final payload = {
'type': type,
'label': label,
'attitude': attitude,
'is_no_disturb': isSilent,
'is_invisible': isInvisible,
'clear_at': clearAt?.toUtc().toIso8601String()
};
try {
await sn.client.request(
'/cgi/id/users/me/status',
data: payload,
options: Options(method: isUpdate ? 'PUT' : 'POST'),
);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _clearStatus() async {
if (_isBusy) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/id/users/me/status');
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.mood, size: 24),
const Gap(16),
Text('accountChangeStatus',
style: Theme.of(context).textTheme.titleLarge)
.tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
LoadingIndicator(isActive: _isBusy),
SizedBox(
height: 48,
child: ListView(
padding: EdgeInsets.symmetric(horizontal: 18),
scrollDirection: Axis.horizontal,
children: kPresetStatus.entries
.map(
(x) => StyledWidget(ActionChip(
avatar: x.value.$1,
label: Text(x.value.$2),
tooltip: x.value.$3,
onPressed: _isBusy
? null
: () {
setStatus(
x.key,
x.value.$2,
0,
isInvisible: x.key == 'invisible',
isSilent: x.key == 'silent',
);
},
)).padding(right: 6),
)
.toList(),
),
),
const Gap(16),
const Divider(thickness: 0.3, height: 0.3),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: widget.currentStatus != null
? const Icon(Icons.edit)
: const Icon(Icons.add),
title: Text('accountCustomStatus').tr(),
subtitle: Text('accountCustomStatusDescription').tr(),
onTap: _isBusy
? null
: () async {
final val = await showDialog(
context: context,
builder: (context) => _AccountStatusEditorDialog(
currentStatus: widget.currentStatus,
),
);
if (val == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
if (widget.currentStatus != null)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.clear),
title: Text('accountClearStatus').tr(),
subtitle: Text('accountClearStatusDescription').tr(),
onTap: _isBusy
? null
: () {
_clearStatus();
},
),
],
);
}
}
class _AccountStatusEditorDialog extends StatefulWidget {
final SnAccountStatusInfo? currentStatus;
const _AccountStatusEditorDialog({this.currentStatus});
@override
State<_AccountStatusEditorDialog> createState() =>
_AccountStatusEditorDialogState();
}
class _AccountStatusEditorDialogState
extends State<_AccountStatusEditorDialog> {
bool _isBusy = false;
final TextEditingController _labelController = TextEditingController();
final TextEditingController _clearAtController = TextEditingController();
int _attitude = 0;
bool _isSilent = false;
bool _isInvisible = false;
DateTime? _clearAt;
Future<void> _selectClearAt() async {
final DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: _clearAt?.toLocal() ?? DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (pickedDate == null) return;
if (!mounted) return;
final TimeOfDay? pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
if (pickedTime == null) return;
if (!mounted) return;
final picked = pickedDate.copyWith(
hour: pickedTime.hour,
minute: pickedTime.minute,
);
setState(() {
_clearAt = picked;
_clearAtController.text = DateFormat('y/M/d HH:mm').format(_clearAt!);
});
}
Future<void> _applyStatus() async {
if (_isBusy) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.request(
'/cgi/id/users/me/status',
data: {
'type': 'custom',
'label': _labelController.text,
'attitude': _attitude,
'is_no_disturb': _isSilent,
'is_invisible': _isInvisible,
'clear_at': _clearAt?.toUtc().toIso8601String(),
},
options: Options(
method: widget.currentStatus?.status != null ? 'PUT' : 'POST',
),
);
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
void _syncWidget() {
if (widget.currentStatus?.status != null) {
_clearAt = widget.currentStatus!.status!.clearAt;
if (_clearAt != null) {
_clearAtController.text = DateFormat('y/M/d HH:mm').format(_clearAt!);
}
_labelController.text = widget.currentStatus!.status!.label;
_attitude = widget.currentStatus!.status!.attitude;
_isInvisible = widget.currentStatus!.status!.isInvisible;
_isSilent = widget.currentStatus!.status!.isNoDisturb;
}
}
@override
void initState() {
_syncWidget();
super.initState();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('accountCustomStatus').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
LoadingIndicator(isActive: _isBusy),
TextField(
controller: _labelController,
decoration: InputDecoration(
isDense: true,
prefixIcon: const Icon(Icons.label),
border: const OutlineInputBorder(),
labelText: 'fieldAccountStatusLabel'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(8),
TextField(
controller: _clearAtController,
readOnly: true,
decoration: InputDecoration(
isDense: true,
prefixIcon: const Icon(Icons.event_busy),
border: const OutlineInputBorder(),
labelText: 'fieldAccountStatusClearAt'.tr(),
),
onTap: () => _selectClearAt(),
),
const Gap(8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: 6,
runSpacing: 0,
children: [
ChoiceChip(
avatar: Icon(
Symbols.radio_button_unchecked,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
selected: _attitude == 2,
label: Text('accountStatusNegative'.tr()),
onSelected: (val) {
if (val) setState(() => _attitude = 2);
},
),
ChoiceChip(
avatar: Icon(
Symbols.contrast,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
selected: _attitude == 0,
label: Text('accountStatusNeutral'.tr()),
onSelected: (val) {
if (val) setState(() => _attitude = 0);
},
),
ChoiceChip(
avatar: Icon(
Symbols.circle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
selected: _attitude == 1,
label: Text('accountStatusPositive'.tr()),
onSelected: (val) {
if (val) setState(() => _attitude = 1);
},
),
],
),
),
const Gap(4),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: 6,
runSpacing: 0,
children: [
ChoiceChip(
selected: _isSilent,
label: Text('accountStatusSilent').tr(),
onSelected: (val) {
setState(() => _isSilent = val);
},
),
ChoiceChip(
selected: _isInvisible,
label: Text('accountStatusInvisible').tr(),
onSelected: (val) {
setState(() => _isInvisible = val);
},
),
],
),
),
],
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
onPressed: _isBusy ? null : () => Navigator.pop(context),
child: Text('dialogCancel').tr(),
),
TextButton(
onPressed: _isBusy ? null : () => _applyStatus(),
child: Text('dialogConfirm').tr(),
),
],
);
}
}

View File

@ -0,0 +1,50 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:surface/screens/account/profile_page.dart' show kBadgesMeta;
import 'package:surface/types/account.dart';
class AccountBadge extends StatelessWidget {
final SnAccountBadge badge;
final double radius;
final EdgeInsets? padding;
const AccountBadge({
super.key,
required this.badge,
this.radius = 20,
this.padding,
});
@override
Widget build(BuildContext context) {
return Tooltip(
richMessage: TextSpan(
children: [
TextSpan(text: kBadgesMeta[badge.type]?.$1.tr() ?? 'unknown'.tr()),
if (badge.metadata['title'] != null)
TextSpan(
text: '\n${badge.metadata['title']}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: '\n'),
TextSpan(
text: DateFormat.yMEd().format(badge.createdAt),
),
],
),
child: Container(
padding: padding ?? EdgeInsets.all(3),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
color: kBadgesMeta[badge.type]?.$3,
),
child: Icon(
kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
color: Colors.white,
fill: 1,
size: radius - 4,
),
),
);
}
}

View File

@ -22,12 +22,14 @@ class AttachmentItem extends StatelessWidget {
final SnAttachment? data; final SnAttachment? data;
final String? heroTag; final String? heroTag;
final BoxFit fit; final BoxFit fit;
final FilterQuality? filterQuality;
const AttachmentItem({ const AttachmentItem({
super.key, super.key,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
required this.data, required this.data,
required this.heroTag, required this.heroTag,
this.filterQuality,
}); });
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
@ -43,10 +45,25 @@ class AttachmentItem extends StatelessWidget {
case 'image': case 'image':
return Hero( return Hero(
tag: 'attachment-${data!.rid}-$tag', tag: 'attachment-${data!.rid}-$tag',
child: AutoResizeUniversalImage( child: Stack(
sn.getAttachmentUrl(data!.rid), fit: StackFit.expand,
key: Key('attachment-${data!.rid}-$tag'), children: [
fit: fit, ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(data!.rid),
key: Key('attachment-${data!.rid}-$tag-blur-background'),
fit: BoxFit.cover,
filterQuality: filterQuality,
),
),
AutoResizeUniversalImage(
sn.getAttachmentUrl(data!.rid),
key: Key('attachment-${data!.rid}-$tag'),
fit: fit,
filterQuality: filterQuality,
),
],
), ),
); );
case 'video': case 'video':
@ -83,13 +100,16 @@ class _AttachmentItemSensitiveBlur extends StatefulWidget {
final Widget child; final Widget child;
final bool isCompact; final bool isCompact;
const _AttachmentItemSensitiveBlur({required this.child, this.isCompact = false}); const _AttachmentItemSensitiveBlur(
{required this.child, this.isCompact = false});
@override @override
State<_AttachmentItemSensitiveBlur> createState() => _AttachmentItemSensitiveBlurState(); State<_AttachmentItemSensitiveBlur> createState() =>
_AttachmentItemSensitiveBlurState();
} }
class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBlur> { class _AttachmentItemSensitiveBlurState
extends State<_AttachmentItemSensitiveBlur> {
bool _doesShow = false; bool _doesShow = false;
@override @override
@ -124,10 +144,15 @@ class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBl
Text( Text(
'sensitiveContentDescription', 'sensitiveContentDescription',
textAlign: TextAlign.center, textAlign: TextAlign.center,
).tr().fontSize(14).textColor(Colors.white.withOpacity(0.8)), )
.tr()
.fontSize(14)
.textColor(Colors.white.withOpacity(0.8)),
if (!widget.isCompact) const Gap(16), if (!widget.isCompact) const Gap(16),
InkWell( InkWell(
child: Text('sensitiveContentReveal').tr().textColor(Colors.white), child: Text('sensitiveContentReveal')
.tr()
.textColor(Colors.white),
onTap: () { onTap: () {
setState(() => _doesShow = !_doesShow); setState(() => _doesShow = !_doesShow);
}, },
@ -137,7 +162,9 @@ class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBl
).center(), ).center(),
), ),
), ),
).opacity(_doesShow ? 0 : 1, animate: true).animate(const Duration(milliseconds: 300), Curves.easeInOut), )
.opacity(_doesShow ? 0 : 1, animate: true)
.animate(const Duration(milliseconds: 300), Curves.easeInOut),
if (_doesShow) if (_doesShow)
Positioned( Positioned(
top: 0, top: 0,
@ -174,10 +201,12 @@ class _AttachmentItemContentVideo extends StatefulWidget {
}); });
@override @override
State<_AttachmentItemContentVideo> createState() => _AttachmentItemContentVideoState(); State<_AttachmentItemContentVideo> createState() =>
_AttachmentItemContentVideoState();
} }
class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo> { class _AttachmentItemContentVideoState
extends State<_AttachmentItemContentVideo> {
bool _showContent = false; bool _showContent = false;
bool _showOriginal = false; bool _showOriginal = false;
@ -188,7 +217,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
setState(() => _showContent = true); setState(() => _showContent = true);
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final url = _showOriginal ? sn.getAttachmentUrl(widget.data.rid) : sn.getAttachmentUrl(widget.data.compressed!.rid); final url = _showOriginal
? sn.getAttachmentUrl(widget.data.rid)
: sn.getAttachmentUrl(widget.data.compressed!.rid);
_videoPlayer = Player(); _videoPlayer = Player();
_videoController = VideoController(_videoPlayer!); _videoController = VideoController(_videoPlayer!);
_videoPlayer!.open(Media(url), play: !widget.isAutoload); _videoPlayer!.open(Media(url), play: !widget.isAutoload);
@ -201,7 +232,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
_videoPlayer?.open( _videoPlayer?.open(
Media( Media(
_showOriginal ? sn.getAttachmentUrl(widget.data.rid) : sn.getAttachmentUrl(widget.data.compressed!.rid), _showOriginal
? sn.getAttachmentUrl(widget.data.rid)
: sn.getAttachmentUrl(widget.data.compressed!.rid),
), ),
play: true, play: true,
); );
@ -232,6 +265,7 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Stack( child: Stack(
fit: StackFit.expand,
children: [ children: [
if (widget.data.thumbnail != null) if (widget.data.thumbnail != null)
AutoResizeUniversalImage( AutoResizeUniversalImage(
@ -283,7 +317,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
), ),
Text( Text(
Duration( Duration(
milliseconds: (widget.data.data['duration'] ?? 0).toInt() * 1000, milliseconds:
(widget.data.data['duration'] ?? 0).toInt() *
1000,
).toString(), ).toString(),
style: GoogleFonts.robotoMono( style: GoogleFonts.robotoMono(
fontSize: 12, fontSize: 12,
@ -346,7 +382,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
MaterialDesktopCustomButton( MaterialDesktopCustomButton(
iconSize: 24, iconSize: 24,
onPressed: _toggleOriginal, onPressed: _toggleOriginal,
icon: _showOriginal ? const Icon(Symbols.high_quality, size: 24) : const Icon(Symbols.sd, size: 24), icon: _showOriginal
? const Icon(Symbols.high_quality, size: 24)
: const Icon(Symbols.sd, size: 24),
), ),
], ],
), ),
@ -354,8 +392,9 @@ class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo
child: Video( child: Video(
controller: _videoController!, controller: _videoController!,
aspectRatio: ratio, aspectRatio: ratio,
controls: controls: !kIsWeb && (Platform.isAndroid || Platform.isIOS)
!kIsWeb && (Platform.isAndroid || Platform.isIOS) ? MaterialVideoControls : MaterialDesktopVideoControls, ? MaterialVideoControls
: MaterialDesktopVideoControls,
), ),
), ),
); );
@ -378,10 +417,12 @@ class _AttachmentItemContentAudio extends StatefulWidget {
}); });
@override @override
State<_AttachmentItemContentAudio> createState() => _AttachmentItemContentAudioState(); State<_AttachmentItemContentAudio> createState() =>
_AttachmentItemContentAudioState();
} }
class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio> { class _AttachmentItemContentAudioState
extends State<_AttachmentItemContentAudio> {
bool _showContent = false; bool _showContent = false;
double? _draggingValue; double? _draggingValue;
@ -429,6 +470,7 @@ class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Stack( child: Stack(
fit: StackFit.expand,
children: [ children: [
if (widget.data.thumbnail != null) if (widget.data.thumbnail != null)
AspectRatio( AspectRatio(
@ -552,8 +594,12 @@ class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio
overlayShape: SliderComponentShape.noOverlay, overlayShape: SliderComponentShape.noOverlay,
), ),
child: Slider( child: Slider(
secondaryTrackValue: _bufferedPosition.inMilliseconds.abs().toDouble(), secondaryTrackValue: _bufferedPosition
value: _draggingValue?.abs() ?? _position.inMilliseconds.toDouble().abs(), .inMilliseconds
.abs()
.toDouble(),
value: _draggingValue?.abs() ??
_position.inMilliseconds.toDouble().abs(),
min: 0, min: 0,
max: math max: math
.max( .max(
@ -593,7 +639,9 @@ class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio
), ),
const Gap(16), const Gap(16),
IconButton.filled( IconButton.filled(
icon: _isPlaying ? const Icon(Symbols.pause) : const Icon(Symbols.play_arrow), icon: _isPlaying
? const Icon(Symbols.pause)
: const Icon(Symbols.play_arrow),
onPressed: () { onPressed: () {
_audioPlayer!.playOrPause(); _audioPlayer!.playOrPause();
}, },

View File

@ -21,6 +21,7 @@ class AttachmentList extends StatefulWidget {
final double? minWidth; final double? minWidth;
final double? maxWidth; final double? maxWidth;
final EdgeInsets? padding; final EdgeInsets? padding;
final FilterQuality? filterQuality;
const AttachmentList({ const AttachmentList({
super.key, super.key,
@ -33,23 +34,27 @@ class AttachmentList extends StatefulWidget {
this.minWidth, this.minWidth,
this.maxWidth, this.maxWidth,
this.padding, this.padding,
this.filterQuality,
}); });
static const BorderRadius kDefaultRadius = BorderRadius.all(Radius.circular(8)); static const BorderRadius kDefaultRadius =
BorderRadius.all(Radius.circular(8));
@override @override
State<AttachmentList> createState() => _AttachmentListState(); State<AttachmentList> createState() => _AttachmentListState();
} }
class _AttachmentListState extends State<AttachmentList> { class _AttachmentListState extends State<AttachmentList> {
late final List<String> heroTags = List.generate(widget.data.length, (_) => const Uuid().v4()); late final List<String> heroTags =
List.generate(widget.data.length, (_) => const Uuid().v4());
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, layoutConstraints) { builder: (context, layoutConstraints) {
final borderSide = final borderSide = widget.bordered
widget.bordered ? BorderSide(width: 1, color: Theme.of(context).dividerColor) : BorderSide.none; ? BorderSide(width: 1, color: Theme.of(context).dividerColor)
: BorderSide.none;
final backgroundColor = Theme.of(context).colorScheme.surfaceContainer; final backgroundColor = Theme.of(context).colorScheme.surfaceContainer;
final constraints = BoxConstraints( final constraints = BoxConstraints(
minWidth: widget.minWidth ?? 80, minWidth: widget.minWidth ?? 80,
@ -58,13 +63,13 @@ class _AttachmentListState extends State<AttachmentList> {
if (widget.data.isEmpty) return const SizedBox.shrink(); if (widget.data.isEmpty) return const SizedBox.shrink();
if (widget.data.length == 1) { if (widget.data.length == 1) {
final singleAspectRatio = final singleAspectRatio = widget.data[0]?.data['ratio']?.toDouble() ??
widget.data[0]?.data['ratio']?.toDouble() ??
switch (widget.data[0]?.mimetype.split('/').firstOrNull) { switch (widget.data[0]?.mimetype.split('/').firstOrNull) {
'audio' => 16 / 9, 'audio' => 16 / 9,
'video' => 16 / 9, 'video' => 16 / 9,
_ => 1, _ => 1,
}.toDouble(); }
.toDouble();
return Container( return Container(
padding: widget.padding ?? EdgeInsets.zero, padding: widget.padding ?? EdgeInsets.zero,
@ -80,12 +85,19 @@ class _AttachmentListState extends State<AttachmentList> {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: AttachmentList.kDefaultRadius, borderRadius: AttachmentList.kDefaultRadius,
child: AttachmentItem(data: widget.data[0], heroTag: heroTags[0], fit: widget.fit), child: AttachmentItem(
data: widget.data[0],
heroTag: heroTags[0],
fit: widget.fit,
filterQuality: widget.filterQuality,
),
), ),
), ),
), ),
onTap: () { onTap: () {
if (widget.data.firstOrNull?.mediaType != SnMediaType.image) return; if (widget.data.firstOrNull?.mediaType != SnMediaType.image) {
return;
}
context.pushTransparentRoute( context.pushTransparentRoute(
AttachmentZoomView( AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(), data: widget.data.where((ele) => ele != null).cast(),
@ -100,8 +112,10 @@ class _AttachmentListState extends State<AttachmentList> {
); );
} }
final fullOfImage = final fullOfImage = widget.data
widget.data.where((ele) => ele?.mediaType == SnMediaType.image).length == widget.data.length; .where((ele) => ele?.mediaType == SnMediaType.image)
.length ==
widget.data.length;
if (widget.gridded && fullOfImage) { if (widget.gridded && fullOfImage) {
return Container( return Container(
@ -117,29 +131,38 @@ class _AttachmentListState extends State<AttachmentList> {
crossAxisCount: math.min(widget.data.length, 2), crossAxisCount: math.min(widget.data.length, 2),
crossAxisSpacing: 4, crossAxisSpacing: 4,
mainAxisSpacing: 4, mainAxisSpacing: 4,
children: children: widget.data
widget.data .mapIndexed(
.mapIndexed( (idx, ele) => GestureDetector(
(idx, ele) => GestureDetector( child: Container(
child: Container( constraints: constraints,
constraints: constraints, child: AttachmentItem(
child: AttachmentItem(data: ele, heroTag: heroTags[idx], fit: BoxFit.cover), data: ele,
), heroTag: heroTags[idx],
onTap: () { fit: BoxFit.cover,
if (widget.data[idx]!.mediaType != SnMediaType.image) return; filterQuality: widget.filterQuality,
context.pushTransparentRoute(
AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(),
initialIndex: idx,
heroTags: heroTags,
),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
},
), ),
) ),
.toList(), onTap: () {
if (widget.data[idx]!.mediaType !=
SnMediaType.image) {
return;
}
context.pushTransparentRoute(
AttachmentZoomView(
data: widget.data
.where((ele) => ele != null)
.cast(),
initialIndex: idx,
heroTags: heroTags,
),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
},
),
)
.toList(),
), ),
), ),
); );
@ -156,22 +179,26 @@ class _AttachmentListState extends State<AttachmentList> {
child: ClipRRect( child: ClipRRect(
borderRadius: AttachmentList.kDefaultRadius, borderRadius: AttachmentList.kDefaultRadius,
child: Column( child: Column(
children: children: widget.data
widget.data .mapIndexed(
.mapIndexed( (idx, ele) => GestureDetector(
(idx, ele) => GestureDetector( child: AspectRatio(
child: AspectRatio( aspectRatio: ele?.data['ratio']?.toDouble() ?? 1,
aspectRatio: ele?.data['ratio']?.toDouble() ?? 1, child: Container(
child: Container( constraints: constraints,
constraints: constraints, child: AttachmentItem(
child: AttachmentItem(data: ele, heroTag: heroTags[idx], fit: BoxFit.cover), data: ele,
), heroTag: heroTags[idx],
fit: BoxFit.cover,
filterQuality: widget.filterQuality,
), ),
), ),
) ),
.expand((ele) => [ele, const Divider(height: 1)]) ),
.toList() )
..removeLast(), .expand((ele) => [ele, const Divider(height: 1)])
.toList()
..removeLast(),
), ),
), ),
); );
@ -179,26 +206,33 @@ class _AttachmentListState extends State<AttachmentList> {
return Container( return Container(
constraints: BoxConstraints(maxHeight: constraints.maxHeight), constraints: BoxConstraints(maxHeight: constraints.maxHeight),
width: double.infinity,
child: AspectRatio( child: AspectRatio(
aspectRatio: widget.data[0]?.data['ratio']?.toDouble() ?? 1, aspectRatio: widget.data[0]?.data['ratio']?.toDouble() ?? 1,
child: ScrollConfiguration( child: ScrollConfiguration(
behavior: _AttachmentListScrollBehavior(), behavior: AttachmentListScrollBehavior(),
child: ListView.separated( child: ListView.separated(
padding: widget.padding, padding: widget.padding,
shrinkWrap: true, shrinkWrap: true,
itemCount: widget.data.length, itemCount: widget.data.length,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return Container( return Container(
constraints: constraints.copyWith(maxWidth: widget.maxWidth), constraints:
constraints.copyWith(maxWidth: widget.maxWidth),
child: AspectRatio( child: AspectRatio(
aspectRatio: (widget.data[idx]?.data['ratio'] ?? 1).toDouble(), aspectRatio:
(widget.data[idx]?.data['ratio'] ?? 1).toDouble(),
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
if (widget.data[idx]?.mediaType != SnMediaType.image) return; if (widget.data[idx]?.mediaType != SnMediaType.image)
return;
context.pushTransparentRoute( context.pushTransparentRoute(
AttachmentZoomView( AttachmentZoomView(
data: data: widget.data
widget.data.where((ele) => ele != null && ele.mediaType == SnMediaType.image).cast(), .where((ele) =>
ele != null &&
ele.mediaType == SnMediaType.image)
.cast(),
initialIndex: idx, initialIndex: idx,
heroTags: heroTags, heroTags: heroTags,
), ),
@ -212,18 +246,25 @@ class _AttachmentListState extends State<AttachmentList> {
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
border: Border(top: borderSide, bottom: borderSide), border:
Border(top: borderSide, bottom: borderSide),
borderRadius: AttachmentList.kDefaultRadius, borderRadius: AttachmentList.kDefaultRadius,
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: AttachmentList.kDefaultRadius, borderRadius: AttachmentList.kDefaultRadius,
child: AttachmentItem(data: widget.data[idx], heroTag: heroTags[idx]), child: AttachmentItem(
data: widget.data[idx],
heroTag: heroTags[idx],
filterQuality: widget.filterQuality,
),
), ),
), ),
Positioned( Positioned(
right: 8, right: 8,
bottom: 8, bottom: 8,
child: Chip(label: Text('${idx + 1}/${widget.data.length}')), child: Chip(
label:
Text('${idx + 1}/${widget.data.length}')),
), ),
], ],
), ),
@ -243,7 +284,8 @@ class _AttachmentListState extends State<AttachmentList> {
} }
} }
class _AttachmentListScrollBehavior extends MaterialScrollBehavior { class AttachmentListScrollBehavior extends MaterialScrollBehavior {
@override @override
Set<PointerDeviceKind> get dragDevices => {PointerDeviceKind.touch, PointerDeviceKind.mouse}; Set<PointerDeviceKind> get dragDevices =>
{PointerDeviceKind.touch, PointerDeviceKind.mouse};
} }

View File

@ -1,5 +1,4 @@
import 'dart:io'; import 'dart:io';
import 'dart:math' show max;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dismissible_page/dismissible_page.dart'; import 'package:dismissible_page/dismissible_page.dart';
@ -48,11 +47,14 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
bool _showOverlay = true; bool _showOverlay = true;
bool _dismissable = true; bool _dismissable = true;
int _page = 0;
void _updatePage() { void _updatePage() {
setState(() { setState(() {
if (_isCompletedDownload) { if (_isCompletedDownload) {
setState(() => _isCompletedDownload = false); setState(() => _isCompletedDownload = false);
} }
_page = _pageController.page?.round() ?? 0;
}); });
} }
@ -155,7 +157,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
direction: _dismissable direction: _dismissable
? DismissiblePageDismissDirection.multi ? DismissiblePageDismissDirection.down
: DismissiblePageDismissDirection.none, : DismissiblePageDismissDirection.none,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
isFullScreen: true, isFullScreen: true,
@ -222,31 +224,11 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
BoxDecoration(color: Colors.transparent), BoxDecoration(color: Colors.transparent),
); );
}), }),
Positioned(
top: max(MediaQuery.of(context).padding.top, 8),
left: 14,
child: IgnorePointer(
ignoring: !_showOverlay,
child: IconButton(
constraints: const BoxConstraints(),
icon: const Icon(Icons.close),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
Theme.of(context).colorScheme.surface.withOpacity(0.5),
),
),
onPressed: () {
Navigator.of(context).pop();
},
).opacity(_showOverlay ? 1 : 0, animate: true).animate(
const Duration(milliseconds: 300), Curves.easeInOut),
),
),
Align( Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: IgnorePointer( child: IgnorePointer(
child: Container( child: Container(
height: 300, height: 200,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.bottomCenter, begin: Alignment.bottomCenter,
@ -269,153 +251,130 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: Builder(builder: (context) { child: Builder(builder: (context) {
final ud = context.read<UserDirectoryProvider>(); return Row(
final item = widget.data.elementAt(
widget.data.length > 1
? _pageController.page?.round() ?? 0
: 0,
);
final account = ud.getFromCache(item.accountId);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (item.accountId > 0) IconButton(
Row( iconSize: 18,
children: [ constraints: const BoxConstraints(),
IgnorePointer( icon: const Icon(Icons.close),
child: AccountImage( style: ButtonStyle(
content: account?.avatar, backgroundColor: MaterialStateProperty.all(
radius: 19, Theme.of(context)
), .colorScheme
), .surface
const Gap(8), .withOpacity(0.5),
Expanded(
child: IgnorePointer(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'attachmentUploadBy'.tr(),
style: Theme.of(context)
.textTheme
.bodySmall,
),
Text(
account?.nick ?? 'unknown'.tr(),
style: Theme.of(context)
.textTheme
.bodyMedium,
),
],
),
),
),
if (widget.data.length > 1)
IgnorePointer(
child: Text(
'${(_pageController.page?.round() ?? 0) + 1}/${widget.data.length}',
style: GoogleFonts.robotoMono(fontSize: 13),
).padding(right: 8),
),
InkWell(
borderRadius:
const BorderRadius.all(Radius.circular(16)),
onTap: _isDownloading
? null
: () => _saveToAlbum(widget.data.length > 1
? _pageController.page?.round() ?? 0
: 0),
child: Container(
padding: const EdgeInsets.all(6),
child: !_isDownloading
? !_isCompletedDownload
? const Icon(Symbols.save_alt)
: const Icon(Symbols.download_done)
: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: _progressOfDownload,
strokeWidth: 3,
),
),
),
),
],
),
const Gap(4),
IgnorePointer(
child: Text(
item.alt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
), ),
), ),
onPressed: () {
Navigator.of(context).pop();
},
), ),
const Gap(2), IconButton(
IgnorePointer( iconSize: 20,
child: Wrap( constraints: const BoxConstraints(),
spacing: 6, padding: EdgeInsets.zero,
children: [ visualDensity: VisualDensity.compact,
if (item.metadata['exif'] == null) icon: const Icon(Symbols.hide).padding(all: 6),
Text( onPressed: () {
'#${item.rid}', setState(() => _showOverlay = false);
style: metaTextStyle, }),
), Expanded(
if (item.metadata['exif']?['Model'] != null) child: IgnorePointer(
Text( child: Builder(builder: (context) {
'attachmentShotOn'.tr(args: [ final item = widget.data.elementAt(_page);
item.metadata['exif']?['Model'], final doShowCameraInfo =
]), item.metadata['exif']?['Model'] != null;
style: metaTextStyle, final exif = item.metadata['exif'];
).padding(right: 2), return Column(
if (item.metadata['exif']?['Megapixels'] != children: [
null && if (widget.data.length > 1)
item.metadata['exif']?['Model'] != null) Text(
Text( '${_page + 1}/${widget.data.length}',
'${item.metadata['exif']?['Megapixels']}MP', style:
style: metaTextStyle, GoogleFonts.robotoMono(fontSize: 13),
) ).padding(right: 8),
else if (doShowCameraInfo)
Text( Text(
item.size.formatBytes(), 'attachmentShotOn'
style: metaTextStyle, .tr(args: [exif?['Model']]),
), style: metaTextStyle,
if (item.metadata['width'] != null && textAlign: TextAlign.center,
item.metadata['height'] != null) ),
Text( if (doShowCameraInfo)
'${item.metadata['width']}x${item.metadata['height']}', Row(
style: metaTextStyle, spacing: 4,
), mainAxisSize: MainAxisSize.min,
], children: [
if (exif?['Megapixels'] != null)
Text(
'${exif?['Megapixels']}MP',
style: metaTextStyle,
),
if (exif?['ISO'] != null)
Text(
'ISO${exif['ISO']}',
style: metaTextStyle,
),
if (exif?['FNumber'] != null)
Text(
'f/${exif['FNumber']}',
style: metaTextStyle,
),
],
)
],
);
}),
), ),
), ),
const Gap(4), IconButton(
InkWell( constraints: const BoxConstraints(),
onTap: () { padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: Container(
padding: const EdgeInsets.all(6),
child: !_isDownloading
? !_isCompletedDownload
? const Icon(Symbols.save_alt)
: const Icon(Symbols.download_done)
: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
value: _progressOfDownload,
strokeWidth: 3,
),
),
),
onPressed:
_isDownloading ? null : () => _saveToAlbum(_page),
),
IconButton(
iconSize: 18,
constraints: const BoxConstraints(),
icon: const Icon(Icons.info_outline),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(
Theme.of(context)
.colorScheme
.surface
.withOpacity(0.5),
),
),
onPressed: () {
_showDetail = true; _showDetail = true;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => _AttachmentZoomDetailPopup( builder: (context) => _AttachmentZoomDetailPopup(
data: widget.data.elementAt( data: widget.data.elementAt(_page),
widget.data.length > 1
? _pageController.page?.round() ?? 0
: 0),
), ),
).then((_) { ).then((_) {
_showDetail = false; _showDetail = false;
}); });
}, },
child: Text(
'viewDetailedAttachment'.tr(),
style: metaTextStyle.copyWith(
decoration: TextDecoration.underline),
),
), ),
], ],
); );
@ -427,18 +386,20 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
), ),
), ),
onTap: () { onTap: () {
if (_showOverlay) {
Navigator.pop(context);
return;
}
setState(() => _showOverlay = !_showOverlay); setState(() => _showOverlay = !_showOverlay);
}, },
onVerticalDragUpdate: (details) { onVerticalDragUpdate: (details) {
if (_showDetail) return; if (_showDetail || !_dismissable) return;
if (details.delta.dy <= -20) { if (details.delta.dy <= -20) {
_showDetail = true; _showDetail = true;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => _AttachmentZoomDetailPopup( builder: (context) => _AttachmentZoomDetailPopup(
data: widget.data.elementAt(widget.data.length > 1 data: widget.data.elementAt(_page),
? _pageController.page?.round() ?? 0
: 0),
), ),
).then((_) { ).then((_) {
_showDetail = false; _showDetail = false;

View File

@ -10,14 +10,16 @@ import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/keypair.dart'; import 'package:surface/providers/keypair.dart';
import 'package:surface/providers/translation.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/types/chat.dart'; import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/account_popover.dart'; import 'package:surface/widgets/account/account_popover.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/attachment/attachment_list.dart'; import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/context_menu.dart'; import 'package:surface/widgets/context_menu.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/link_preview.dart'; import 'package:surface/widgets/link_preview.dart';
import 'package:surface/widgets/markdown_content.dart'; import 'package:surface/widgets/markdown_content.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
@ -109,18 +111,10 @@ class ChatMessage extends StatelessWidget {
child: AccountImage( child: AccountImage(
content: user?.avatar, content: user?.avatar,
badge: (user?.badges.isNotEmpty ?? false) badge: (user?.badges.isNotEmpty ?? false)
? Icon( ? AccountBadge(
kBadgesMeta[user!.badges.first.type]?.$2 ?? badge: user!.badges.first,
Symbols.question_mark, radius: 16,
color: kBadgesMeta[user.badges.first.type]?.$3, padding: EdgeInsets.all(2),
fill: 1,
size: 18,
shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 5.0,
color: Color.fromARGB(200, 0, 0, 0)),
],
) )
: null, : null,
), ),
@ -214,7 +208,8 @@ class ChatMessage extends StatelessWidget {
data.type == 'messages.new' && data.type == 'messages.new' &&
(data.body['text']?.isNotEmpty ?? false) && (data.body['text']?.isNotEmpty ?? false) &&
(cfg.prefs.getBool(kAppExpandChatLink) ?? true)) (cfg.prefs.getBool(kAppExpandChatLink) ?? true))
LinkPreviewWidget(text: data.body['text']!).padding(left: 48), LinkPreviewWidget(text: data.body['text']!)
.padding(left: isCompact ? 0 : 48),
if (data.preload?.attachments?.isNotEmpty ?? false) if (data.preload?.attachments?.isNotEmpty ?? false)
AttachmentList( AttachmentList(
data: data.preload!.attachments!, data: data.preload!.attachments!,
@ -235,7 +230,7 @@ class ChatMessage extends StatelessWidget {
} }
} }
class _ChatMessageText extends StatelessWidget { class _ChatMessageText extends StatefulWidget {
final SnChatMessage data; final SnChatMessage data;
final Function(SnChatMessage)? onReply; final Function(SnChatMessage)? onReply;
final Function(SnChatMessage)? onEdit; final Function(SnChatMessage)? onEdit;
@ -244,13 +239,56 @@ class _ChatMessageText extends StatelessWidget {
const _ChatMessageText( const _ChatMessageText(
{required this.data, this.onReply, this.onEdit, this.onDelete}); {required this.data, this.onReply, this.onEdit, this.onDelete});
@override
State<_ChatMessageText> createState() => _ChatMessageTextState();
}
class _ChatMessageTextState extends State<_ChatMessageText> {
late String _displayText = widget.data.body['text'] ?? '';
bool _isTranslated = false;
bool _isTranslating = false;
Future<void> _translateText() async {
if (widget.data.body['text'] == null || widget.data.body['text']!.isEmpty) {
return;
}
final ta = context.read<SnTranslator>();
setState(() => _isTranslating = true);
try {
final to = EasyLocalization.of(context)!.locale.languageCode;
_displayText = await ta.translate(
widget.data.body['text'],
to: to,
);
_isTranslated = true;
if (mounted) setState(() {});
} catch (err) {
if (mounted) context.showErrorDialog(err);
} finally {
setState(() => _isTranslating = false);
}
}
@override
void initState() {
super.initState();
final cfg = context.read<ConfigProvider>();
if (cfg.autoTranslate) {
Future.delayed(const Duration(milliseconds: 100), () {
_translateText();
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
final isOwner = ua.isAuthorized && data.sender.accountId == ua.user?.id; final isOwner =
ua.isAuthorized && widget.data.sender.accountId == ua.user?.id;
if (data.body['text'] != null && data.body['text'].isNotEmpty) { if (widget.data.body['text'] != null &&
widget.data.body['text'].isNotEmpty) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -259,38 +297,50 @@ class _ChatMessageText extends StatelessWidget {
final List<ContextMenuButtonItem> items = final List<ContextMenuButtonItem> items =
editableTextState.contextMenuButtonItems; editableTextState.contextMenuButtonItems;
if (onReply != null) { if (widget.onReply != null) {
items.insert( items.insert(
0, 0,
ContextMenuButtonItem( ContextMenuButtonItem(
label: 'reply'.tr(), label: 'reply'.tr(),
onPressed: () { onPressed: () {
ContextMenuController.removeAny(); ContextMenuController.removeAny();
onReply?.call(data); widget.onReply?.call(widget.data);
}, },
), ),
); );
} }
if (isOwner && onEdit != null) { if (isOwner && widget.onEdit != null) {
items.insert( items.insert(
1, 1,
ContextMenuButtonItem( ContextMenuButtonItem(
label: 'edit'.tr(), label: 'edit'.tr(),
onPressed: () { onPressed: () {
ContextMenuController.removeAny(); ContextMenuController.removeAny();
onEdit?.call(data); widget.onEdit?.call(widget.data);
}, },
), ),
); );
} }
if (isOwner && onDelete != null) { if (isOwner && widget.onDelete != null) {
items.insert( items.insert(
2, 2,
ContextMenuButtonItem( ContextMenuButtonItem(
label: 'delete'.tr(), label: 'delete'.tr(),
onPressed: () { onPressed: () {
ContextMenuController.removeAny(); ContextMenuController.removeAny();
onDelete?.call(data); widget.onDelete?.call(widget.data);
},
),
);
}
if (widget.data.body['algorithm'] == 'plain') {
items.insert(
3,
ContextMenuButtonItem(
label: 'translate'.tr(),
onPressed: () {
ContextMenuController.removeAny();
_translateText();
}, },
), ),
); );
@ -301,26 +351,47 @@ class _ChatMessageText extends StatelessWidget {
buttonItems: items, buttonItems: items,
); );
}, },
child: switch (data.body['algorithm']) { child: switch (widget.data.body['algorithm']) {
'rsa' => _ChatDecryptMessage(message: data), 'rsa' => _ChatDecryptMessage(message: widget.data),
_ => MarkdownTextContent( _ => MarkdownTextContent(
content: data.body['text'], content: _displayText,
isAutoWarp: true, isAutoWarp: true,
isEnlargeSticker: isEnlargeSticker: RegExp(r"^:([-\w]+):$")
RegExp(r"^:([-\w]+):$").hasMatch(data.body['text'] ?? ''), .hasMatch(widget.data.body['text'] ?? ''),
), ),
}, },
), ),
if (data.updatedAt != data.createdAt) if (widget.data.updatedAt != widget.data.createdAt)
Text('messageEditedHint'.tr()).fontSize(13).opacity(0.75), Text('messageEditedHint'.tr()).fontSize(13).opacity(0.75),
if (_isTranslating)
AnimateWidgetExtensions(Text('translating').tr())
.animate(onPlay: (e) => e.repeat())
.fadeIn(duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
if (_isTranslated)
InkWell(
child: Text('translated').tr().opacity(0.75),
onTap: () {
setState(() {
_displayText = widget.data.body['text'] ?? '';
_isTranslated = false;
});
},
),
], ],
); );
} else if (data.body['attachments']?.isNotEmpty) { } else if (widget.data.body['attachments']?.isNotEmpty) {
return Row( return Row(
children: [ children: [
const Icon(Symbols.file_present, size: 20), const Icon(Symbols.file_present, size: 20),
const Gap(4), const Gap(4),
Text('messageFileHint'.plural(data.body['attachments']!.length)), Text('messageFileHint'
.plural(widget.data.body['attachments']!.length)),
], ],
).opacity(0.8); ).opacity(0.8);
} }

View File

@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@ -445,6 +446,61 @@ class _StickerPicker extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sticker = context.read<SnStickerProvider>(); final sticker = context.read<SnStickerProvider>();
if (sticker.stickersByPack.isEmpty) {
return GestureDetector(
onTap: () {
onDismiss?.call();
},
child: Container(
constraints: BoxConstraints(
maxWidth: min(360, MediaQuery.of(context).size.width - 40),
),
child: Material(
elevation: 8,
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.ar_stickers, size: 48),
const Gap(8),
Text('stickerPickerEmpty').tr().bold(),
Text(
'stickerPickerEmptyHint',
textAlign: TextAlign.center,
).tr().opacity(0.75),
TextButton(
child: Text('goto'.tr(args: ['screenStickers'.tr()])),
onPressed: () {
GoRouter.of(context).goNamed('stickers');
},
),
InkWell(
child: Text(
'stickersReload',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.primary,
),
).tr(),
onTap: () async {
await sticker.listSticker();
if (!context.mounted) return;
HapticFeedback.heavyImpact();
context.showSnackbar('stickersReloaded'.tr());
onDismiss?.call();
},
)
],
).padding(all: 64),
),
),
),
);
}
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
onDismiss?.call(); onDismiss?.call();

View File

@ -0,0 +1,105 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/types/news.dart';
import 'package:surface/types/post.dart';
class NewsFeedEntry extends StatelessWidget {
final SnFeedEntry data;
const NewsFeedEntry({super.key, required this.data});
@override
Widget build(BuildContext context) {
final List<SnNewsArticle> news = data.data
.map((ele) => SnNewsArticle.fromJson(ele))
.cast<SnNewsArticle>()
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Symbols.newspaper),
const Gap(8),
Text(
'newsToday',
style: Theme.of(context).textTheme.titleLarge,
).tr()
],
).padding(horizontal: 18, top: 12, bottom: 8),
Container(
margin: const EdgeInsets.only(bottom: 12),
height: 150,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: news.length,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemBuilder: (context, idx) {
return Container(
width: 360,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Material(
elevation: 0,
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
news[idx].title,
maxLines: 2,
style: Theme.of(context).textTheme.titleMedium,
).padding(horizontal: 16, top: 12, bottom: 4),
Text(
news[idx].description,
maxLines: 2,
style: Theme.of(context).textTheme.bodyMedium,
).padding(horizontal: 16, vertical: 4),
const Gap(4),
Row(
children: [
Text(
DateFormat('y/M/d HH:mm')
.format(news[idx].createdAt.toLocal()),
style: Theme.of(context).textTheme.bodySmall,
),
const Gap(4),
Text(
RelativeTime(context)
.format(news[idx].createdAt.toLocal()),
style: Theme.of(context).textTheme.bodySmall,
),
],
).opacity(0.8).padding(horizontal: 16),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'newsDetail',
pathParameters: {'hash': news[idx].hash},
);
},
),
),
);
},
separatorBuilder: (_, __) => const Gap(12),
),
),
],
);
}
}

View File

@ -0,0 +1,25 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.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/types/post.dart';
class FeedUnknownEntry extends StatelessWidget {
final SnFeedEntry data;
const FeedUnknownEntry({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Symbols.help, size: 36),
const Gap(4),
Text('feedUnknownItem').tr(),
Text(data.type, style: GoogleFonts.robotoMono()),
],
).padding(horizontal: 12, vertical: 8);
}
}

108
lib/widgets/html.dart Normal file
View File

@ -0,0 +1,108 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:html/dom.dart' as dom;
import 'package:surface/widgets/universal_image.dart';
import 'package:url_launcher/url_launcher_string.dart';
List<Widget> parseHtmlToWidgets(
BuildContext context, 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(context, 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 = 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(context, node.children));
break;
}
}
return widgets;
}

View File

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:dismissible_page/dismissible_page.dart'; import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_highlight/flutter_highlight.dart'; import 'package:flutter_highlight/flutter_highlight.dart';
@ -207,10 +209,14 @@ class MarkdownTextContent extends StatelessWidget {
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
width ??= math.min(MediaQuery.of(context).size.width, 640);
height ??= width;
return UniversalImage( return UniversalImage(
url, url,
width: width, width: width,
height: height, height: height,
cacheHeight: height,
cacheWidth: width,
fit: fit, fit: fit,
); );
}, },

103
lib/widgets/menu_bar.dart Normal file
View File

@ -0,0 +1,103 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/navigation.dart';
import 'package:surface/router.dart';
// https://api.flutter.dev/flutter/widgets/PlatformMenuBar-class.html
// All the code following is only works on macOS
class AppSystemMenuBar extends StatelessWidget {
final Function? onQuit;
final Widget child;
const AppSystemMenuBar({super.key, this.onQuit, required this.child});
@override
Widget build(BuildContext context) {
if (kIsWeb || !Platform.isMacOS) return child;
final nav = context.watch<NavigationProvider>();
return PlatformMenuBar(
menus: <PlatformMenuItem>[
PlatformMenu(
label: 'Solian',
menus: <PlatformMenuItem>[
PlatformMenuItemGroup(
members: <PlatformMenuItem>[
PlatformMenuItem(
label: 'screenAbout'.tr(),
onSelected: () {
appRouter.goNamed('about');
nav.autoDetectIndex(appRouter);
},
),
],
),
PlatformMenuItemGroup(
members: [
PlatformMenuItem(
label: 'screenHome'.tr(),
shortcut: const SingleActivator(
LogicalKeyboardKey.digit1,
meta: true,
),
onSelected: () {
appRouter.goNamed('home');
nav.autoDetectIndex(appRouter);
},
),
PlatformMenuItem(
label: 'screenExplore'.tr(),
shortcut: const SingleActivator(
LogicalKeyboardKey.digit2,
meta: true,
),
onSelected: () {
appRouter.goNamed('explore');
nav.autoDetectIndex(appRouter);
},
),
PlatformMenuItem(
label: 'screenChat'.tr(),
shortcut: const SingleActivator(
LogicalKeyboardKey.digit3,
meta: true,
),
onSelected: () {
appRouter.goNamed('chat');
},
),
PlatformMenuItem(
label: 'screenAccount'.tr(),
shortcut: const SingleActivator(
LogicalKeyboardKey.digit4,
meta: true,
),
onSelected: () {
appRouter.goNamed('account');
},
),
],
),
if (onQuit != null)
PlatformMenuItem(
shortcut: const SingleActivator(
LogicalKeyboardKey.keyQ,
meta: true,
),
label: 'trayMenuExit'.tr(),
onSelected: () {
onQuit?.call();
},
),
],
),
],
child: child,
);
}
}

View File

@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:html2md/html2md.dart' as html2md;
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:surface/widgets/universal_image.dart';
class FediversePostWidget extends StatelessWidget {
final SnFediversePost data;
final double maxWidth;
const FediversePostWidget({
super.key,
required this.data,
required this.maxWidth,
});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AccountImage(
content: data.user.avatar,
radius: 20,
),
const Gap(12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.user.nick.isNotEmpty
? data.user.nick
: '@${data.user.name}',
maxLines: 1,
).bold(),
Row(
children: [
Text(
data.user.identifier.contains('@')
? data.user.identifier
: '${data.user.identifier}@${data.user.origin}',
maxLines: 1,
).fontSize(13),
const Gap(4),
Text(
RelativeTime(context)
.format(data.createdAt.toLocal()),
).fontSize(13),
],
),
],
),
],
).padding(horizontal: 12, vertical: 8),
MarkdownTextContent(
isAutoWarp: true,
content: html2md.convert(data.content),
).padding(horizontal: 16, bottom: 6),
if (data.images.isNotEmpty)
_FediversePostImageList(
data: data,
maxWidth: maxWidth,
),
],
),
),
);
}
}
class _FediversePostImageList extends StatelessWidget {
const _FediversePostImageList({
required this.data,
required this.maxWidth,
});
final SnFediversePost data;
final double maxWidth;
@override
Widget build(BuildContext context) {
final borderSide =
BorderSide(width: 1, color: Theme.of(context).dividerColor);
final backgroundColor = Theme.of(context).colorScheme.surfaceContainer;
if (data.images.length == 1) {
return AspectRatio(
aspectRatio: 1,
child: Container(
constraints: BoxConstraints(maxWidth: maxWidth),
decoration: BoxDecoration(
color: backgroundColor,
border: Border(
top: borderSide,
bottom: borderSide,
),
borderRadius: AttachmentList.kDefaultRadius,
),
child: ClipRRect(
borderRadius: AttachmentList.kDefaultRadius,
child: AutoResizeUniversalImage(
data.images.first,
),
),
),
).padding(horizontal: 8);
}
return AspectRatio(
aspectRatio: 1,
child: ScrollConfiguration(
behavior: AttachmentListScrollBehavior(),
child: ListView.separated(
shrinkWrap: true,
itemCount: data.images.length,
itemBuilder: (context, idx) {
return Container(
constraints: BoxConstraints(maxWidth: maxWidth),
child: AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: BoxDecoration(
color: backgroundColor,
border: Border(
top: borderSide,
bottom: borderSide,
),
borderRadius: AttachmentList.kDefaultRadius,
),
child: ClipRRect(
borderRadius: AttachmentList.kDefaultRadius,
child: AutoResizeUniversalImage(
data.images[idx],
),
),
),
Positioned(
right: 8,
bottom: 8,
child: Chip(
label: Text('${idx + 1}/${data.images.length}'),
),
),
],
),
),
);
},
separatorBuilder: (context, index) => const Gap(8),
physics: const BouncingScrollPhysics(),
scrollDirection: Axis.horizontal,
),
),
);
}
}

View File

@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart'; import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart'; import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
@ -14,14 +15,13 @@ import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_mini_editor.dart'; import 'package:surface/widgets/post/post_mini_editor.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../../providers/sn_network.dart';
class PostCommentQuickAction extends StatelessWidget { class PostCommentQuickAction extends StatelessWidget {
final double? maxWidth; final double? maxWidth;
final SnPost parentPost; final SnPost parentPost;
final Function? onPosted; final Function? onPosted;
const PostCommentQuickAction({super.key, this.maxWidth, required this.parentPost, this.onPosted}); const PostCommentQuickAction(
{super.key, this.maxWidth, required this.parentPost, this.onPosted});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -30,7 +30,9 @@ class PostCommentQuickAction extends StatelessWidget {
return Container( return Container(
height: 240, height: 240,
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
margin: ResponsiveBreakpoints.of(context).largerThan(MOBILE) ? const EdgeInsets.symmetric(vertical: 8) : EdgeInsets.zero, margin: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? const EdgeInsets.symmetric(vertical: 8)
: EdgeInsets.zero,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE) borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? const BorderRadius.all(Radius.circular(8)) ? const BorderRadius.all(Radius.circular(8))
@ -99,7 +101,8 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
Future<void> _selectAnswer(SnPost answer) async { Future<void> _selectAnswer(SnPost answer) async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.put('/cgi/co/questions/${widget.parentPost.id}/answer', data: { await sn.client
.put('/cgi/co/questions/${widget.parentPost.id}/answer', data: {
'publisher': answer.publisherId, 'publisher': answer.publisherId,
'answer_id': answer.id, 'answer_id': answer.id,
}); });
@ -135,7 +138,10 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
child: PostItem( child: PostItem(
data: _posts[idx], data: _posts[idx],
maxWidth: widget.maxWidth, maxWidth: widget.maxWidth,
onSelectAnswer: widget.parentPost.type == 'question' ? () => _selectAnswer(_posts[idx]) : null, showExpandableComments: true,
onSelectAnswer: widget.parentPost.type == 'question'
? () => _selectAnswer(_posts[idx])
: null,
onChanged: (data) { onChanged: (data) {
setState(() => _posts[idx] = data); setState(() => _posts[idx] = data);
}, },
@ -153,7 +159,8 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
}, },
); );
}, },
separatorBuilder: (context, index) => const Divider(height: 1), separatorBuilder: (context, index) =>
const Divider().padding(vertical: 2),
); );
} }
} }
@ -161,11 +168,13 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
class PostCommentListPopup extends StatefulWidget { class PostCommentListPopup extends StatefulWidget {
final SnPost post; final SnPost post;
final int commentCount; final int commentCount;
final int depth;
const PostCommentListPopup({ const PostCommentListPopup({
super.key, super.key,
required this.post, required this.post,
this.commentCount = 0, this.commentCount = 0,
this.depth = 1,
}); });
@override @override
@ -180,48 +189,54 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
final ua = context.watch<UserProvider>(); final ua = context.watch<UserProvider>();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Column( return SizedBox(
crossAxisAlignment: CrossAxisAlignment.start, height: MediaQuery.of(context).size.height * 0.85,
children: [ child: Column(
Row( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center, children: [
children: [ Row(
const Icon(Symbols.comment, size: 24), crossAxisAlignment: CrossAxisAlignment.center,
const Gap(16), children: [
Text('postCommentsDetailed').plural(widget.commentCount).textStyle(Theme.of(context).textTheme.titleLarge!), const Icon(Symbols.comment, size: 24),
], const Gap(16),
).padding(horizontal: 20, top: 16, bottom: 12), Text('postCommentsDetailed')
Expanded( .plural(widget.commentCount)
child: CustomScrollView( .textStyle(Theme.of(context).textTheme.titleLarge!),
slivers: [ ],
if (ua.isAuthorized) ).padding(horizontal: 20, top: 16, bottom: 12),
SliverToBoxAdapter( Expanded(
child: Container( child: CustomScrollView(
height: 240, slivers: [
decoration: BoxDecoration( if (ua.isAuthorized)
border: Border.symmetric( SliverToBoxAdapter(
horizontal: BorderSide( child: Container(
color: Theme.of(context).dividerColor, margin: const EdgeInsets.only(bottom: 8),
width: 1 / devicePixelRatio, height: 240,
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
), ),
), ),
), child: PostMiniEditor(
child: PostMiniEditor( postReplyId: widget.post.id,
postReplyId: widget.post.id, onPost: () {
onPost: () { _childListKey.currentState!.refresh();
_childListKey.currentState!.refresh(); },
}, ),
), ),
), ),
PostCommentSliverList(
parentPost: widget.post,
key: _childListKey,
), ),
PostCommentSliverList( ],
parentPost: widget.post, ),
key: _childListKey,
),
],
), ),
), ],
], ),
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,8 @@ class PostMiniEditor extends StatefulWidget {
} }
class _PostMiniEditorState extends State<PostMiniEditor> { class _PostMiniEditorState extends State<PostMiniEditor> {
final PostWriteController _writeController = PostWriteController(doLoadFromTemporary: false); final PostWriteController _writeController =
PostWriteController(doLoadFromTemporary: false);
bool _isFetching = false; bool _isFetching = false;
@ -44,8 +45,9 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
); );
final beforeId = config.prefs.getInt('int_last_publisher_id'); final beforeId = config.prefs.getInt('int_last_publisher_id');
_writeController _writeController.setPublisher(
.setPublisher(_publishers?.where((ele) => ele.id == beforeId).firstOrNull ?? _publishers?.firstOrNull); _publishers?.where((ele) => ele.id == beforeId).firstOrNull ??
_publishers?.firstOrNull);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -99,11 +101,17 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
Expanded( Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!), Text(item.nick).textStyle(
Theme.of(context)
.textTheme
.bodyMedium!),
Text('@${item.name}') Text('@${item.name}')
.textStyle(Theme.of(context).textTheme.bodySmall!) .textStyle(Theme.of(context)
.textTheme
.bodySmall!)
.fontSize(12), .fontSize(12),
], ],
), ),
@ -120,7 +128,8 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
CircleAvatar( CircleAvatar(
radius: 16, radius: 16,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.onSurface, foregroundColor:
Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
), ),
const Gap(8), const Gap(8),
@ -129,7 +138,8 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!), Text('publishersNew').tr().textStyle(
Theme.of(context).textTheme.bodyMedium!),
], ],
), ),
), ),
@ -140,7 +150,9 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
value: _writeController.publisher, value: _writeController.publisher,
onChanged: (SnPublisher? value) { onChanged: (SnPublisher? value) {
if (value == null) { if (value == null) {
GoRouter.of(context).pushNamed('accountPublisherNew').then((value) { GoRouter.of(context)
.pushNamed('accountPublisherNew')
.then((value) {
if (value == true) { if (value == true) {
_publishers = null; _publishers = null;
_fetchPublishers(); _fetchPublishers();
@ -176,7 +188,8 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
), ),
border: InputBorder.none, border: InputBorder.none,
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
), ),
const Gap(8), const Gap(8),
@ -185,7 +198,8 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
TweenAnimationBuilder<double>( TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _writeController.progress), tween: Tween(begin: 0, end: _writeController.progress),
duration: Duration(milliseconds: 300), duration: Duration(milliseconds: 300),
builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2), builder: (context, value, _) =>
LinearProgressIndicator(value: value, minHeight: 2),
) )
else if (_writeController.isBusy) else if (_writeController.isBusy)
const LinearProgressIndicator(value: null, minHeight: 2), const LinearProgressIndicator(value: null, minHeight: 2),
@ -200,15 +214,17 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
onPressed: () { onPressed: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postEditor', 'postEditor',
pathParameters: {'mode': 'stories'},
queryParameters: { queryParameters: {
if (widget.postReplyId != null) 'replying': widget.postReplyId.toString(), if (widget.postReplyId != null)
'replying': widget.postReplyId.toString(),
'mode': 'stories',
}, },
); );
}, },
), ),
TextButton.icon( TextButton.icon(
onPressed: (_writeController.isBusy || _writeController.publisher == null) onPressed: (_writeController.isBusy ||
_writeController.publisher == null)
? null ? null
: () { : () {
_writeController.sendPost(context).then((_) { _writeController.sendPost(context).then((_) {

View File

@ -80,59 +80,64 @@ class _PostPollState extends State<PostPoll> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return LayoutBuilder(
margin: EdgeInsets.zero, builder: (context, constraints) {
child: Column( return Card(
children: [ margin: EdgeInsets.zero,
for (final option in _poll.options) child: Column(
Stack( children: [
children: [ for (final option in _poll.options)
ClipRRect( Stack(
borderRadius: const BorderRadius.all(Radius.circular(8)), children: [
child: Container( ClipRRect(
height: 60, borderRadius: const BorderRadius.all(Radius.circular(8)),
width: MediaQuery.of(context).size.width * child: Container(
(_poll.metric.byOptionsPercentage[option.id] ?? 0) height: 60,
.toDouble(), width: constraints.maxWidth *
color: Theme.of(context).colorScheme.surfaceContainerHigh, (_poll.metric.byOptionsPercentage[option.id] ?? 0)
), .toDouble(),
), color:
ListTile( Theme.of(context).colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder( ),
borderRadius: BorderRadius.circular(8), ),
), ListTile(
minTileHeight: 60, shape: RoundedRectangleBorder(
leading: _answeredChoice == option.id borderRadius: BorderRadius.circular(8),
? const Icon(Symbols.circle, fill: 1) ),
: const Icon(Symbols.circle), minTileHeight: 60,
title: Text(option.name), leading: _answeredChoice == option.id
subtitle: Column( ? const Icon(Symbols.circle, fill: 1)
crossAxisAlignment: CrossAxisAlignment.start, : const Icon(Symbols.circle),
mainAxisSize: MainAxisSize.min, title: Text(option.name),
children: [ subtitle: Column(
Row( crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Row(
'pollVotes' mainAxisSize: MainAxisSize.min,
.plural(_poll.metric.byOptions[option.id] ?? 0), children: [
), Text(
Text(' · ').padding(horizontal: 4), 'pollVotes'.plural(
Text( _poll.metric.byOptions[option.id] ?? 0),
'${((_poll.metric.byOptionsPercentage[option.id] ?? 0).toDouble() * 100).toStringAsFixed(2)}%', ),
Text(' · ').padding(horizontal: 4),
Text(
'${((_poll.metric.byOptionsPercentage[option.id] ?? 0).toDouble() * 100).toStringAsFixed(2)}%',
),
],
), ),
if (option.description.isNotEmpty)
Text(option.description),
], ],
), ),
if (option.description.isNotEmpty) onTap: _isBusy ? null : () => _voteForOption(option),
Text(option.description), ),
], ],
), )
onTap: _isBusy ? null : () => _voteForOption(option), ],
), ),
], );
) },
],
),
); );
} }
} }

View File

@ -9,10 +9,9 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/account/badge.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import '../../screens/account/profile_page.dart' show kBadgesMeta;
class PublisherPopoverCard extends StatelessWidget { class PublisherPopoverCard extends StatelessWidget {
final SnPublisher data; final SnPublisher data;
@ -76,39 +75,22 @@ class PublisherPopoverCard extends StatelessWidget {
const Gap(8) const Gap(8)
], ],
).padding(horizontal: 16), ).padding(horizontal: 16),
if (user != null && user.badges.isNotEmpty) const Gap(16),
if (user != null && user.badges.isNotEmpty) if (user != null && user.badges.isNotEmpty)
Wrap( Wrap(
spacing: 4, spacing: 4,
children: user.badges children: user.badges
.map( .map(
(ele) => Tooltip( (ele) => AccountBadge(badge: ele),
richMessage: TextSpan(
children: [
TextSpan(
text: kBadgesMeta[ele.type]?.$1.tr() ??
'unknown'.tr()),
if (ele.metadata['title'] != null)
TextSpan(
text: '\n${ele.metadata['title']}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: '\n'),
TextSpan(
text: DateFormat.yMEd().format(ele.createdAt),
),
],
),
child: Icon(
kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
color: kBadgesMeta[ele.type]?.$3,
fill: 1,
),
),
) )
.toList(), .toList(),
).padding(horizontal: 24), ).padding(horizontal: 24, top: 16),
const Gap(16), const Gap(16),
if (data.description.isNotEmpty)
Text(
data.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).padding(horizontal: 26, bottom: 20),
Row( Row(
children: [ children: [
Expanded( Expanded(

View File

@ -14,36 +14,37 @@ class UnauthorizedHint extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
child: Container( child: Container(
constraints: const BoxConstraints(maxWidth: 280), constraints: const BoxConstraints(maxWidth: 280),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Symbols.login, size: 36), const Icon(Symbols.login, size: 36),
const Gap(8), const Gap(8),
Text( Text(
'unauthorized', 'unauthorized',
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
).tr(), textAlign: TextAlign.center,
const Gap(8), ).tr(),
Text( const Gap(8),
'unauthorizedDescription', Text(
style: Theme.of(context).textTheme.bodyMedium, 'unauthorizedDescription',
).tr(), style: Theme.of(context).textTheme.bodyMedium,
], textAlign: TextAlign.center,
).tr(),
],
),
), ),
), onTap: () {
onTap: () { GoRouter.of(context).pushNamed('authLogin').then((value) {
GoRouter.of(context).pushNamed('authLogin').then((value) { if (value == true && context.mounted) {
if (value == true && context.mounted) { final ua = context.read<UserProvider>();
final ua = context.read<UserProvider>(); context.showSnackbar('loginSuccess'.tr(args: [
context.showSnackbar('loginSuccess'.tr(args: [ '@${ua.user?.name} (${ua.user?.nick})',
'@${ua.user?.name} (${ua.user?.nick})', ]));
])); }
} });
}); });
}
);
} }
} }

View File

@ -34,11 +34,14 @@ class UniversalImage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final double? resizeHeight = cacheHeight != null ? (cacheHeight! * devicePixelRatio) : null; final double? resizeHeight =
final double? resizeWidth = cacheWidth != null ? (cacheWidth! * devicePixelRatio) : null; cacheHeight != null ? (cacheHeight! * devicePixelRatio) : null;
final double? resizeWidth =
cacheWidth != null ? (cacheWidth! * devicePixelRatio) : null;
return Image( return Image(
filterQuality: filterQuality ?? context.read<ConfigProvider>().imageQuality, filterQuality:
filterQuality ?? context.read<ConfigProvider>().imageQuality,
image: kIsWeb image: kIsWeb
? UniversalImage.provider(url) ? UniversalImage.provider(url)
: ResizeImage( : ResizeImage(
@ -52,7 +55,8 @@ class UniversalImage extends StatelessWidget {
fit: fit, fit: fit,
loadingBuilder: noProgressIndicator loadingBuilder: noProgressIndicator
? null ? null
: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { : (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child; if (loadingProgress == null) return child;
return Container( return Container(
constraints: BoxConstraints(maxHeight: 80), constraints: BoxConstraints(maxHeight: 80),
@ -61,12 +65,15 @@ class UniversalImage extends StatelessWidget {
tween: Tween( tween: Tween(
begin: 0, begin: 0,
end: loadingProgress.expectedTotalBytes != null end: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! ? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: 0, : 0,
), ),
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator( builder: (context, value, _) => CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null ? value.toDouble() : null, value: loadingProgress.expectedTotalBytes != null
? value.toDouble()
: null,
), ),
), ),
), ),
@ -114,6 +121,7 @@ class AutoResizeUniversalImage extends StatelessWidget {
final BoxFit? fit; final BoxFit? fit;
final bool noProgressIndicator; final bool noProgressIndicator;
final bool noErrorWidget; final bool noErrorWidget;
final FilterQuality? filterQuality;
const AutoResizeUniversalImage( const AutoResizeUniversalImage(
this.url, { this.url, {
@ -123,6 +131,7 @@ class AutoResizeUniversalImage extends StatelessWidget {
this.fit, this.fit,
this.noProgressIndicator = false, this.noProgressIndicator = false,
this.noErrorWidget = false, this.noErrorWidget = false,
this.filterQuality,
}); });
@override @override
@ -137,6 +146,7 @@ class AutoResizeUniversalImage extends StatelessWidget {
noErrorWidget: noErrorWidget, noErrorWidget: noErrorWidget,
cacheHeight: constraints.maxHeight, cacheHeight: constraints.maxHeight,
cacheWidth: constraints.maxWidth, cacheWidth: constraints.maxWidth,
filterQuality: filterQuality,
); );
}); });
} }

View File

@ -20,7 +20,6 @@ import flutter_timezone
import flutter_udid import flutter_udid
import flutter_webrtc import flutter_webrtc
import gal import gal
import geolocator_apple
import hotkey_manager_macos import hotkey_manager_macos
import in_app_review import in_app_review
import livekit_client import livekit_client
@ -56,7 +55,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterUdidPlugin.register(with: registry.registrar(forPlugin: "FlutterUdidPlugin")) FlutterUdidPlugin.register(with: registry.registrar(forPlugin: "FlutterUdidPlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin")) HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin")) InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin")) LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))

View File

@ -90,8 +90,6 @@ PODS:
- gal (1.0.0): - gal (1.0.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- geolocator_apple (1.2.0):
- FlutterMacOS
- GoogleAppMeasurement (11.8.0): - GoogleAppMeasurement (11.8.0):
- GoogleAppMeasurement/AdIdSupport (= 11.8.0) - GoogleAppMeasurement/AdIdSupport (= 11.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.0)
@ -148,7 +146,7 @@ PODS:
- HotKey - HotKey
- in_app_review (2.0.0): - in_app_review (2.0.0):
- FlutterMacOS - FlutterMacOS
- livekit_client (2.4.0): - livekit_client (2.4.1):
- flutter_webrtc - flutter_webrtc
- FlutterMacOS - FlutterMacOS
- WebRTC-SDK (= 125.6422.06) - WebRTC-SDK (= 125.6422.06)
@ -232,7 +230,6 @@ DEPENDENCIES:
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`) - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`)
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos`)
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`) - hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`) - in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
- livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`) - livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
@ -307,8 +304,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral :path: Flutter/ephemeral
gal: gal:
:path: Flutter/ephemeral/.symlinks/plugins/gal/darwin :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
geolocator_apple:
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos
hotkey_manager_macos: hotkey_manager_macos:
:path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos
in_app_review: in_app_review:
@ -372,14 +367,13 @@ SPEC CHECKSUMS:
flutter_webrtc: d55fd3f5c75b42940b6b4b2cf376a5797398d1f8 flutter_webrtc: d55fd3f5c75b42940b6b4b2cf376a5797398d1f8
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5 gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
geolocator_apple: 72a78ae3f3e4ec0db62117bd93e34523f5011d58
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896 GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager_macos: 1e2edb0c7ae4fe67108af44a9d3445de41404160 hotkey_manager_macos: 1e2edb0c7ae4fe67108af44a9d3445de41404160
in_app_review: a6a031b9acd03c7d103e341aa334adf2c493fb93 in_app_review: a6a031b9acd03c7d103e341aa334adf2c493fb93
livekit_client: 2e766be2c3ee6274a8e2633b356b98b5eb842987 livekit_client: d03409f83df069a1bb00a4c8dc78c54fb2287262
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82 media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5 media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5

View File

@ -53,10 +53,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: args name: args
sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.0" version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@ -173,10 +173,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: built_value name: built_value
sha256: "8b158ab94ec6913e480dc3f752418348b5ae099eb75868b5f4775f0572999c61" sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.9.4" version: "8.9.5"
cached_network_image: cached_network_image:
dependency: "direct main" dependency: "direct main"
description: description:
@ -213,10 +213,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: chalkdart name: chalkdart
sha256: "08c910ee341fcdd1e46f20ddce59b13c1d85f5d97f2fd2f12014c46ede670e40" sha256: e7cfcc9a9d9546843304c1ff87fe0696c7eb82ee70e6df63f555f321b15a40d8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.3"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -301,10 +301,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: croppy name: croppy
sha256: bf99b00023df0d7d047e04d27d496d87cbefd968f578d0bd30f342ff75570a12 sha256: "2a69059d9ec007b79d6a494854094b2e3c0a4f7ed609cf55a4805c9de9ec171d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.6"
cross_file: cross_file:
dependency: "direct main" dependency: "direct main"
description: description:
@ -314,7 +314,7 @@ packages:
source: hosted source: hosted
version: "0.3.4+2" version: "0.3.4+2"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
@ -429,18 +429,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: drift name: drift
sha256: "97d5832657d49f26e7a8e07de397ddc63790b039372878d5117af816d0fdb5cb" sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.25.1" version: "2.26.0"
drift_dev: drift_dev:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: drift_dev name: drift_dev
sha256: f1db88482dbb016b9bbddddf746d5d0a6938b156ff20e07320052981f97388cc sha256: "0d3f8b33b76cf1c6a82ee34d9511c40957549c4674b8f1688609e6d6c7306588"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.25.2" version: "2.26.0"
drift_flutter: drift_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@ -541,10 +541,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: "7423298f08f6fc8cce05792bae329f9a93653fc9c08712831b1a55540127995d" sha256: ee11ce89f8937c39181bc88d57a455972f7545b86150d8f287d0d9cf95bcdf0a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.0.2" version: "9.1.0"
file_saver: file_saver:
dependency: "direct main" dependency: "direct main"
description: description:
@ -702,6 +702,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.2" version: "3.2.2"
flutter_blurhash:
dependency: "direct main"
description:
name: flutter_blurhash
sha256: "5e67678e479ac639069d7af1e133f4a4702311491188ff3e0227486430db0c06"
url: "https://pub.dev"
source: hosted
version: "0.8.2"
flutter_cache_manager: flutter_cache_manager:
dependency: "direct main" dependency: "direct main"
description: description:
@ -710,6 +718,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.1" version: "3.4.1"
flutter_card_swiper:
dependency: "direct main"
description:
name: flutter_card_swiper
sha256: "1eacbfab31b572223042e03409726553aec431abe48af48c8d591d376d070d3d"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
flutter_colorpicker: flutter_colorpicker:
dependency: "direct main" dependency: "direct main"
description: description:
@ -831,10 +847,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_map name: flutter_map
sha256: bbf145e8220531f2f727608c431871c7457f3b134e513543913afd00fdc1cd47 sha256: f7d0379477274f323c3f3bc12d369a2b42eb86d1e7bd2970ae1ea3cff782449a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.0" version: "8.1.1"
flutter_markdown: flutter_markdown:
dependency: "direct main" dependency: "direct main"
description: description:
@ -929,10 +945,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_webrtc name: flutter_webrtc
sha256: "6ea3a86d95b61cfe42d5715426d355b3cece6c88d0119de428d56f6c653811ce" sha256: b832dc76c0d1577f14aaf35e9c38d4ed7667cbc89c492b7bf4505d8d5f62e08b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.11" version: "0.12.12+hotfix.1"
freezed: freezed:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -973,54 +989,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.1"
geolocator:
dependency: "direct main"
description:
name: geolocator
sha256: d2ec66329cab29cb297d51d96c067d457ca519dca8589665fa0b82ebacb7dbe4
url: "https://pub.dev"
source: hosted
version: "13.0.2"
geolocator_android:
dependency: transitive
description:
name: geolocator_android
sha256: "7aefc530db47d90d0580b552df3242440a10fe60814496a979aa67aa98b1fd47"
url: "https://pub.dev"
source: hosted
version: "4.6.1"
geolocator_apple:
dependency: transitive
description:
name: geolocator_apple
sha256: c4ecead17985ede9634f21500072edfcb3dba0ef7b97f8d7bc556d2d722b3ba3
url: "https://pub.dev"
source: hosted
version: "2.3.9"
geolocator_platform_interface:
dependency: transitive
description:
name: geolocator_platform_interface
sha256: "386ce3d9cce47838355000070b1d0b13efb5bc430f8ecda7e9238c8409ace012"
url: "https://pub.dev"
source: hosted
version: "4.2.4"
geolocator_web:
dependency: transitive
description:
name: geolocator_web
sha256: "2ed69328e05cd94e7eb48bb0535f5fc0c0c44d1c4fa1e9737267484d05c29b5e"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
geolocator_windows:
dependency: transitive
description:
name: geolocator_windows
sha256: "53da08937d07c24b0d9952eb57a3b474e29aae2abf9dd717f7e1230995f13f0e"
url: "https://pub.dev"
source: hosted
version: "0.2.3"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@ -1125,6 +1093,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.15.5" version: "0.15.5"
html2md:
dependency: "direct main"
description:
name: html2md
sha256: "465cf8ffa1b510fe0e97941579bf5b22e2d575f2cecb500a9c0254efe33a8036"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
http: http:
dependency: transitive dependency: transitive
description: description:
@ -1177,10 +1153,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker_android name: image_picker_android
sha256: "82652a75e3dd667a91187769a6a2cc81bd8c111bbead698d8e938d2b63e5e89a" sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.12+21" version: "0.8.12+22"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@ -1286,7 +1262,7 @@ packages:
source: hosted source: hosted
version: "6.9.4" version: "6.9.4"
latlong2: latlong2:
dependency: transitive dependency: "direct main"
description: description:
name: latlong2 name: latlong2
sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe"
@ -1345,10 +1321,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: livekit_client name: livekit_client
sha256: "753bbf484c6b70f10f3dc1dc808dfe3755f472d80eb9682323cff07ad8e2609d" sha256: "7f489fa415253d8d99c649b7efc95a733c5e5ac38dcfb02362ced99feb139376"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.1"
local_notifier: local_notifier:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1409,10 +1385,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: material_symbols_icons name: material_symbols_icons
sha256: "1403944c2a68dbdf934fca2b2115a372e70a4a185c136ab71a9d16e2108d0b14" sha256: db745002d0323c32097f5bc23711c02b0fb36ef616011a38c8c1798d5684d368
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2805.1" version: "4.2810.0"
media_kit: media_kit:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1545,10 +1521,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: package_config name: package_config
sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.2.0"
package_info_plus: package_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1601,10 +1577,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.15" version: "2.2.16"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
@ -1785,10 +1761,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: pub_semver name: pub_semver
sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.2.0"
pubspec_parse: pubspec_parse:
dependency: transitive dependency: transitive
description: description:
@ -1953,10 +1929,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: a768fc8ede5f0c8e6150476e14f38e2417c0864ca36bb4582be8e21925a03c22 sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.6" version: "2.4.8"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
@ -2118,10 +2094,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqlite3 name: sqlite3
sha256: "32b632dda27d664f85520093ed6f735ae5c49b5b75345afb8b19411bc59bb53d" sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.7.4" version: "2.7.5"
sqlite3_flutter_libs: sqlite3_flutter_libs:
dependency: transitive dependency: transitive
description: description:
@ -2198,34 +2174,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: talker name: talker
sha256: "5ab7d974ad92042b3e2382441c41ec4c6e5b3fa2b4b024d8ccbfc4bc2244b7bb" sha256: a074e0a2c1db1b09c1856050c5284a14ff64faea003403409871ab8335738d08
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.6.14" version: "4.7.0"
talker_dio_logger: talker_dio_logger:
dependency: "direct main" dependency: "direct main"
description: description:
name: talker_dio_logger name: talker_dio_logger
sha256: "71780c52951d36e94964ca06158d827dfc67aa2fb75c8b880603cfefa4377b39" sha256: "6d713d26bdf90c7440222758a4e02ebfbdb3b323cdbdb50b78082db720f69427"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.6.14" version: "4.7.0"
talker_flutter: talker_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: talker_flutter name: talker_flutter
sha256: "0cc816260b226c0ff930909c9f22984316b652b140f5eabb97ae9813ee0de135" sha256: "25a9faa98a0dcbab8d1ec366f19b3e91d422ede523461ec6e26e198b280cb98d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.6.14" version: "4.7.0"
talker_logger: talker_logger:
dependency: transitive dependency: transitive
description: description:
name: talker_logger name: talker_logger
sha256: "16ff0cfdf011f65b37957c9ff7ef7043dd9f1c8af3ccb4a44ac4a448defb9eb5" sha256: "0a1a295a9d036a3416e89c7155eaaca9ca6e3e0abb5401a40597f98b9cfc8fe6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.6.14" version: "4.7.0"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -2242,6 +2218,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.4"
timelines_plus:
dependency: "direct main"
description:
name: timelines_plus
sha256: be31f493402dc24df7fe410dc5f82a605807bb4ca13183de6d4362886449b593
url: "https://pub.dev"
source: hosted
version: "1.0.6"
timing: timing:
dependency: transitive dependency: transitive
description: description:
@ -2326,10 +2310,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.14" version: "6.3.15"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@ -2518,10 +2502,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: b89e6e24d1454e149ab20fbb225af58660f0c0bf4475544650700d8e2da54aef sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.11.0" version: "5.12.0"
win32_registry: win32_registry:
dependency: transitive dependency: transitive
description: description:

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2.4.2+77 version: 2.4.2+81
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@ -135,9 +135,14 @@ dependencies:
talker: ^4.6.14 talker: ^4.6.14
flutter_cache_manager: ^3.4.1 flutter_cache_manager: ^3.4.1
flutter_timezone: ^4.1.0 flutter_timezone: ^4.1.0
flutter_map: ^8.1.0 flutter_map: ^8.1.1
geolocator: ^13.0.2
fast_rsa: ^3.8.0 fast_rsa: ^3.8.0
flutter_card_swiper: ^7.0.2
html2md: ^1.3.2
flutter_blurhash: ^0.8.2
timelines_plus: ^1.0.6
latlong2: ^0.9.1
crypto: ^3.0.6
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -17,7 +17,6 @@
#include <flutter_udid/flutter_udid_plugin_c_api.h> #include <flutter_udid/flutter_udid_plugin_c_api.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h> #include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <gal/gal_plugin_c_api.h> #include <gal/gal_plugin_c_api.h>
#include <geolocator_windows/geolocator_windows.h>
#include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h> #include <hotkey_manager_windows/hotkey_manager_windows_plugin_c_api.h>
#include <livekit_client/live_kit_plugin.h> #include <livekit_client/live_kit_plugin.h>
#include <local_notifier/local_notifier_plugin.h> #include <local_notifier/local_notifier_plugin.h>
@ -54,8 +53,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
GalPluginCApiRegisterWithRegistrar( GalPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GalPluginCApi")); registry->GetRegistrarForPlugin("GalPluginCApi"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
HotkeyManagerWindowsPluginCApiRegisterWithRegistrar( HotkeyManagerWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi")); registry->GetRegistrarForPlugin("HotkeyManagerWindowsPluginCApi"));
LiveKitPluginRegisterWithRegistrar( LiveKitPluginRegisterWithRegistrar(

View File

@ -14,7 +14,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_udid flutter_udid
flutter_webrtc flutter_webrtc
gal gal
geolocator_windows
hotkey_manager_windows hotkey_manager_windows
livekit_client livekit_client
local_notifier local_notifier