Compare commits

..

49 Commits

Author SHA1 Message Date
6e6c3f42f6 🚀 Launch 2.4.2+84 2025-03-22 20:28:53 +08:00
dc38b46b2c Support captcha 2025-03-22 20:24:05 +08:00
b4990308e9 ♻️ Refactored nav completely 2025-03-22 18:39:01 +08:00
237abe564d ♻️ Refactored drawer nav 2025-03-22 18:14:36 +08:00
71b41d470a Splash screen loading 2025-03-22 16:36:10 +08:00
7052b5b635 Join channel hint
🗃️ Realm local db
2025-03-22 14:12:46 +08:00
f356e08f79 💄 New navigation draft (skip ci) 2025-03-22 12:48:55 +08:00
152872db65 💄 Show nick instead of name in typing indicator 2025-03-22 00:16:59 +08:00
dfe117d04f Auth preference screen 2025-03-22 00:14:55 +08:00
caf63f0cbe Notification preferences 2025-03-21 23:59:42 +08:00
b8f5cc82f9 Add attachments from file 2025-03-20 23:20:24 +08:00
360bc50f21 🐛 Fix linux G_APPLICATION_FLAGS_NONE api deprecated 2025-03-20 22:52:32 +08:00
2de93a0486 🐛 Close #15 with vide coding (not tested) 2025-03-20 22:45:53 +08:00
02227852f8 🚀 Launch 2.4.2+83 for some platforms 2025-03-19 00:48:36 +08:00
ad16de595b 🐛 Fix menubar missing hide 2025-03-19 00:30:58 +08:00
9f8c8923d9 🐛 Bug fixes in posts 2025-03-19 00:29:29 +08:00
060bfa4887 🐛 Fix explore unmixed feed pagination issue 2025-03-19 00:23:57 +08:00
e68ada2d04 💄 Optimize post comments 2025-03-19 00:21:54 +08:00
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
93 changed files with 10137 additions and 1569 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 {
url: {{endpoint}}/cgi/id/reports/abuse/3/status
url: {{endpoint}}/cgi/id/reports/abuse/6/status
body: json
auth: inherit
}
body:json {
{
"status": "processed",
"message": "相关附件已经进行评级处理,未来会将该项权限下放到帖主以及社区成员。"
"status": "rejected",
"message": "Not a good reason"
}
}

View File

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

View File

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

View File

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

View File

@ -130,7 +130,7 @@
"accountPublishersSubtitle": "Manage your publish identities.",
"accountSettings": "Account Settings",
"accountSettingsSubtitle": "Manage your account and make it yours.",
"accountProfileEdit": "Edit your profile",
"accountProfileEdit": "Edit Profile",
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
"accountWallet": "Wallet",
"accountWalletSubtitle": "View your balance and transactions.",
@ -207,6 +207,7 @@
"one": "{} comment",
"other": "{} comments"
},
"postCommentExpand": "Show comments",
"settingsAppearance": "Appearance",
"settingsCustomFonts": "Custom Fonts",
"settingsCustomFontsDescription": "Set custom fonts for the application.",
@ -337,6 +338,7 @@
"fieldAttachmentRandomId": "Random ID",
"fieldAttachmentAlt": "Alternative text",
"addAttachmentFromAlbum": "Add from album",
"addAttachmentFromFiles": "Add from files",
"addAttachmentFromClipboard": "Paste file",
"addAttachmentFromCameraPhoto": "Take photo",
"addAttachmentFromCameraVideo": "Take video",
@ -791,5 +793,102 @@
"fieldAccountStatusClearAt": "Clear At",
"accountStatusNegative": "Negative",
"accountStatusNeutral": "Neutral",
"accountStatusPositive": "Positive"
"accountStatusPositive": "Positive",
"mixedFeed": "Mixed Feed",
"mixedFeedDescription": "The Explore screen may not only display the user's posts, but may also contain other content. However, this mode does not apply to classification and filtering.",
"filterFeed": "Exploring Adjust",
"feedUnknownItem": "Unable to display this content, the current version of the client does not support the type of content, please try to update the application afterwards.",
"serviceStatusOperational": "All services operational",
"serviceStatusDowngraded": "Some services downgraded",
"serviceStatusFailed": "All services unavailable",
"serviceStatusFailedDescription": "The server is down or the maintenance is just finished.",
"serviceNameInsights": "Summarize and Insights",
"serviceNameInteractive": "Posts, Reactions and Explore",
"serviceNameReader": "News and Link Previews",
"serviceNameMessaging": "Chat",
"serviceNameMatrix": "Matrix Software and Game Marketplace",
"serviceNamePaperclip": "Attachments, Images and Files",
"serviceNameWallet": "Source Points Wallet",
"serviceNamePassport": "Authorization and Authentication",
"accountActionEvent": "Action Events",
"accountActionEventDescription": "View your action event logs.",
"eventMetadata": "Metadata",
"accountAuthTickets": "Auth Sessions",
"accountAuthTicketsDescription": "View and manage your auth sessions.",
"authTicketCreatedAt": "Issued at {}",
"authTicketExpiredAt": "Expired at {}",
"authTicketLastGrantAt": "Last granted at {}",
"authTicketCurrent": "Current",
"accountUnconfirmedTitle": "Unconfirmed Account",
"accountUnconfirmedSubtitle": "Your account is unconfirmed, which will make most features unavailable and your account will be destroyed in 24 hours. You should receive an email in your inbox with a confirmation link.",
"accountUnconfirmedUnreceived": "Didn't receive the email?",
"accountUnconfirmedResend": "Resend one",
"accountUnconfirmedResendSuccessful": "Email has been resent, you can resend it again in 60 minutes.",
"stickerPickerEmpty": "Sticker list is empty",
"stickerPickerEmptyHint": "To start using stickers, you need to add a sticker pack first.",
"goto": "Go to {}",
"accountContactMethods": "Contact Methods",
"accountContactMethodsDescription": "Manage your contact methods.",
"accountContactMethodsNameEmail": "Email address",
"accountContactMethodsNamePhone": "Phone number",
"accountContactMethodsNameAddress": "Address",
"accountContactMethodsPrimary": "Primary",
"accountContactMethodsVerified": "Verified",
"accountContactMethodsPublic": "Public",
"accountContactMethodsAdd": "Add Contact Method",
"accountContactMethodsEdit": "Edit Contact Method",
"accountContactMethodsAddDescription": "Add a new contact method.",
"fieldContactContent": "Contact method",
"accountContactMethodsPublicHint": "This contact method will be displayed publicly on your profile.",
"accountContactMethodsDelete": "Delete Contact Method",
"accountContactMethodsDeleteDescription": "Are you sure you want to delete contact method {}? This operation is irreversible.",
"postCommentAdd": "Write a comment",
"translate": "Translate",
"translating": "Translating…",
"translated": "Translated",
"settingsAutoTranslate": "Auto Translate",
"settingsAutoTranslateDescription": "Automatically translate text when viewing posts and messages.",
"trayMenuHide": "Hide",
"accountSettingsNotify": "Notify Settings",
"accountSettingsNotifyDescription": "Adjust the types of notifications you receive.",
"accountSettingsSecurity": "Security Settings",
"accountSettingsSecurityDescription": "Adjust your account security settings.",
"save": "Save",
"notificationTopicPostFeedback": "Post Feedback",
"notificationTopicPostReply": "Post Replies",
"notificationTopicPostSubscription": "Post Subscriptions",
"notificationTopicMessaging": "New Messages",
"notificationTopicMessagingCall": "Incoming Calls",
"notificationTopicGeneral": "General",
"authMaximumAuthSteps": "Maximum Authenticate Steps",
"authMaximumAuthStepsDescription": {
"one": "Maximum ask for {} step authenticate",
"other": "Maximum ask for {} steps authenticate"
},
"authAlwaysRisky": "Always Risky",
"authAlwaysRiskyDescription": "Always ask for the highest steps count of authentication when logging in.",
"chatUnjoined": "Unjoined Channel",
"chatUnjoinedDescription": "You haven't joined this channel, so you can't send messages either view messages in it.",
"chatUnjoinedPublicDescription": "Fortunately, this is a public channel, so you can join it as you want.",
"chatJoin": "Join the Channel",
"appInitStarting": "Starting",
"appInitNetwork": "Initializing Network",
"appInitUserdata": "Initializing User Data",
"appInitWebsocket": "Establishing Solar Link",
"appInitNotification": "Initializing Push Notifications",
"appInitKeyPair": "Initializing Key Pairs",
"appInitStickers": "Initializing Stickers",
"appInitUserDirectory": "Initializing User Directory",
"appInitRealm": "Initializing Realms",
"appInitChat": "Initializing Chat",
"appInitDone": "Completed",
"community": "Community",
"realmCommunity": "{}'s Community",
"postTotalCount": {
"one": "Total {} post",
"other": "Total {} posts"
},
"settingsHideBottomNav": "Hide Bottom Navigation",
"settingsHideBottomNavDescription": "Hide the bottom navigation bar, and show the navigation buttons in the drawer.",
"reCaptcha": "reCaptcha"
}

View File

@ -205,6 +205,7 @@
"one": "{} 条评论",
"other": "{} 条评论"
},
"postCommentExpand": "展开评论",
"settingsAppearance": "外观",
"settingsCustomFonts": "自定义字体",
"settingsCustomFontsDescription": "设置应用程序使用的字体。",
@ -335,6 +336,7 @@
"fieldAttachmentRandomId": "访问 ID",
"fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "从相册中添加附件",
"addAttachmentFromFiles": "从文件中添加附件",
"addAttachmentFromClipboard": "粘贴附件",
"addAttachmentFromCameraPhoto": "拍摄照片",
"addAttachmentFromCameraVideo": "拍摄视频",
@ -789,5 +791,102 @@
"fieldAccountStatusClearAt": "清除时间",
"accountStatusNegative": "负面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
"accountStatusPositive": "正面",
"mixedFeed": "混合推荐流",
"mixedFeedDescription": "探索页面可能不只会展示用户的帖子,更可能包含其他的内容。但该模式不适用分类和过滤。",
"filterFeed": "探索队列调整",
"feedUnknownItem": "无法显示该内容,当前版本客户端不支持该类型的内容,请尝试更新应用程序后再试。",
"serviceStatusOperational": "所有服务正常",
"serviceStatusDowngraded": "部分服务异常",
"serviceStatusFailed": "服务状态异常",
"serviceStatusFailedDescription": "服务器炸了或者刚刚执行完维护程序。",
"serviceNameInsights": "总结、见解与洞察",
"serviceNameInteractive": "帖子与互动",
"serviceNameReader": "新闻与链接展开",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩阵市场",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源点钱包",
"serviceNamePassport": "身份验证与授权",
"accountActionEvent": "操作日志",
"accountActionEventDescription": "查看你的操作日志。",
"eventMetadata": "元数据",
"accountAuthTickets": "授权会话",
"accountAuthTicketsDescription": "查看和管理你的授权会话。",
"authTicketCreatedAt": "签发于 {}",
"authTicketExpiredAt": "到期于 {}",
"authTicketLastGrantAt": "上次刷新于 {}",
"authTicketCurrent": "当前会话",
"accountUnconfirmedTitle": "尚未未确认账户",
"accountUnconfirmedSubtitle": "您的账户尚未确认,这会导致大部分功能不可用,并且您的帐户将在 24 小时后自毁。您绑定的邮箱的收件箱应该会有一封邮件指引您确认您的帐户。",
"accountUnconfirmedUnreceived": "未收到邮件?",
"accountUnconfirmedResend": "重新发送一封",
"accountUnconfirmedResendSuccessful": "邮件已重新发送,你可以在 60 分钟后再重发一封。",
"stickerPickerEmpty": "贴图列表为空",
"stickerPickerEmptyHint": "想要开始使用贴图,请先添加贴图包。",
"goto": "跳转到 {}",
"accountContactMethods": "联系方式",
"accountContactMethodsDescription": "管理你的联系方式。",
"accountContactMethodsNameEmail": "电子邮箱",
"accountContactMethodsNamePhone": "电话",
"accountContactMethodsNameAddress": "地址",
"accountContactMethodsPrimary": "主要的",
"accountContactMethodsVerified": "已验证",
"accountContactMethodsPublic": "公开的",
"accountContactMethodsAdd": "添加联系方式",
"accountContactMethodsEdit": "编辑联系方式",
"accountContactMethodsAddDescription": "添加新的联系方式。",
"fieldContactContent": "联系方式",
"accountContactMethodsPublicHint": "这个联系方式公开地显示在个人资料中。",
"accountContactMethodsDelete": "删除联系方式",
"accountContactMethodsDeleteDescription": "你确定要删除联系方式 {} 吗?这个操作不可撤销。",
"postCommentAdd": "撰写一条评论",
"translate": "翻译",
"translating": "正在翻译……",
"translated": "已翻译",
"settingsAutoTranslate": "自动翻译",
"settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。",
"trayMenuHide": "隐藏",
"accountSettingsNotify": "通知设置",
"accountSettingsNotifyDescription": "调整你所收到的通知种类。",
"accountSettingsSecurity": "安全设置",
"accountSettingsSecurityDescription": "调整你的帐户安全设置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子数据反馈",
"notificationTopicPostReply": "帖子回复",
"notificationTopicPostSubscription": "帖子订阅",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通话",
"notificationTopicGeneral": "杂项",
"authMaximumAuthSteps": "最大验证步骤",
"authMaximumAuthStepsDescription": {
"one": "登入时最多要求 {} 步验证",
"other": "登入时最多要求 {} 步验证"
},
"authAlwaysRisky": "总是风险",
"authAlwaysRiskyDescription": "在登入时始终按最高标准要求验证。",
"chatUnjoined": "未加入频道",
"chatUnjoinedDescription": "你没有加入这个频道,所以你也无法发送消息或者查看这个频道中的消息。",
"chatUnjoinedPublicDescription": "但幸运的是,这是一个公开频道,所以你可以主动加入。",
"chatJoin": "加入频道",
"appInitStarting": "启动中",
"appInitNetwork": "正在初始化网络",
"appInitUserdata": "正在初始化用户数据",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密钥对",
"appInitStickers": "正在初始化贴图包",
"appInitUserDirectory": "正在初始化用户目录",
"appInitRealm": "正在初始化领域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社区",
"realmCommunity": "{}的社区",
"postTotalCount": {
"zero": "没有帖子",
"one": "共 {} 条帖子"
},
"settingsHideBottomNav": "隐藏底部导航栏",
"settingsHideBottomNavDescription": "隐藏底部导航栏,在侧边栏抽屉显示导航按钮。",
"reCaptcha": "人机验证"
}

View File

@ -205,6 +205,7 @@
"one": "{} 條評論",
"other": "{} 條評論"
},
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
@ -335,6 +336,7 @@
"fieldAttachmentRandomId": "訪問 ID",
"fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromFiles": "從文件中添加附件",
"addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻",
@ -789,5 +791,102 @@
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
"accountStatusPositive": "正面",
"mixedFeed": "混合推薦流",
"mixedFeedDescription": "探索頁面可能不只會展示用户的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
"filterFeed": "探索隊列調整",
"feedUnknownItem": "無法顯示該內容,當前版本客户端不支持該類型的內容,請嘗試更新應用程序後再試。",
"serviceStatusOperational": "所有服務正常",
"serviceStatusDowngraded": "部分服務異常",
"serviceStatusFailed": "服務狀態異常",
"serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
"serviceNameInsights": "總結、見解與洞察",
"serviceNameInteractive": "帖子與互動",
"serviceNameReader": "新聞與鏈接展開",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩陣市場",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源點錢包",
"serviceNamePassport": "身份驗證與授權",
"accountActionEvent": "操作日誌",
"accountActionEventDescription": "查看你的操作日誌。",
"eventMetadata": "元數據",
"accountAuthTickets": "授權會話",
"accountAuthTicketsDescription": "查看和管理你的授權會話。",
"authTicketCreatedAt": "簽發於 {}",
"authTicketExpiredAt": "到期於 {}",
"authTicketLastGrantAt": "上次刷新於 {}",
"authTicketCurrent": "當前會話",
"accountUnconfirmedTitle": "尚未未確認賬户",
"accountUnconfirmedSubtitle": "您的賬户尚未確認,這會導致大部分功能不可用,並且您的帳户將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳户。",
"accountUnconfirmedUnreceived": "未收到郵件?",
"accountUnconfirmedResend": "重新發送一封",
"accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。",
"stickerPickerEmpty": "貼圖列表為空",
"stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。",
"goto": "跳轉到 {}",
"accountContactMethods": "聯繫方式",
"accountContactMethodsDescription": "管理你的聯繫方式。",
"accountContactMethodsNameEmail": "電子郵箱",
"accountContactMethodsNamePhone": "電話",
"accountContactMethodsNameAddress": "地址",
"accountContactMethodsPrimary": "主要的",
"accountContactMethodsVerified": "已驗證",
"accountContactMethodsPublic": "公開的",
"accountContactMethodsAdd": "添加聯繫方式",
"accountContactMethodsEdit": "編輯聯繫方式",
"accountContactMethodsAddDescription": "添加新的聯繫方式。",
"fieldContactContent": "聯繫方式",
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
"accountContactMethodsDelete": "刪除聯繫方式",
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
"postCommentAdd": "撰寫一條評論",
"translate": "翻譯",
"translating": "正在翻譯……",
"translated": "已翻譯",
"settingsAutoTranslate": "自動翻譯",
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
"trayMenuHide": "隱藏",
"accountSettingsNotify": "通知設置",
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
"accountSettingsSecurity": "安全設置",
"accountSettingsSecurityDescription": "調整你的帳户安全設置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子數據反饋",
"notificationTopicPostReply": "帖子回覆",
"notificationTopicPostSubscription": "帖子訂閲",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通話",
"notificationTopicGeneral": "雜項",
"authMaximumAuthSteps": "最大驗證步驟",
"authMaximumAuthStepsDescription": {
"one": "登入時最多要求 {} 步驗證",
"other": "登入時最多要求 {} 步驗證"
},
"authAlwaysRisky": "總是風險",
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
"chatUnjoined": "未加入頻道",
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
"chatJoin": "加入頻道",
"appInitStarting": "啓動中",
"appInitNetwork": "正在初始化網絡",
"appInitUserdata": "正在初始化用户數據",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密鑰對",
"appInitStickers": "正在初始化貼圖包",
"appInitUserDirectory": "正在初始化用户目錄",
"appInitRealm": "正在初始化領域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社區",
"realmCommunity": "{}的社區",
"postTotalCount": {
"zero": "沒有帖子",
"one": "共 {} 條帖子"
},
"settingsHideBottomNav": "隱藏底部導航欄",
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
"reCaptcha": "人機驗證"
}

View File

@ -205,6 +205,7 @@
"one": "{} 條評論",
"other": "{} 條評論"
},
"postCommentExpand": "展開評論",
"settingsAppearance": "外觀",
"settingsCustomFonts": "自定義字體",
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
@ -335,6 +336,7 @@
"fieldAttachmentRandomId": "訪問 ID",
"fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromFiles": "從文件中添加附件",
"addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻",
@ -789,5 +791,102 @@
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
"accountStatusPositive": "正面",
"mixedFeed": "混合推薦流",
"mixedFeedDescription": "探索頁面可能不只會展示用戶的帖子,更可能包含其他的內容。但該模式不適用分類和過濾。",
"filterFeed": "探索隊列調整",
"feedUnknownItem": "無法顯示該內容,當前版本客戶端不支持該類型的內容,請嘗試更新應用程序後再試。",
"serviceStatusOperational": "所有服務正常",
"serviceStatusDowngraded": "部分服務異常",
"serviceStatusFailed": "服務狀態異常",
"serviceStatusFailedDescription": "服務器炸了或者剛剛執行完維護程序。",
"serviceNameInsights": "總結、見解與洞察",
"serviceNameInteractive": "帖子與互動",
"serviceNameReader": "新聞與鏈接展開",
"serviceNameMessaging": "即使聊天",
"serviceNameMatrix": "矩陣市場",
"serviceNamePaperclip": "附件",
"serviceNameWallet": "源點錢包",
"serviceNamePassport": "身份驗證與授權",
"accountActionEvent": "操作日誌",
"accountActionEventDescription": "查看你的操作日誌。",
"eventMetadata": "元數據",
"accountAuthTickets": "授權會話",
"accountAuthTicketsDescription": "查看和管理你的授權會話。",
"authTicketCreatedAt": "簽發於 {}",
"authTicketExpiredAt": "到期於 {}",
"authTicketLastGrantAt": "上次刷新於 {}",
"authTicketCurrent": "當前會話",
"accountUnconfirmedTitle": "尚未未確認賬戶",
"accountUnconfirmedSubtitle": "您的賬戶尚未確認,這會導致大部分功能不可用,並且您的帳戶將在 24 小時後自毀。您綁定的郵箱的收件箱應該會有一封郵件指引您確認您的帳戶。",
"accountUnconfirmedUnreceived": "未收到郵件?",
"accountUnconfirmedResend": "重新發送一封",
"accountUnconfirmedResendSuccessful": "郵件已重新發送,你可以在 60 分鐘後再重發一封。",
"stickerPickerEmpty": "貼圖列表為空",
"stickerPickerEmptyHint": "想要開始使用貼圖,請先添加貼圖包。",
"goto": "跳轉到 {}",
"accountContactMethods": "聯繫方式",
"accountContactMethodsDescription": "管理你的聯繫方式。",
"accountContactMethodsNameEmail": "電子郵箱",
"accountContactMethodsNamePhone": "電話",
"accountContactMethodsNameAddress": "地址",
"accountContactMethodsPrimary": "主要的",
"accountContactMethodsVerified": "已驗證",
"accountContactMethodsPublic": "公開的",
"accountContactMethodsAdd": "添加聯繫方式",
"accountContactMethodsEdit": "編輯聯繫方式",
"accountContactMethodsAddDescription": "添加新的聯繫方式。",
"fieldContactContent": "聯繫方式",
"accountContactMethodsPublicHint": "這個聯繫方式公開地顯示在個人資料中。",
"accountContactMethodsDelete": "刪除聯繫方式",
"accountContactMethodsDeleteDescription": "你確定要刪除聯繫方式 {} 嗎?這個操作不可撤銷。",
"postCommentAdd": "撰寫一條評論",
"translate": "翻譯",
"translating": "正在翻譯……",
"translated": "已翻譯",
"settingsAutoTranslate": "自動翻譯",
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
"trayMenuHide": "隱藏",
"accountSettingsNotify": "通知設置",
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
"accountSettingsSecurity": "安全設置",
"accountSettingsSecurityDescription": "調整你的帳戶安全設置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子數據反饋",
"notificationTopicPostReply": "帖子回覆",
"notificationTopicPostSubscription": "帖子訂閱",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通話",
"notificationTopicGeneral": "雜項",
"authMaximumAuthSteps": "最大驗證步驟",
"authMaximumAuthStepsDescription": {
"one": "登入時最多要求 {} 步驗證",
"other": "登入時最多要求 {} 步驗證"
},
"authAlwaysRisky": "總是風險",
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
"chatUnjoined": "未加入頻道",
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
"chatJoin": "加入頻道",
"appInitStarting": "啟動中",
"appInitNetwork": "正在初始化網絡",
"appInitUserdata": "正在初始化用戶數據",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密鑰對",
"appInitStickers": "正在初始化貼圖包",
"appInitUserDirectory": "正在初始化用戶目錄",
"appInitRealm": "正在初始化領域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社區",
"realmCommunity": "{}的社區",
"postTotalCount": {
"zero": "沒有帖子",
"one": "共 {} 條帖子"
},
"settingsHideBottomNav": "隱藏底部導航欄",
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
"reCaptcha": "人機驗證"
}

File diff suppressed because one or more lines are too long

View File

@ -126,8 +126,6 @@ PODS:
- gal (1.0.0):
- Flutter
- FlutterMacOS
- geolocator_apple (1.2.0):
- Flutter
- GoogleAppMeasurement (11.8.0):
- GoogleAppMeasurement/AdIdSupport (= 11.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
@ -185,7 +183,7 @@ PODS:
- in_app_review (2.0.0):
- Flutter
- Kingfisher (8.2.0)
- livekit_client (2.4.0):
- livekit_client (2.4.1):
- Flutter
- flutter_webrtc
- WebRTC-SDK (= 125.6422.06)
@ -234,6 +232,8 @@ PODS:
- sqlite3/common
- sqlite3/fts5 (3.49.1):
- sqlite3/common
- sqlite3/math (3.49.1):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.1):
- sqlite3/common
- sqlite3/rtree (3.49.1):
@ -244,6 +244,7 @@ PODS:
- sqlite3 (~> 3.49.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- SwiftyGif (5.4.5)
@ -278,7 +279,6 @@ DEPENDENCIES:
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
@ -362,8 +362,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_webrtc/ios"
gal:
:path: ".symlinks/plugins/gal/darwin"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
@ -436,7 +434,6 @@ SPEC CHECKSUMS:
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
@ -444,7 +441,7 @@ SPEC CHECKSUMS:
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
livekit_client: 9819ebc8be8ef00ed0fae7d806bf8938ec689573
livekit_client: 170022ce5f7c8c70d7f862ac9c17e11508ad5fbc
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
@ -463,7 +460,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db
sqlite3_flutter_libs: 487032b9008b28de37c72a3aa66849ef3745f3e6
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe

View File

@ -79,6 +79,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>

View File

@ -6,10 +6,12 @@ import 'package:surface/database/attachment.dart';
import 'package:surface/database/chat.dart';
import 'package:surface/database/database.steps.dart';
import 'package:surface/database/keypair.dart';
import 'package:surface/database/realm.dart';
import 'package:surface/database/sticker.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/realm.dart';
part 'database.g.dart';
@ -22,12 +24,13 @@ part 'database.g.dart';
SnLocalAttachment,
SnLocalSticker,
SnLocalStickerPack,
SnLocalRealm,
])
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection());
@override
int get schemaVersion => 3;
int get schemaVersion => 4;
static QueryExecutor _openConnection() {
return driftDatabase(
@ -49,6 +52,10 @@ class AppDatabase extends _$AppDatabase {
// Nothing else to do here
}, from2To3: (m, schema) async {
// Nothing else to do here, too
}, from3To4: (m, schema) async {
m.createTable(schema.snLocalRealm);
m.createIndex(schema.idxRealmAccount);
m.createIndex(schema.idxRealmAlias);
}),
);
}

View File

@ -2454,6 +2454,351 @@ class SnLocalStickerPackCompanion
}
}
class $SnLocalRealmTable extends SnLocalRealm
with TableInfo<$SnLocalRealmTable, SnLocalRealmData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$SnLocalRealmTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id', aliasedName, false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const VerificationMeta _aliasMeta = const VerificationMeta('alias');
@override
late final GeneratedColumn<String> alias = GeneratedColumn<String>(
'alias', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'));
@override
late final GeneratedColumnWithTypeConverter<SnRealm, String> content =
GeneratedColumn<String>('content', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true)
.withConverter<SnRealm>($SnLocalRealmTable.$convertercontent);
static const VerificationMeta _accountIdMeta =
const VerificationMeta('accountId');
@override
late final GeneratedColumn<int> accountId = GeneratedColumn<int>(
'account_id', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true);
static const VerificationMeta _createdAtMeta =
const VerificationMeta('createdAt');
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at', aliasedName, false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime);
static const VerificationMeta _cacheExpiredAtMeta =
const VerificationMeta('cacheExpiredAt');
@override
late final GeneratedColumn<DateTime> cacheExpiredAt =
GeneratedColumn<DateTime>('cache_expired_at', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
@override
List<GeneratedColumn> get $columns =>
[id, alias, content, accountId, createdAt, cacheExpiredAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'sn_local_realm';
@override
VerificationContext validateIntegrity(Insertable<SnLocalRealmData> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
if (data.containsKey('alias')) {
context.handle(
_aliasMeta, alias.isAcceptableOrUnknown(data['alias']!, _aliasMeta));
} else if (isInserting) {
context.missing(_aliasMeta);
}
if (data.containsKey('account_id')) {
context.handle(_accountIdMeta,
accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta));
} else if (isInserting) {
context.missing(_accountIdMeta);
}
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
}
if (data.containsKey('cache_expired_at')) {
context.handle(
_cacheExpiredAtMeta,
cacheExpiredAt.isAcceptableOrUnknown(
data['cache_expired_at']!, _cacheExpiredAtMeta));
} else if (isInserting) {
context.missing(_cacheExpiredAtMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
SnLocalRealmData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return SnLocalRealmData(
id: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}id'])!,
alias: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}alias'])!,
content: $SnLocalRealmTable.$convertercontent.fromSql(attachedDatabase
.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}content'])!),
accountId: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}account_id'])!,
createdAt: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
cacheExpiredAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, data['${effectivePrefix}cache_expired_at'])!,
);
}
@override
$SnLocalRealmTable createAlias(String alias) {
return $SnLocalRealmTable(attachedDatabase, alias);
}
static JsonTypeConverter2<SnRealm, String, Map<String, Object?>>
$convertercontent = const SnRealmConverter();
}
class SnLocalRealmData extends DataClass
implements Insertable<SnLocalRealmData> {
final int id;
final String alias;
final SnRealm content;
final int accountId;
final DateTime createdAt;
final DateTime cacheExpiredAt;
const SnLocalRealmData(
{required this.id,
required this.alias,
required this.content,
required this.accountId,
required this.createdAt,
required this.cacheExpiredAt});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['alias'] = Variable<String>(alias);
{
map['content'] =
Variable<String>($SnLocalRealmTable.$convertercontent.toSql(content));
}
map['account_id'] = Variable<int>(accountId);
map['created_at'] = Variable<DateTime>(createdAt);
map['cache_expired_at'] = Variable<DateTime>(cacheExpiredAt);
return map;
}
SnLocalRealmCompanion toCompanion(bool nullToAbsent) {
return SnLocalRealmCompanion(
id: Value(id),
alias: Value(alias),
content: Value(content),
accountId: Value(accountId),
createdAt: Value(createdAt),
cacheExpiredAt: Value(cacheExpiredAt),
);
}
factory SnLocalRealmData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return SnLocalRealmData(
id: serializer.fromJson<int>(json['id']),
alias: serializer.fromJson<String>(json['alias']),
content: $SnLocalRealmTable.$convertercontent
.fromJson(serializer.fromJson<Map<String, Object?>>(json['content'])),
accountId: serializer.fromJson<int>(json['accountId']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
cacheExpiredAt: serializer.fromJson<DateTime>(json['cacheExpiredAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'alias': serializer.toJson<String>(alias),
'content': serializer.toJson<Map<String, Object?>>(
$SnLocalRealmTable.$convertercontent.toJson(content)),
'accountId': serializer.toJson<int>(accountId),
'createdAt': serializer.toJson<DateTime>(createdAt),
'cacheExpiredAt': serializer.toJson<DateTime>(cacheExpiredAt),
};
}
SnLocalRealmData copyWith(
{int? id,
String? alias,
SnRealm? content,
int? accountId,
DateTime? createdAt,
DateTime? cacheExpiredAt}) =>
SnLocalRealmData(
id: id ?? this.id,
alias: alias ?? this.alias,
content: content ?? this.content,
accountId: accountId ?? this.accountId,
createdAt: createdAt ?? this.createdAt,
cacheExpiredAt: cacheExpiredAt ?? this.cacheExpiredAt,
);
SnLocalRealmData copyWithCompanion(SnLocalRealmCompanion data) {
return SnLocalRealmData(
id: data.id.present ? data.id.value : this.id,
alias: data.alias.present ? data.alias.value : this.alias,
content: data.content.present ? data.content.value : this.content,
accountId: data.accountId.present ? data.accountId.value : this.accountId,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
cacheExpiredAt: data.cacheExpiredAt.present
? data.cacheExpiredAt.value
: this.cacheExpiredAt,
);
}
@override
String toString() {
return (StringBuffer('SnLocalRealmData(')
..write('id: $id, ')
..write('alias: $alias, ')
..write('content: $content, ')
..write('accountId: $accountId, ')
..write('createdAt: $createdAt, ')
..write('cacheExpiredAt: $cacheExpiredAt')
..write(')'))
.toString();
}
@override
int get hashCode =>
Object.hash(id, alias, content, accountId, createdAt, cacheExpiredAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is SnLocalRealmData &&
other.id == this.id &&
other.alias == this.alias &&
other.content == this.content &&
other.accountId == this.accountId &&
other.createdAt == this.createdAt &&
other.cacheExpiredAt == this.cacheExpiredAt);
}
class SnLocalRealmCompanion extends UpdateCompanion<SnLocalRealmData> {
final Value<int> id;
final Value<String> alias;
final Value<SnRealm> content;
final Value<int> accountId;
final Value<DateTime> createdAt;
final Value<DateTime> cacheExpiredAt;
const SnLocalRealmCompanion({
this.id = const Value.absent(),
this.alias = const Value.absent(),
this.content = const Value.absent(),
this.accountId = const Value.absent(),
this.createdAt = const Value.absent(),
this.cacheExpiredAt = const Value.absent(),
});
SnLocalRealmCompanion.insert({
this.id = const Value.absent(),
required String alias,
required SnRealm content,
required int accountId,
this.createdAt = const Value.absent(),
required DateTime cacheExpiredAt,
}) : alias = Value(alias),
content = Value(content),
accountId = Value(accountId),
cacheExpiredAt = Value(cacheExpiredAt);
static Insertable<SnLocalRealmData> custom({
Expression<int>? id,
Expression<String>? alias,
Expression<String>? content,
Expression<int>? accountId,
Expression<DateTime>? createdAt,
Expression<DateTime>? cacheExpiredAt,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (alias != null) 'alias': alias,
if (content != null) 'content': content,
if (accountId != null) 'account_id': accountId,
if (createdAt != null) 'created_at': createdAt,
if (cacheExpiredAt != null) 'cache_expired_at': cacheExpiredAt,
});
}
SnLocalRealmCompanion copyWith(
{Value<int>? id,
Value<String>? alias,
Value<SnRealm>? content,
Value<int>? accountId,
Value<DateTime>? createdAt,
Value<DateTime>? cacheExpiredAt}) {
return SnLocalRealmCompanion(
id: id ?? this.id,
alias: alias ?? this.alias,
content: content ?? this.content,
accountId: accountId ?? this.accountId,
createdAt: createdAt ?? this.createdAt,
cacheExpiredAt: cacheExpiredAt ?? this.cacheExpiredAt,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (alias.present) {
map['alias'] = Variable<String>(alias.value);
}
if (content.present) {
map['content'] = Variable<String>(
$SnLocalRealmTable.$convertercontent.toSql(content.value));
}
if (accountId.present) {
map['account_id'] = Variable<int>(accountId.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (cacheExpiredAt.present) {
map['cache_expired_at'] = Variable<DateTime>(cacheExpiredAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('SnLocalRealmCompanion(')
..write('id: $id, ')
..write('alias: $alias, ')
..write('content: $content, ')
..write('accountId: $accountId, ')
..write('createdAt: $createdAt, ')
..write('cacheExpiredAt: $cacheExpiredAt')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
@ -2470,6 +2815,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $SnLocalStickerTable snLocalSticker = $SnLocalStickerTable(this);
late final $SnLocalStickerPackTable snLocalStickerPack =
$SnLocalStickerPackTable(this);
late final $SnLocalRealmTable snLocalRealm = $SnLocalRealmTable(this);
late final Index idxChannelAlias = Index('idx_channel_alias',
'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
late final Index idxChatChannel = Index('idx_chat_channel',
@ -2480,6 +2826,10 @@ abstract class _$AppDatabase extends GeneratedDatabase {
'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
late final Index idxAttachmentAccount = Index('idx_attachment_account',
'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
late final Index idxRealmAlias = Index('idx_realm_alias',
'CREATE INDEX idx_realm_alias ON sn_local_realm (alias)');
late final Index idxRealmAccount = Index('idx_realm_account',
'CREATE INDEX idx_realm_account ON sn_local_realm (account_id)');
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@ -2493,11 +2843,14 @@ abstract class _$AppDatabase extends GeneratedDatabase {
snLocalAttachment,
snLocalSticker,
snLocalStickerPack,
snLocalRealm,
idxChannelAlias,
idxChatChannel,
idxAccountName,
idxAttachmentRid,
idxAttachmentAccount
idxAttachmentAccount,
idxRealmAlias,
idxRealmAccount
];
}
@ -3888,6 +4241,192 @@ typedef $$SnLocalStickerPackTableProcessedTableManager = ProcessedTableManager<
),
SnLocalStickerPackData,
PrefetchHooks Function()>;
typedef $$SnLocalRealmTableCreateCompanionBuilder = SnLocalRealmCompanion
Function({
Value<int> id,
required String alias,
required SnRealm content,
required int accountId,
Value<DateTime> createdAt,
required DateTime cacheExpiredAt,
});
typedef $$SnLocalRealmTableUpdateCompanionBuilder = SnLocalRealmCompanion
Function({
Value<int> id,
Value<String> alias,
Value<SnRealm> content,
Value<int> accountId,
Value<DateTime> createdAt,
Value<DateTime> cacheExpiredAt,
});
class $$SnLocalRealmTableFilterComposer
extends Composer<_$AppDatabase, $SnLocalRealmTable> {
$$SnLocalRealmTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get alias => $composableBuilder(
column: $table.alias, builder: (column) => ColumnFilters(column));
ColumnWithTypeConverterFilters<SnRealm, SnRealm, String> get content =>
$composableBuilder(
column: $table.content,
builder: (column) => ColumnWithTypeConverterFilters(column));
ColumnFilters<int> get accountId => $composableBuilder(
column: $table.accountId, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get cacheExpiredAt => $composableBuilder(
column: $table.cacheExpiredAt,
builder: (column) => ColumnFilters(column));
}
class $$SnLocalRealmTableOrderingComposer
extends Composer<_$AppDatabase, $SnLocalRealmTable> {
$$SnLocalRealmTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get alias => $composableBuilder(
column: $table.alias, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get content => $composableBuilder(
column: $table.content, builder: (column) => ColumnOrderings(column));
ColumnOrderings<int> get accountId => $composableBuilder(
column: $table.accountId, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get cacheExpiredAt => $composableBuilder(
column: $table.cacheExpiredAt,
builder: (column) => ColumnOrderings(column));
}
class $$SnLocalRealmTableAnnotationComposer
extends Composer<_$AppDatabase, $SnLocalRealmTable> {
$$SnLocalRealmTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get alias =>
$composableBuilder(column: $table.alias, builder: (column) => column);
GeneratedColumnWithTypeConverter<SnRealm, String> get content =>
$composableBuilder(column: $table.content, builder: (column) => column);
GeneratedColumn<int> get accountId =>
$composableBuilder(column: $table.accountId, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<DateTime> get cacheExpiredAt => $composableBuilder(
column: $table.cacheExpiredAt, builder: (column) => column);
}
class $$SnLocalRealmTableTableManager extends RootTableManager<
_$AppDatabase,
$SnLocalRealmTable,
SnLocalRealmData,
$$SnLocalRealmTableFilterComposer,
$$SnLocalRealmTableOrderingComposer,
$$SnLocalRealmTableAnnotationComposer,
$$SnLocalRealmTableCreateCompanionBuilder,
$$SnLocalRealmTableUpdateCompanionBuilder,
(
SnLocalRealmData,
BaseReferences<_$AppDatabase, $SnLocalRealmTable, SnLocalRealmData>
),
SnLocalRealmData,
PrefetchHooks Function()> {
$$SnLocalRealmTableTableManager(_$AppDatabase db, $SnLocalRealmTable table)
: super(TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$SnLocalRealmTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$SnLocalRealmTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$SnLocalRealmTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
Value<int> id = const Value.absent(),
Value<String> alias = const Value.absent(),
Value<SnRealm> content = const Value.absent(),
Value<int> accountId = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime> cacheExpiredAt = const Value.absent(),
}) =>
SnLocalRealmCompanion(
id: id,
alias: alias,
content: content,
accountId: accountId,
createdAt: createdAt,
cacheExpiredAt: cacheExpiredAt,
),
createCompanionCallback: ({
Value<int> id = const Value.absent(),
required String alias,
required SnRealm content,
required int accountId,
Value<DateTime> createdAt = const Value.absent(),
required DateTime cacheExpiredAt,
}) =>
SnLocalRealmCompanion.insert(
id: id,
alias: alias,
content: content,
accountId: accountId,
createdAt: createdAt,
cacheExpiredAt: cacheExpiredAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$SnLocalRealmTableProcessedTableManager = ProcessedTableManager<
_$AppDatabase,
$SnLocalRealmTable,
SnLocalRealmData,
$$SnLocalRealmTableFilterComposer,
$$SnLocalRealmTableOrderingComposer,
$$SnLocalRealmTableAnnotationComposer,
$$SnLocalRealmTableCreateCompanionBuilder,
$$SnLocalRealmTableUpdateCompanionBuilder,
(
SnLocalRealmData,
BaseReferences<_$AppDatabase, $SnLocalRealmTable, SnLocalRealmData>
),
SnLocalRealmData,
PrefetchHooks Function()>;
class $AppDatabaseManager {
final _$AppDatabase _db;
@ -3908,4 +4447,6 @@ class $AppDatabaseManager {
$$SnLocalStickerTableTableManager(_db, _db.snLocalSticker);
$$SnLocalStickerPackTableTableManager get snLocalStickerPack =>
$$SnLocalStickerPackTableTableManager(_db, _db.snLocalStickerPack);
$$SnLocalRealmTableTableManager get snLocalRealm =>
$$SnLocalRealmTableTableManager(_db, _db.snLocalRealm);
}

View File

@ -412,9 +412,214 @@ class Shape8 extends i0.VersionedTable {
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
}
final class Schema4 extends i0.VersionedSchema {
Schema4({required super.database}) : super(version: 4);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
snLocalChatChannel,
snLocalChatMessage,
snLocalChannelMember,
snLocalKeyPair,
snLocalAccount,
snLocalAttachment,
snLocalSticker,
snLocalStickerPack,
snLocalRealm,
idxChannelAlias,
idxChatChannel,
idxAccountName,
idxAttachmentRid,
idxAttachmentAccount,
idxRealmAlias,
idxRealmAccount,
];
late final Shape0 snLocalChatChannel = Shape0(
source: i0.VersionedTable(
entityName: 'sn_local_chat_channel',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape3 snLocalChatMessage = Shape3(
source: i0.VersionedTable(
entityName: 'sn_local_chat_message',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_10,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape4 snLocalChannelMember = Shape4(
source: i0.VersionedTable(
entityName: 'sn_local_channel_member',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_6,
_column_2,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape2 snLocalKeyPair = Shape2(
source: i0.VersionedTable(
entityName: 'sn_local_key_pair',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
],
attachedDatabase: database,
),
alias: null);
late final Shape5 snLocalAccount = Shape5(
source: i0.VersionedTable(
entityName: 'sn_local_account',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_12,
_column_2,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape6 snLocalAttachment = Shape6(
source: i0.VersionedTable(
entityName: 'sn_local_attachment',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_13,
_column_14,
_column_2,
_column_6,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 snLocalSticker = Shape7(
source: i0.VersionedTable(
entityName: 'sn_local_sticker',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_15,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape8 snLocalStickerPack = Shape8(
source: i0.VersionedTable(
entityName: 'sn_local_sticker_pack',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape9 snLocalRealm = Shape9(
source: i0.VersionedTable(
entityName: 'sn_local_realm',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_16,
_column_2,
_column_6,
_column_3,
_column_11,
],
attachedDatabase: database,
),
alias: null);
final i1.Index idxChannelAlias = i1.Index('idx_channel_alias',
'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
final i1.Index idxChatChannel = i1.Index('idx_chat_channel',
'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)');
final i1.Index idxAccountName = i1.Index('idx_account_name',
'CREATE INDEX idx_account_name ON sn_local_account (name)');
final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid',
'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account',
'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
final i1.Index idxRealmAlias = i1.Index('idx_realm_alias',
'CREATE INDEX idx_realm_alias ON sn_local_realm (alias)');
final i1.Index idxRealmAccount = i1.Index('idx_realm_account',
'CREATE INDEX idx_realm_account ON sn_local_realm (account_id)');
}
class Shape9 extends i0.VersionedTable {
Shape9({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get alias =>
columnsByName['alias']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get accountId =>
columnsByName['account_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_16(String aliasedName) =>
i1.GeneratedColumn<String>('alias', aliasedName, false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -428,6 +633,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from2To3(migrator, schema);
return 3;
case 3:
final schema = Schema4(database: database);
final migrator = i1.Migrator(database, schema);
await from3To4(migrator, schema);
return 4;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -437,9 +647,11 @@ i0.MigrationStepWithVersion migrationSteps({
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
from2To3: from2To3,
from3To4: from3To4,
));

45
lib/database/realm.dart Normal file
View File

@ -0,0 +1,45 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:surface/types/realm.dart';
class SnRealmConverter extends TypeConverter<SnRealm, String>
with JsonTypeConverter2<SnRealm, String, Map<String, Object?>> {
const SnRealmConverter();
@override
SnRealm fromSql(String fromDb) {
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
}
@override
String toSql(SnRealm value) {
return jsonEncode(toJson(value));
}
@override
SnRealm fromJson(Map<String, Object?> json) {
return SnRealm.fromJson(json);
}
@override
Map<String, Object?> toJson(SnRealm value) {
return value.toJson();
}
}
@TableIndex(name: 'idx_realm_alias', columns: {#alias})
@TableIndex(name: 'idx_realm_account', columns: {#accountId})
class SnLocalRealm extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get alias => text().unique()();
TextColumn get content => text().map(const SnRealmConverter())();
IntColumn get accountId => integer()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get cacheExpiredAt => dateTime()();
}

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'dart:math' hide log;
import 'dart:ui';
import 'package:bitsdojo_window/bitsdojo_window.dart';
@ -12,6 +13,7 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:package_info_plus/package_info_plus.dart';
@ -19,6 +21,7 @@ import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/firebase_options.dart';
import 'package:surface/logger.dart';
import 'package:surface/providers/channel.dart';
@ -37,6 +40,7 @@ import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/sn_sticker.dart';
import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/theme.dart';
import 'package:surface/providers/translation.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
@ -44,6 +48,8 @@ import 'package:surface/providers/widget.dart';
import 'package:surface/router.dart';
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/menu_bar.dart';
import 'package:surface/widgets/version_label.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:version/version.dart';
import 'package:workmanager/workmanager.dart';
@ -166,6 +172,7 @@ class SolianApp extends StatelessWidget {
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
Provider(create: (ctx) => SnTranslator()),
// Additional helper layer
Provider(create: (ctx) => SpecialDayProvider(ctx)),
@ -225,6 +232,9 @@ class _AppSplashScreen extends StatefulWidget {
}
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
bool _isBusy = false;
String _phaseText = 'appInitStarting';
void _tryRequestRating() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('first_boot_time')) {
@ -273,7 +283,9 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
mounted) {
final config = context.read<ConfigProvider>();
config.setUpdate(
remoteVersionString, resp.data?['body'] ?? 'No changelog');
remoteVersionString,
resp.data?['body'] ?? 'No changelog',
);
logging.info("[Update] Update available: $remoteVersionString");
}
} catch (e) {
@ -282,6 +294,11 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
}
}
void _setPhaseText(String text) {
_phaseText = 'appInit${text.capitalize()}'.tr();
if (mounted) setState(() {});
}
Future<void> _initialize() async {
try {
final cfg = context.read<ConfigProvider>();
@ -294,31 +311,45 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
// The Network initialization must be done after the HomeWidget initialization
// The Network initialization will save the server url to the HomeWidget
// The Network initialization will also save initialize the Config, so it not need to be initialized again
_setPhaseText('network');
final sn = context.read<SnNetworkProvider>();
await sn.initializeUserAgent();
await sn.setConfigWithNative();
if (!mounted) return;
_setPhaseText('userdata');
final ua = context.read<UserProvider>();
await ua.initialize();
if (!mounted) return;
_setPhaseText('websocket');
final ws = context.read<WebSocketProvider>();
await ws.tryConnect();
if (!mounted) return;
_setPhaseText('notification');
final notify = context.read<NotificationProvider>();
notify.listen();
await notify.registerPushNotifications();
if (!mounted) return;
_setPhaseText('keyPair');
final kp = context.read<KeyPairProvider>();
await kp.reloadActive();
kp.listen();
if (!mounted) return;
_setPhaseText('stickers');
final sticker = context.read<SnStickerProvider>();
await sticker.listSticker();
if (!mounted) return;
_setPhaseText('userDirectory');
final ud = context.read<UserDirectoryProvider>();
final userCacheSize = await ud.loadAccountCache();
logging.info('[Users] Loaded local user cache, size: $userCacheSize');
logging.info('[Bootstrap] Everything initialized!');
await ud.loadAccountCache();
if (!mounted) return;
_setPhaseText('realm');
final rm = context.read<SnRealmProvider>();
await rm.refreshAvailableRealms();
if (!mounted) return;
_setPhaseText('chat');
final ct = context.read<ChatChannelProvider>();
await ct.refreshAvailableChannels();
_setPhaseText('done');
} catch (err) {
if (!mounted) return;
await context.showErrorDialog(err);
@ -331,18 +362,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
Future<void> _hotkeyInitialization() async {
if (kIsWeb) return;
if (Platform.isMacOS) {
HotKey quitHotKey = HotKey(
key: PhysicalKeyboardKey.keyQ,
modifiers: [HotKeyModifier.meta],
scope: HotKeyScope.inapp,
);
await hotKeyManager.register(quitHotKey, keyUpHandler: (_) {
_appLifecycleListener?.dispose();
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
});
}
// The quit key has been removed, and the logic of the quit key is moved to system menu bar activator.
}
final Menu _appTrayMenu = Menu(
@ -405,6 +425,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
void initState() {
super.initState();
_isBusy = true;
if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
_appLifecycleListener = AppLifecycleListener(
onExitRequested: _onExitRequested,
@ -418,6 +439,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
_postInitialization();
_tryRequestRating();
_checkForUpdate();
setState(() => _isBusy = false);
});
}
@ -426,6 +448,15 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
return AppExitResponse.cancel;
}
void _quitApp() {
_appLifecycleListener?.dispose();
if (Platform.isWindows) {
appWindow.close();
} else {
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
}
}
@override
void onTrayIconMouseDown() {
if (Platform.isWindows) {
@ -460,12 +491,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
Timer(const Duration(milliseconds: 100), () => appWindow.show());
break;
case 'exit':
_appLifecycleListener?.dispose();
if (Platform.isWindows) {
appWindow.close();
} else {
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
}
_quitApp();
break;
}
}
@ -482,29 +508,110 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
@override
Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>();
return NotificationListener<SizeChangedLayoutNotification>(
onNotification: (notification) {
WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context);
});
return false;
},
child: OrientationBuilder(
builder: (context, orientation) {
final cfg = context.read<ConfigProvider>();
return AppSystemMenuBar(
onQuit: _quitApp,
child: NotificationListener<SizeChangedLayoutNotification>(
onNotification: (notification) {
WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context);
});
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
cfg.calcDrawerSize(context);
}
});
return SizeChangedLayoutNotifier(
child: widget.child,
);
return false;
},
child: OrientationBuilder(
builder: (context, orientation) {
final cfg = context.read<ConfigProvider>();
WidgetsBinding.instance.addPostFrameCallback((_) {
cfg.calcDrawerSize(context);
});
Future.delayed(const Duration(milliseconds: 300), () {
if (context.mounted) {
cfg.calcDrawerSize(context);
}
});
return SizeChangedLayoutNotifier(
child: _isBusy
? Material(
key: Key('app-splash-screen-$_isBusy'),
child: Stack(
children: [
CustomPaint(painter: GraphPainter()),
Center(
child: Container(
constraints: const BoxConstraints(
maxWidth: 240,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/icon/icon.png',
width: 64,
height: 64,
color:
Theme.of(context).colorScheme.onSurface,
),
Text('Solar Network').bold(),
AppVersionLabel(),
Gap(8),
Text(
_phaseText,
textAlign: TextAlign.center,
),
Gap(16),
const LinearProgressIndicator(),
],
),
),
),
],
),
)
: widget.child,
);
},
),
),
);
}
}
class GraphPainter extends CustomPainter {
final Random random = Random();
final int numNodes = 20;
final double maxDistance = 100; // Max distance to draw a line
@override
void paint(Canvas canvas, Size size) {
final paintNode = Paint()..color = Colors.white;
final paintEdge = Paint()
..color = Colors.white.withOpacity(0.3)
..strokeWidth = 1;
// Generate random points
List<Offset> nodes = List.generate(
numNodes,
(_) => Offset(
random.nextDouble() * size.width,
random.nextDouble() * size.height,
),
);
// Draw edges between close nodes
for (var i = 0; i < nodes.length; i++) {
for (var j = i + 1; j < nodes.length; j++) {
double distance = (nodes[i] - nodes[j]).distance;
if (distance < maxDistance) {
canvas.drawLine(nodes[i], nodes[j], paintEdge);
}
}
}
// Draw nodes
for (var node in nodes) {
canvas.drawCircle(node, 4, paintNode);
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}

View File

@ -28,6 +28,19 @@ class ChatChannelProvider extends ChangeNotifier {
_rels = context.read<SnRealmProvider>();
}
final List<SnChannel> _availableChannels = List.empty(growable: true);
List<SnChannel> get availableChannels => _availableChannels;
Future<void> refreshAvailableChannels() async {
final stream = fetchChannels();
stream.listen((ele) {
_availableChannels.clear();
_availableChannels.addAll(ele);
notifyListeners();
});
}
Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
await Future.wait(
channels.map(

View File

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

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/types/realm.dart';
class AppNavDestination {
final String label;
@ -24,13 +25,10 @@ class NavigationProvider extends ChangeNotifier {
int? get currentIndex => _currentIndex;
static const List<String> kShowBottomNavScreen = [
'home',
'explore',
'account',
'album',
'chat',
];
List<String> get showBottomNavScreen => destinations
.where((ele) => ele.isPinned)
.map((ele) => ele.screen)
.toList();
static const List<AppNavDestination> kAllDestination = [
AppNavDestination(
@ -88,7 +86,7 @@ class NavigationProvider extends ChangeNotifier {
'home',
'explore',
'chat',
'account',
'realm',
];
List<AppNavDestination> destinations = [];
@ -143,4 +141,11 @@ class NavigationProvider extends ChangeNotifier {
_currentIndex = idx;
notifyListeners();
}
SnRealm? focusedRealm;
void setFocusedRealm(SnRealm? realm) {
focusedRealm = realm;
notifyListeners();
}
}

View File

@ -145,6 +145,36 @@ class SnPostContentProvider {
return out;
}
Future<List<SnFeedEntry>> getFeed({int take = 20, DateTime? cursor}) async {
final resp =
await _sn.client.get('/cgi/co/recommendations/feed', queryParameters: {
'take': take,
if (cursor != null) 'cursor': cursor.toUtc().millisecondsSinceEpoch,
});
final List<SnFeedEntry> out =
List.from(resp.data.map((ele) => SnFeedEntry.fromJson(ele)));
List<SnPost> posts = List.empty(growable: true);
for (var idx = 0; idx < out.length; idx++) {
final ele = out[idx];
if (ele.type == 'interactive.post') {
posts.add(SnPost.fromJson(ele.data));
}
}
posts = await _preloadRelatedDataInBatch(posts);
var postsIdx = 0;
for (var idx = 0; idx < out.length; idx++) {
final ele = out[idx];
if (ele.type == 'interactive.post') {
out[idx] = ele.copyWith(data: posts[postsIdx].toJson());
postsIdx++;
}
}
return out;
}
Future<(List<SnPost>, int)> listPosts({
int take = 10,
int offset = 0,

View File

@ -321,13 +321,13 @@ class SnAttachmentProvider {
uuid: ele.uuid,
content: ele,
accountId: ele.accountId,
cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
),
onConflict: DoUpdate(
(_) => SnLocalAttachmentCompanion.custom(
content: Constant(jsonEncode(ele.toJson())),
cacheExpiredAt:
Constant(DateTime.now().add(const Duration(days: 7))),
Constant(DateTime.now().add(const Duration(hours: 1))),
),
),
);

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_settings.dart';
enum ServiceStatus { operational, downgraded, failed }
const Map<String, String> kServicesName = {
'ai': 'Insights',
'co': 'Interactive',
're': 'Reader',
'im': 'Messaging',
'ma': 'Matrix',
'uc': 'Paperclip',
'wa': 'Wallet',
'id': 'Passport',
'pusher': 'Pusher',
};
const kNetworkServerDirectory = [
('Solar Network', 'https://api.sn.solsynth.dev'),
('Local', 'http://localhost:8001'),

View File

@ -1,16 +1,30 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/database/database.dart';
import 'package:surface/providers/database.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/realm.dart';
class SnRealmProvider {
late final SnNetworkProvider _sn;
late final DatabaseProvider _dt;
SnRealmProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_dt = context.read<DatabaseProvider>();
}
final Map<String, SnRealm> _cache = {};
List<SnRealm> _availableRealms = List.empty(growable: true);
Future<void> refreshAvailableRealms() async {
_availableRealms = await listAvailableRealms();
}
List<SnRealm> get availableRealms => _availableRealms;
Future<List<SnRealm>> listAvailableRealms() async {
final resp = await _sn.client.get('/cgi/id/realms/me/available');
@ -21,6 +35,7 @@ class SnRealmProvider {
_cache[realm.alias] = realm;
_cache[realm.id.toString()] = realm;
}
_saveToLocal(out);
return out;
}
@ -28,10 +43,43 @@ class SnRealmProvider {
if (_cache.containsKey(aliasOrId.toString())) {
return _cache[aliasOrId.toString()]!;
}
final localResp = await (_dt.db.snLocalRealm.select()
..where((e) =>
e.id.equals(aliasOrId is int ? aliasOrId : 0) |
e.alias.equals(aliasOrId.toString()))
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
.getSingleOrNull();
if (localResp != null) {
_cache[localResp.content.id.toString()] = localResp.content;
_cache[localResp.content.alias] = localResp.content;
return localResp.content;
}
final resp = await _sn.client.get('/cgi/id/realms/$aliasOrId');
final out = SnRealm.fromJson(resp.data);
_cache[out.alias] = out;
_cache[out.id.toString()] = out;
_saveToLocal([out]);
return out;
}
Future<void> _saveToLocal(Iterable<SnRealm> out) async {
for (final ele in out) {
await _dt.db.snLocalRealm.insertOne(
SnLocalRealmCompanion.insert(
id: Value(ele.id),
alias: ele.alias,
content: ele,
accountId: ele.accountId,
cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
),
onConflict: DoUpdate(
(_) => SnLocalRealmCompanion.custom(
content: Constant(jsonEncode(ele.toJson())),
cacheExpiredAt:
Constant(DateTime.now().add(const Duration(hours: 1))),
),
),
);
}
}
}

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

@ -33,7 +33,7 @@ class UserDirectoryProvider {
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
// In-memory cache
if (_cacheExpiredAt != null && _cacheExpiredAt!.isAfter(DateTime.now())) {
if (_cacheExpiredAt != null && _cacheExpiredAt!.isBefore(DateTime.now())) {
_cache.clear();
_cacheExpiredAt = DateTime.now().add(const Duration(hours: 1));
} else {

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.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 {
final resp = await _sn.client.get('/cgi/id/users/me');
final out = SnAccount.fromJson(resp.data);
@ -47,7 +75,13 @@ class UserProvider extends ChangeNotifier {
}
void logoutUser() async {
_sn.clearTokenPair();
atkClaims.then((value) async {
if (value != null) {
await _sn.client.delete('/cgi/id/users/me/tickets/${value['sed']}');
logging.info('[Auth] Current session has been destroyed.');
}
_sn.clearTokenPair();
});
isAuthorized = false;
user = null;
notifyListeners();

View File

@ -4,14 +4,19 @@ import 'package:go_router/go_router.dart';
import 'package:surface/screens/abuse_report.dart';
import 'package:surface/screens/account.dart';
import 'package:surface/screens/account/account_settings.dart';
import 'package:surface/screens/account/action_events.dart';
import 'package:surface/screens/account/badges.dart';
import 'package:surface/screens/account/contact_methods.dart';
import 'package:surface/screens/account/factor_settings.dart';
import 'package:surface/screens/account/keypairs.dart';
import 'package:surface/screens/account/prefs/notify.dart';
import 'package:surface/screens/account/prefs/security.dart';
import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/screens/account/profile_edit.dart';
import 'package:surface/screens/account/publishers/publisher_edit.dart';
import 'package:surface/screens/account/publishers/publisher_new.dart';
import 'package:surface/screens/account/publishers/publishers.dart';
import 'package:surface/screens/account/auth_tickets.dart';
import 'package:surface/screens/album.dart';
import 'package:surface/screens/auth/login.dart';
import 'package:surface/screens/auth/register.dart';
@ -34,6 +39,7 @@ import 'package:surface/screens/post/post_shuffle.dart';
import 'package:surface/screens/post/publisher_page.dart';
import 'package:surface/screens/post/post_search.dart';
import 'package:surface/screens/realm.dart';
import 'package:surface/screens/realm/community.dart';
import 'package:surface/screens/realm/manage.dart';
import 'package:surface/screens/realm/realm_detail.dart';
import 'package:surface/screens/realm/realm_discovery.dart';
@ -124,6 +130,21 @@ final _appRoutes = [
name: 'account',
builder: (context, state) => const AccountScreen(),
routes: [
GoRoute(
path: '/contacts',
name: 'accountContactMethods',
builder: (context, state) => const AccountContactMethod(),
),
GoRoute(
path: '/events',
name: 'accountActionEvents',
builder: (context, state) => const ActionEventScreen(),
),
GoRoute(
path: '/tickets',
name: 'accountAuthTickets',
builder: (context, state) => const AccountAuthTicket(),
),
GoRoute(
path: '/badges',
name: 'accountBadges',
@ -143,6 +164,18 @@ final _appRoutes = [
path: '/settings',
name: 'accountSettings',
builder: (context, state) => AccountSettingsScreen(),
routes: [
GoRoute(
path: '/notify',
name: 'accountSettingsNotify',
builder: (context, state) => const AccountNotifyPrefsScreen(),
),
GoRoute(
path: '/auth',
name: 'accountSettingsSecurity',
builder: (context, state) => const AccountSecurityPrefsScreen(),
),
],
),
GoRoute(
path: '/settings/factors',
@ -172,7 +205,7 @@ final _appRoutes = [
),
),
GoRoute(
path: '/:name',
path: '/profile/:name',
name: 'accountProfilePage',
pageBuilder: (context, state) => NoTransitionPage(
child: UserScreen(name: state.pathParameters['name']!),
@ -227,6 +260,13 @@ final _appRoutes = [
child: const RealmScreen(),
),
routes: [
GoRoute(
path: '/:alias/community',
name: 'realmCommunity',
builder: (context, state) => RealmCommunityScreen(
alias: state.pathParameters['alias']!,
),
),
GoRoute(
path: '/manage',
name: 'realmManage',

View File

@ -207,6 +207,26 @@ class _AuthorizedAccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('accountKeyPairs');
},
),
ListTile(
title: Text('accountActionEvent').tr(),
subtitle: Text('accountActionEventDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.history),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountActionEvents');
},
),
ListTile(
title: Text('accountAuthTickets').tr(),
subtitle: Text('accountAuthTicketsDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.confirmation_number),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountAuthTickets');
},
),
ListTile(
title: Text('accountSettings').tr(),
subtitle: Text('accountSettingsSubtitle').tr(),

View File

@ -87,6 +87,36 @@ class AccountSettingsScreen extends StatelessWidget {
),
),
),
ListTile(
title: Text('accountContactMethods').tr(),
subtitle: Text('accountContactMethodsDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.contacts),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountContactMethods');
},
),
ListTile(
title: Text('accountSettingsNotify').tr(),
subtitle: Text('accountSettingsNotifyDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.notifications),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountSettingsNotify');
},
),
ListTile(
title: Text('accountSettingsSecurity').tr(),
subtitle: Text('accountSettingsSecurityDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.shield),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountSettingsSecurity');
},
),
ListTile(
title: Text('accountProfileEdit').tr(),
subtitle: Text('accountProfileEditSubtitle').tr(),

View File

@ -0,0 +1,160 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:timelines_plus/timelines_plus.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class ActionEventScreen extends StatefulWidget {
const ActionEventScreen({super.key});
@override
State<ActionEventScreen> createState() => _ActionEventScreenState();
}
class _ActionEventScreenState extends State<ActionEventScreen> {
bool _isBusy = false;
int? _totalCount;
final List<SnActionEvent> _actionEvents = List.empty(growable: true);
Future<void> _fetchActionEvents() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/id/users/me/events',
queryParameters: {
'take': 10,
'offset': _actionEvents.length,
},
);
_totalCount = resp.data['count'];
_actionEvents.addAll(
(resp.data['data'] as List<dynamic>)
.map((e) => SnActionEvent.fromJson(e)),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchActionEvents();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountActionEvent').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_totalCount = null;
return _fetchActionEvents();
},
child: InfiniteList(
padding: EdgeInsets.only(left: 20, right: 8),
itemCount: _actionEvents.length,
isLoading: _isBusy,
hasReachedMax:
_totalCount != null && _actionEvents.length >= _totalCount!,
onFetchData: _fetchActionEvents,
itemBuilder: (context, idx) {
final event = _actionEvents[idx];
return TimelineTile(
nodeAlign: TimelineNodeAlign.start,
contents: Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.type,
maxLines: 1,
style: GoogleFonts.robotoMono(),
),
if (event.ipAddress.isNotEmpty)
Text(
event.ipAddress,
style: TextStyle(fontSize: 13),
),
if (event.location?.isNotEmpty ?? false)
Text(event.location!),
Row(
children: [
Text(DateFormat()
.format(event.createdAt.toLocal()))
.fontSize(12),
Text(' · ')
.fontSize(12)
.padding(horizontal: 4),
Text(RelativeTime(context)
.format(event.createdAt.toLocal()))
.fontSize(12),
],
).opacity(0.75).padding(top: 4),
],
),
),
if (event.metadata != null)
ExpansionTile(
minTileHeight: 40,
tilePadding: EdgeInsets.symmetric(horizontal: 16),
title: Text('eventMetadata').tr(),
expandedAlignment: Alignment.topLeft,
expandedCrossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
JsonEncoder.withIndent('\t')
.convert(event.metadata),
style: GoogleFonts.robotoMono(),
).padding(vertical: 8, horizontal: 16),
],
).padding(bottom: 6),
],
),
),
node: TimelineNode(
indicator: DotIndicator(),
startConnector: SolidLineConnector(),
endConnector: SolidLineConnector(),
),
);
},
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,186 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/auth.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
const Map<String, IconData> kAuthTicketIcon = {
'ios': Symbols.ios,
'android': Symbols.android,
'macos': Symbols.computer,
'windows nt': Symbols.laptop_windows,
'linux': Symbols.laptop,
};
class AccountAuthTicket extends StatefulWidget {
const AccountAuthTicket({super.key});
@override
State<AccountAuthTicket> createState() => _AccountAuthTicketState();
}
class _AccountAuthTicketState extends State<AccountAuthTicket> {
bool _isBusy = false;
int? _totalCount;
final List<SnAuthTicket> _authTickets = List.empty(growable: true);
Future<void> _fetchAuthTickets() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/id/users/me/tickets',
queryParameters: {
'take': 10,
'offset': _authTickets.length,
},
);
_totalCount = resp.data['count'];
_authTickets.addAll(
(resp.data['data'] as List<dynamic>)
.map((e) => SnAuthTicket.fromJson(e)),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _deleteAuthTicket(SnAuthTicket ticket) async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete(
'/cgi/id/users/me/tickets/${ticket.id}',
);
setState(() {
_authTickets.remove(ticket);
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
int? _currentTicketId;
@override
void initState() {
super.initState();
_fetchAuthTickets();
final ua = context.read<UserProvider>();
ua.atkClaims.then((value) {
if (value == null) return;
_currentTicketId = int.parse(value['sed']);
});
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountAuthTickets').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_totalCount = null;
return _fetchAuthTickets();
},
child: InfiniteList(
padding: EdgeInsets.zero,
onFetchData: _fetchAuthTickets,
isLoading: _isBusy,
hasReachedMax:
_totalCount != null && _authTickets.length >= _totalCount!,
itemCount: _authTickets.length,
itemBuilder: (context, idx) {
final ticket = _authTickets[idx];
final platform = RegExp(r'\(([^;]+);')
.firstMatch(ticket.userAgent)
?.group(1);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
kAuthTicketIcon[platform!.toLowerCase()] ?? Symbols.web,
),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ticket.ipAddress,
style: TextStyle(fontSize: 15),
),
Text(ticket.userAgent).opacity(0.8),
if (ticket.location?.isNotEmpty ?? false)
const Gap(4),
if (ticket.location?.isNotEmpty ?? false)
Text(ticket.location!).opacity(0.8),
const Gap(4),
Text('authTicketCreatedAt'.tr(args: [
(DateFormat().format(ticket.createdAt.toLocal()))
])).fontSize(12).opacity(0.75),
if (ticket.expiredAt != null)
Text('authTicketExpiredAt'.tr(args: [
(DateFormat()
.format(ticket.expiredAt!.toLocal()))
])).fontSize(12).opacity(0.75),
if (ticket.lastGrantAt != null)
Text('authTicketLastGrantAt'.tr(args: [
(DateFormat()
.format(ticket.lastGrantAt!.toLocal()))
])).fontSize(12).opacity(0.75),
const Gap(4),
if (_currentTicketId == ticket.id)
Text('authTicketCurrent'.tr())
.fontSize(11)
.bold()
.opacity(0.75),
Text('#${ticket.id}').fontSize(11).opacity(0.75),
],
),
),
IconButton(
iconSize: 20,
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
icon: const Icon(Symbols.logout),
onPressed: _currentTicketId == ticket.id
? null
: () {
_deleteAuthTicket(ticket);
},
),
],
).padding(horizontal: 16, vertical: 12);
},
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,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,122 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
final Map<String, String> kNotifyTopicMap = {
'interactive.reply': 'notificationTopicPostReply'.tr(),
'interactive.feedback': 'notificationTopicPostFeedback'.tr(),
'interactive.subscription': 'notificationTopicPostSubscription'.tr(),
'messaging.message': 'notificationTopicMessaging'.tr(),
'messaging.call': 'notificationTopicMessagingCall'.tr(),
'general': 'notificationTopicGeneral'.tr(),
};
class AccountNotifyPrefsScreen extends StatefulWidget {
const AccountNotifyPrefsScreen({super.key});
@override
State<AccountNotifyPrefsScreen> createState() =>
_AccountNotifyPrefsScreenState();
}
class _AccountNotifyPrefsScreenState extends State<AccountNotifyPrefsScreen> {
bool _isBusy = true;
Map<String, bool> _config = {};
Future<void> _getPreferences() async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
try {
final resp = await sn.client.get('/cgi/id/preferences/notifications');
_config = resp.data['config']
.map((k, v) => MapEntry(k, v as bool))
.cast<String, bool>();
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _savePreferences() async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
try {
await sn.client.put(
'/cgi/id/preferences/notifications',
data: {
'config': _config,
},
);
if (!mounted) return;
context.showSnackbar('accountSettingsApplied'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_getPreferences();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountSettingsNotify').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.save),
title: Text('save').tr(),
enabled: !_isBusy,
onTap: () {
_savePreferences();
},
),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: kNotifyTopicMap.length,
itemBuilder: (context, index) {
final element = kNotifyTopicMap.entries.elementAt(index);
return CheckboxListTile(
title: Text(element.value),
subtitle: Text(
element.key,
style: GoogleFonts.robotoMono(fontSize: 12),
),
value: _config[element.key] ?? true,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onChanged: (value) {
setState(() {
_config[element.key] = value ?? false;
});
},
);
},
),
),
],
),
);
}
}

View File

@ -0,0 +1,147 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountSecurityPrefsScreen extends StatefulWidget {
const AccountSecurityPrefsScreen({super.key});
@override
State<AccountSecurityPrefsScreen> createState() =>
_AccountSecurityPrefsScreenState();
}
class _AccountSecurityPrefsScreenState
extends State<AccountSecurityPrefsScreen> {
bool _isBusy = true;
Map<String, dynamic> _config = {
'maximum_auth_steps': 2,
'always_risky': false,
};
Future<void> _getPreferences() async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
try {
final resp = await sn.client.get('/cgi/id/preferences/auth');
_config = resp.data['config']
.map((k, v) => MapEntry(k, v as bool))
.cast<String, bool>();
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _savePreferences() async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
try {
await sn.client.put(
'/cgi/id/preferences/auth',
data: {
'config': _config,
},
);
if (!mounted) return;
context.showSnackbar('accountSettingsApplied'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_getPreferences();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountSettingsSecurity').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.save),
title: Text('save').tr(),
enabled: !_isBusy,
onTap: () {
_savePreferences();
},
),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
ListTile(
title: Text('authMaximumAuthSteps').tr(),
subtitle: Text('authMaximumAuthStepsDescription')
.plural(_config['maximum_auth_steps'] ?? 2),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Symbols.remove),
onPressed: () {
if (_config['maximum_auth_steps'] > 1) {
setState(() => _config['maximum_auth_steps']--);
}
},
),
IconButton(
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Symbols.add),
onPressed: () {
if (_config['maximum_auth_steps'] < 99) {
setState(() => _config['maximum_auth_steps']++);
}
},
),
],
),
),
CheckboxListTile(
title: Text('authAlwaysRisky').tr(),
subtitle: Text('authAlwaysRiskyDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
value: _config['always_risky'] ?? false,
onChanged: (value) {
setState(() => _config['always_risky'] = value);
},
),
],
),
),
],
),
);
}
}

View File

@ -7,6 +7,7 @@ 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/screens/captcha.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:url_launcher/url_launcher_string.dart';
@ -33,10 +34,20 @@ class _RegisterScreenState extends State<RegisterScreen> {
final username = _usernameController.value.text;
final nickname = _nicknameController.value.text;
final password = _passwordController.value.text;
if (email.isEmpty || username.isEmpty || nickname.isEmpty || password.isEmpty) {
if (email.isEmpty ||
username.isEmpty ||
nickname.isEmpty ||
password.isEmpty) {
return;
}
final captchaTk = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => TurnstileScreen(),
),
);
if (captchaTk == null) return;
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/users', data: {
@ -45,6 +56,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
'email': email,
'password': password,
'language': EasyLocalization.of(context)!.currentLocale.toString(),
'captcha_token': captchaTk,
});
if (!context.mounted) return;
@ -91,8 +103,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
children: [
TextFormField(
validator: (value) {
if (value == null || value.length < 4 || value.length > 32) {
return 'fieldUsernameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
if (value == null ||
value.length < 4 ||
value.length > 32) {
return 'fieldUsernameLengthLimit'
.tr(args: [4.toString(), 32.toString()]);
}
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
return 'fieldUsernameAlphanumOnly'.tr();
@ -108,13 +123,17 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldUsername'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
validator: (value) {
if (value == null || value.length < 4 || value.length > 32) {
return 'fieldNicknameLengthLimit'.tr(args: [4.toString(), 32.toString()]);
if (value == null ||
value.length < 4 ||
value.length > 32) {
return 'fieldNicknameLengthLimit'
.tr(args: [4.toString(), 32.toString()]);
}
return null;
},
@ -127,7 +146,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldNickname'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
@ -149,7 +169,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldEmail'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
@ -169,7 +190,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
border: const UnderlineInputBorder(),
labelText: 'fieldPassword'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
],
).padding(horizontal: 7),
@ -186,9 +208,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
),
style:
Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withAlpha((255 * 0.75).round()),
),
),
Material(
color: Colors.transparent,

38
lib/screens/captcha.dart Normal file
View File

@ -0,0 +1,38 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class TurnstileScreen extends StatefulWidget {
const TurnstileScreen({
super.key,
});
@override
State<TurnstileScreen> createState() => _TurnstileScreenState();
}
class _TurnstileScreenState extends State<TurnstileScreen> {
@override
Widget build(BuildContext context) {
final cfg = context.read<ConfigProvider>();
return AppScaffold(
appBar: AppBar(title: Text("reCaptcha").tr()),
body: InAppWebView(
initialUrlRequest: URLRequest(
url: WebUri('${cfg.serverUrl}/captcha?redirect_uri=solink://captcha'),
),
shouldOverrideUrlLoading: (controller, navigationAction) async {
Uri? url = navigationAction.request.url;
if (url != null && url.queryParameters.containsKey('captcha_tk')) {
Navigator.pop(context, url.queryParameters['captcha_tk']!);
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
),
);
}
}

View File

@ -52,8 +52,10 @@ class ChatRoomScreen extends StatefulWidget {
class _ChatRoomScreenState extends State<ChatRoomScreen> {
bool _isBusy = false;
bool _isCalling = false;
bool _isJoining = false;
SnChannel? _channel;
SnChannelMember? _currentMember;
SnChannelMember? _otherMember;
SnChatCall? _ongoingCall;
@ -67,7 +69,24 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
StreamSubscription? _wsSubscription;
// TODO fetch user identity and ask them to join the channel or not
Future<void> _joinChannel() async {
try {
setState(() => _isJoining = true);
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client
.post('/cgi/im/channels/${_channel!.keyPath}/members', data: {
'related': ua.user?.name,
});
_initializeChat();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isJoining = true);
}
}
Future<void> _fetchChannel() async {
setState(() => _isBusy = true);
@ -76,6 +95,12 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
_channel = await chan.getChannel('${widget.scope}:${widget.alias}');
if (!mounted || _channel == null) return;
final ct = context.read<ChatChannelProvider>();
try {
_currentMember = await ct.getChannelProfile(_channel!);
} catch (_) {}
if (!mounted || _currentMember == null) return;
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
if (_channel!.type == 1) {
@ -204,11 +229,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
}
@override
void initState() {
super.initState();
_messageController = ChatMessageController(context);
Future<void> _initializeChat() async {
_fetchChannel().then((_) async {
if (_currentMember == null) return;
await _messageController.initialize(_channel!);
if (widget.extra != null) {
@ -230,6 +253,13 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
_fetchOngoingCall(),
]);
});
}
@override
void initState() {
super.initState();
_messageController = ChatMessageController(context);
_initializeChat();
_wsSubscription = _ws.pk.stream.listen((event) {
switch (event.method) {
@ -281,25 +311,27 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
: _channel?.name ?? 'loading'.tr(),
),
actions: [
IconButton(
onPressed: () {
setState(() => _isEncrypted = !_isEncrypted);
_inputGlobalKey.currentState?.setEncrypted(_isEncrypted);
},
icon: _isEncrypted
? const Icon(Symbols.lock)
: const Icon(Symbols.no_encryption),
),
IconButton(
icon: _ongoingCall == null
? const Icon(Symbols.call)
: const Icon(Symbols.call_end),
onPressed: _isCalling
? null
: _ongoingCall == null
? _makeCall
: _endCall,
),
if (_currentMember != null)
IconButton(
onPressed: () {
setState(() => _isEncrypted = !_isEncrypted);
_inputGlobalKey.currentState?.setEncrypted(_isEncrypted);
},
icon: _isEncrypted
? const Icon(Symbols.lock)
: const Icon(Symbols.no_encryption),
),
if (_currentMember != null)
IconButton(
icon: _ongoingCall == null
? const Icon(Symbols.call)
: const Icon(Symbols.call_end),
onPressed: _isCalling
? null
: _ongoingCall == null
? _makeCall
: _endCall,
),
IconButton(
icon: const Icon(Symbols.more_vert),
onPressed: () {
@ -348,7 +380,41 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
).height(_ongoingCall != null ? 54 : 0, animate: true).animate(
const Duration(milliseconds: 300),
Curves.fastLinearToSlowEaseIn),
if (_messageController.isPending)
if (_currentMember == null && !_isBusy)
Expanded(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Symbols.person_remove, size: 40, fill: 1),
const Gap(8),
Text('chatUnjoined'.tr(), textAlign: TextAlign.center)
.fontSize(16)
.bold(),
Text('chatUnjoinedDescription'.tr(),
textAlign: TextAlign.center)
.fontSize(13),
if (_channel!.isPublic)
Text('chatUnjoinedPublicDescription'.tr(),
textAlign: TextAlign.center)
.fontSize(13)
.padding(top: 8),
if (_channel!.isPublic)
TextButton(
style: ButtonStyle(
visualDensity: VisualDensity.compact,
),
onPressed: _isJoining ? null : _joinChannel,
child: Text('chatJoin').tr(),
),
],
),
),
),
)
else if (_messageController.isPending)
Expanded(
child: const CircularProgressIndicator().center(),
)
@ -403,7 +469,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
},
),
),
if (!_messageController.isPending)
if (!_messageController.isPending && _currentMember != null)
Material(
elevation: 2,
child: Column(

View File

@ -5,16 +5,22 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/feed/feed_news.dart';
import 'package:surface/widgets/feed/feed_unknown.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/fediverse_post_item.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -75,6 +81,8 @@ class _ExploreScreenState extends State<ExploreScreen>
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(() {
@ -147,6 +155,7 @@ class _ExploreScreenState extends State<ExploreScreen>
@override
Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return AppScaffold(
floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: ExpandableFab(
@ -219,10 +228,15 @@ class _ExploreScreenState extends State<ExploreScreen>
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
leading: AutoAppBarLeading(),
leading:
ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
? AutoAppBarLeading()
: null,
titleSpacing: 0,
title: Row(
children: [
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
const Gap(8),
IconButton(
icon: const Icon(Symbols.shuffle),
onPressed: () {
@ -263,6 +277,14 @@ class _ExploreScreenState extends State<ExploreScreen>
}
});
},
onMixedFeedChanged: (flag) {
_listKey.currentState?.setRealm(null);
_listKey.currentState?.setCategory(null);
if (_showCategories && flag) {
_toggleShowCategories();
}
_listKey.currentState?.refreshPosts();
},
),
);
},
@ -286,9 +308,11 @@ class _ExploreScreenState extends State<ExploreScreen>
),
)
: null,
onPressed: () {
_toggleShowCategories();
},
onPressed: cfg.mixedFeed
? null
: () {
_toggleShowCategories();
},
),
IconButton(
icon: const Icon(Symbols.search),
@ -298,74 +322,78 @@ class _ExploreScreenState extends State<ExploreScreen>
),
const Gap(8),
],
bottom: TabBar(
isScrollable: _showCategories,
controller: _tabController,
tabs: _showCategories
? [
for (final category in _categories)
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
kCategoryIcons[category.alias] ??
Symbols.question_mark,
color: Theme.of(context)
.appBarTheme
.foregroundColor!,
),
const Gap(8),
Flexible(
child: Text(
'postCategory${category.alias.capitalize()}'
.trExists()
? 'postCategory${category.alias.capitalize()}'
.tr()
: category.name,
maxLines: 1,
).textColor(
Theme.of(context)
.appBarTheme
.foregroundColor!,
bottom: cfg.mixedFeed
? null
: TabBar(
isScrollable: _showCategories,
controller: _tabController,
tabs: _showCategories
? [
for (final category in _categories)
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Icon(
kCategoryIcons[category.alias] ??
Symbols.question_mark,
color: Theme.of(context)
.appBarTheme
.foregroundColor!,
),
const Gap(8),
Flexible(
child: Text(
'postCategory${category.alias.capitalize()}'
.trExists()
? 'postCategory${category.alias.capitalize()}'
.tr()
: category.name,
maxLines: 1,
).textColor(
Theme.of(context)
.appBarTheme
.foregroundColor!,
),
),
],
),
),
],
),
),
]
: [
for (final channel in kPostChannels)
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
kPostChannelIcons[
kPostChannels.indexOf(channel)],
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor,
),
const Gap(8),
Flexible(
child: Text(
'postChannel$channel',
maxLines: 1,
).tr().textColor(
Theme.of(context)
]
: [
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,
),
),
],
),
),
],
),
),
],
),
],
),
),
),
];
@ -390,21 +418,22 @@ class _PostListWidgetState extends State<_PostListWidget> {
SnRealm? get realm => _selectedRealm;
final List<SnPost> _posts = List.empty(growable: true);
final List<SnFeedEntry> _feed = List.empty(growable: true);
SnRealm? _selectedRealm;
String? _selectedChannel;
SnPostCategory? _selectedCategory;
int? _postCount;
bool _hasLoadedAll = false;
// Called when using regular feed
Future<void> _fetchPosts() async {
if (_postCount != null && _posts.length >= _postCount!) return;
if (_hasLoadedAll) return;
setState(() => _isBusy = true);
final pt = context.read<SnPostContentProvider>();
final result = await pt.listPosts(
take: 10,
offset: _posts.length,
offset: _feed.length,
categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
channel: _selectedChannel,
realm: _selectedRealm?.alias,
@ -413,8 +442,36 @@ class _PostListWidgetState extends State<_PostListWidget> {
if (!mounted) return;
_postCount = result.$2;
_posts.addAll(out);
final postCount = result.$2;
_feed.addAll(
out.map((ele) => SnFeedEntry(
type: 'interactive.post',
data: ele.toJson(),
createdAt: ele.createdAt)),
);
_hasLoadedAll = _feed.length >= postCount;
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);
}
@ -435,77 +492,81 @@ class _PostListWidgetState extends State<_PostListWidget> {
}
Future<void> refreshPosts() {
_postCount = null;
_posts.clear();
return _fetchPosts();
_hasLoadedAll = false;
_feed.clear();
final cfg = context.read<ConfigProvider>();
if (cfg.mixedFeed) {
return _fetchFeed();
} else {
return _fetchPosts();
}
}
@override
void initState() {
super.initState();
_fetchPosts();
final cfg = context.read<ConfigProvider>();
if (cfg.mixedFeed) {
_fetchFeed();
} else {
_fetchPosts();
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
if (_selectedCategory != null)
MaterialBanner(
content: Text(
'postFilterWithCategory'.tr(args: [
'postCategory${_selectedCategory!.alias.capitalize()}'.trExists()
? 'postCategory${_selectedCategory!.alias.capitalize()}'
.tr()
: _selectedCategory!.name,
]),
),
leading: Icon(kCategoryIcons[_selectedCategory!.alias] ??
Symbols.question_mark),
actions: [
IconButton(
icon: const Icon(Symbols.clear),
onPressed: () {
setState(() => _selectedCategory = null);
refreshPosts();
},
),
],
padding: const EdgeInsets.only(left: 20, right: 4),
),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () => refreshPosts(),
child: InfiniteList(
padding: EdgeInsets.only(top: 8),
itemCount: _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),
),
),
),
final cfg = context.watch<ConfigProvider>();
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () => refreshPosts(),
child: InfiniteList(
padding: EdgeInsets.only(top: 8),
itemCount: _feed.length,
isLoading: _isBusy,
centerLoading: true,
hasReachedMax: _hasLoadedAll,
onFetchData: cfg.mixedFeed ? _fetchFeed : _fetchPosts,
itemBuilder: (context, idx) {
final ele = _feed[idx];
switch (ele.type) {
case 'interactive.post':
return OpenablePostItem(
data: SnPost.fromJson(ele.data),
maxWidth: 640,
onChanged: (data) {
setState(() {
_feed[idx] = _feed[idx].copyWith(data: data.toJson());
});
},
onDeleted: () {
refreshPosts();
},
);
case 'fediverse.post':
return FediversePostWidget(
data: SnFediversePost.fromJson(ele.data),
maxWidth: 640,
);
case 'reader.news':
return Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: NewsFeedEntry(data: ele),
),
);
default:
return Container(
constraints: BoxConstraints(maxWidth: 640),
child: FeedUnknownEntry(data: ele),
);
}
},
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
),
],
),
);
}
}
@ -513,54 +574,71 @@ class _PostListWidgetState extends State<_PostListWidget> {
class _PostListRealmPopup extends StatelessWidget {
final List<SnRealm>? realms;
final Function(SnRealm?) onUpdate;
final Function(bool) onMixedFeedChanged;
const _PostListRealmPopup({
required this.realms,
required this.onUpdate,
required this.onMixedFeedChanged,
});
@override
Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.face, size: 24),
const Icon(Symbols.tune, size: 24),
const Gap(16),
Text('accountRealms', style: Theme.of(context).textTheme.titleLarge)
Text('filterFeed', style: Theme.of(context).textTheme.titleLarge)
.tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
ListTile(
leading: const Icon(Symbols.close),
title: Text('postInGlobal').tr(),
subtitle: Text('postViewInGlobalDescription').tr(),
SwitchListTile(
secondary: const Icon(Symbols.merge_type),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
onUpdate.call(null);
Navigator.pop(context);
title: Text('mixedFeed').tr(),
subtitle: Text('mixedFeedDescription').tr(),
value: cfg.mixedFeed,
onChanged: (value) {
cfg.mixedFeed = value;
onMixedFeedChanged.call(value);
},
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
itemCount: realms?.length ?? 0,
itemBuilder: (context, idx) {
final realm = realms![idx];
return ListTile(
title: Text(realm.name),
subtitle: Text('@${realm.alias}'),
leading: AccountImage(content: realm.avatar, radius: 18),
onTap: () {
onUpdate.call(realm);
Navigator.pop(context);
},
);
if (!cfg.mixedFeed)
ListTile(
leading: const Icon(Symbols.close),
title: Text('postInGlobal').tr(),
subtitle: Text('postViewInGlobalDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
onUpdate.call(null);
Navigator.pop(context);
},
),
),
if (!cfg.mixedFeed) const Divider(height: 1),
if (!cfg.mixedFeed)
Expanded(
child: ListView.builder(
itemCount: realms?.length ?? 0,
itemBuilder: (context, idx) {
final realm = realms![idx];
return ListTile(
title: Text(realm.name),
subtitle: Text('@${realm.alias}'),
leading: AccountImage(content: realm.avatar, radius: 18),
onTap: () {
onUpdate.call(realm);
Navigator.pop(context);
},
);
},
),
),
],
);
}

View File

@ -6,7 +6,6 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:html/parser.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
@ -19,14 +18,16 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/widget.dart';
import 'package:surface/screens/captcha.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/news.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/updater.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:url_launcher/url_launcher_string.dart';
class HomeScreenDashEntry {
final String name;
@ -66,7 +67,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
HomeScreenDashEntry(
name: 'dashEntryTodayNews',
child: _HomeDashTodayNews(),
child: _HomeDashServiceStatus(),
cols: MediaQuery.of(context).size.width >= 640 ? 3 : 2,
),
];
@ -99,6 +100,7 @@ class _HomeScreenState extends State<HomeScreen> {
right: 8,
),
),
_HomeDashUnconfirmedWidget().padding(horizontal: 8),
_HomeDashSpecialDayWidget().padding(horizontal: 8),
StaggeredGrid.extent(
maxCrossAxisExtent: 280,
@ -123,6 +125,64 @@ class _HomeScreenState extends State<HomeScreen> {
}
}
class _HomeDashUnconfirmedWidget extends StatelessWidget {
const _HomeDashUnconfirmedWidget();
Future<void> _resendConfirmationEmail(BuildContext context) async {
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.patch('/cgi/id/users/me/confirm');
if (!context.mounted) return;
context.showSnackbar('accountUnconfirmedResendSuccessful'.tr());
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
}
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
if (ua.user == null || ua.user?.confirmedAt != null) {
return SizedBox.shrink();
}
return Card(
margin: EdgeInsets.zero,
child: ListTile(
leading: const Icon(Symbols.shield),
title: Text('accountUnconfirmedTitle').tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('accountUnconfirmedSubtitle').tr(),
const Gap(4),
Row(
children: [
Text('accountUnconfirmedUnreceived').tr(),
const Gap(4),
InkWell(
child: Text(
'accountUnconfirmedResend',
style: TextStyle(
decoration: TextDecoration.underline,
color: Theme.of(context).colorScheme.onSurface,
),
).tr(),
onTap: () {
_resendConfirmationEmail(context);
},
),
],
),
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
),
).padding(bottom: 8);
}
}
class _HomeDashUpdateWidget extends StatelessWidget {
final EdgeInsets? padding;
@ -131,7 +191,6 @@ class _HomeDashUpdateWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final config = context.watch<ConfigProvider>();
return ListenableBuilder(
listenable: config,
builder: (context, _) {
@ -245,21 +304,31 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
}
}
class _HomeDashTodayNews extends StatefulWidget {
const _HomeDashTodayNews();
class _HomeDashServiceStatus extends StatefulWidget {
const _HomeDashServiceStatus();
@override
State<_HomeDashTodayNews> createState() => _HomeDashTodayNewsState();
State<_HomeDashServiceStatus> createState() => _HomeDashServiceStatusState();
}
class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
SnNewsArticle? _article;
class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
Map<String, dynamic>? _statuses;
ServiceStatus? _serviceStatus;
Future<void> _fetchArticle() async {
Future<void> _fetchStatuses() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/re/news/today');
_article = SnNewsArticle.fromJson(resp.data['data']);
final resp = await sn.client.get('/directory/status');
_statuses = resp.data;
if (_statuses!.values.contains(false)) {
if (_statuses!.values.contains(true)) {
_serviceStatus = ServiceStatus.downgraded;
} else {
_serviceStatus = ServiceStatus.failed;
}
} else {
_serviceStatus = ServiceStatus.operational;
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
@ -272,7 +341,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
@override
initState() {
super.initState();
_fetchArticle();
_fetchStatuses();
}
@override
@ -284,73 +353,127 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
children: [
Row(
children: [
const Icon(Symbols.newspaper),
const Icon(Symbols.flare),
const Gap(8),
Text(
'newsToday',
style: Theme.of(context).textTheme.titleLarge,
).tr()
],
).padding(horizontal: 18, top: 12, bottom: 8),
if (_article != null)
Expanded(
child: InkWell(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
_article!.title,
style: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontSize: 18),
maxLines:
MediaQuery.of(context).size.width >= 640 ? 2 : 1,
overflow: TextOverflow.ellipsis,
),
Text(
parse(_article!.description)
.children
.map((e) => e.text.trim())
.join(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Builder(builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 2,
children: [
Text(DateFormat().format(date)).textStyle(
Theme.of(context).textTheme.bodySmall!),
Text(' · ')
.textStyle(Theme.of(context).textTheme.bodySmall!)
.bold(),
Text(RelativeTime(context).format(date)).textStyle(
Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75);
}),
],
).padding(horizontal: 16),
onTap: () {
GoRouter.of(context).pushNamed(
'newsDetail',
pathParameters: {'hash': _article!.hash},
);
Expanded(
child: Text(
'serviceStatus',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
),
IconButton(
icon: const Icon(Symbols.launch, size: 20),
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
onPressed: () {
launchUrlString('https://status.solsynth.dev');
},
),
)
else
],
).padding(horizontal: 18, top: 12, bottom: 8),
Container(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 6),
width: double.infinity,
color: _serviceStatus == null
? Theme.of(context).colorScheme.surfaceContainerHigh
: switch (_serviceStatus) {
ServiceStatus.operational => Colors.green[300],
ServiceStatus.failed => Colors.red[300],
_ => Colors.orange[300],
},
child: _serviceStatus == null
? Row(
children: [
const Icon(
Symbols.more_horiz,
size: 20,
),
const Gap(10),
Text('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(
child: Center(
child: CircularProgressIndicator(),
child: SingleChildScrollView(
padding: EdgeInsets.only(top: 6),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final entry in _statuses!.entries)
Tooltip(
message: kServicesName[entry.key] != null
? 'serviceName${kServicesName[entry.key]}'.tr()
: 'unknown'.tr(),
child: Chip(
visualDensity:
VisualDensity(horizontal: -4, vertical: -4),
avatar: entry.value
? const Icon(
Symbols.circle,
color: Colors.green,
fill: 1,
size: 16,
)
: AnimateWidgetExtensions(const Icon(
Symbols.error,
color: Colors.red,
fill: 1,
size: 16,
))
.animate(onPlay: (e) => e.repeat())
.fadeIn(
duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
label: Text(kServicesName[entry.key] ?? entry.key),
),
),
],
).padding(horizontal: 12),
),
)
),
],
),
);
@ -386,11 +509,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
}
Future<void> _doCheckIn() async {
final captchaTk = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => TurnstileScreen(),
),
);
if (captchaTk == null) return;
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final home = context.read<HomeWidgetProvider>();
final resp = await sn.client.post('/cgi/id/check-in');
final resp = await sn.client.post('/cgi/id/check-in', data: {
'captcha_token': captchaTk,
});
_todayRecord = SnCheckInRecord.fromJson(resp.data);
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
} catch (err) {
@ -758,8 +890,10 @@ class _HomeDashRecommendationPostWidgetState
).tr(),
],
),
Text('${_currentPage + 1}/${_posts?.length ?? 0}',
style: GoogleFonts.robotoMono())
Text(
'${_currentPage + 1}/${_posts?.length ?? 0}',
style: GoogleFonts.robotoMono(),
)
],
).padding(horizontal: 18, top: 12, bottom: 8),
Expanded(
@ -777,6 +911,7 @@ class _HomeDashRecommendationPostWidgetState
child: PostItem(
data: _posts![index],
showMenu: false,
showFullPost: true,
).padding(bottom: 8),
onTap: () {
GoRouter.of(context)

View File

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

View File

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

View File

@ -22,7 +22,8 @@ class PostDetailScreen extends StatefulWidget {
final SnPost? preload;
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
State<PostDetailScreen> createState() => _PostDetailScreenState();
@ -88,14 +89,16 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
TextSpan(
text: _data?.body['title'] ?? 'postNoun'.tr(),
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
color:
Theme.of(context).appBarTheme.foregroundColor!,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: 'postDetail'.tr(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
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 && _data!.type != 'video')
if (_data != null)
SliverToBoxAdapter(
child: Divider(height: 1).padding(top: 8),
),
if (_data != null)
SliverToBoxAdapter(
child: Container(
constraints: BoxConstraints(maxWidth: maxWidth),
@ -141,7 +147,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
).padding(horizontal: 20, vertical: 12).center(),
),
),
if (_data != null && ua.isAuthorized && _data!.type != 'video')
if (_data != null && ua.isAuthorized)
SliverToBoxAdapter(
child: PostCommentQuickAction(
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(
key: _childListKey,
parentPost: _data!,
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

@ -1,7 +1,7 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.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';
@ -77,7 +77,8 @@ class _PostDraftBoxState extends State<PostDraftBox> {
},
);
},
separatorBuilder: (_, __) => const Gap(8),
separatorBuilder: (_, __) =>
const Divider().padding(vertical: 2),
),
),
),

View File

@ -45,12 +45,14 @@ class PostEditorExtra {
final String? title;
final String? description;
final List<PostWriteMedia>? attachments;
final SnRealm? realm;
const PostEditorExtra({
this.text,
this.title,
this.description,
this.attachments,
this.realm,
});
}
@ -164,7 +166,7 @@ class _PostEditorScreenState extends State<PostEditorScreen>
});
hotKeyManager.register(_saveDraftHotKey, keyDownHandler: (_) async {
if (mounted) {
_writeController.sendPost(context);
_writeController.sendPost(context, saveAsDraft: true);
}
});
}
@ -263,6 +265,7 @@ class _PostEditorScreenState extends State<PostEditorScreen>
_writeController.descriptionController.text =
widget.extraProps!.description ?? '';
_writeController.addAttachments(widget.extraProps!.attachments ?? []);
_writeController.setRealm(widget.extraProps!.realm);
}
}

View File

@ -45,7 +45,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
}
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;
setState(() => _isBusy = true);
@ -152,7 +153,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
},
);
},
separatorBuilder: (_, __) => const Gap(8),
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
),
Positioned(
top: 16,
@ -166,7 +167,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 24),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (value) {
_searchTerm = value;
},

View File

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

View File

@ -0,0 +1,149 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class RealmCommunityScreen extends StatefulWidget {
final String alias;
const RealmCommunityScreen({super.key, required this.alias});
@override
State<RealmCommunityScreen> createState() => _RealmCommunityScreenState();
}
class _RealmCommunityScreenState extends State<RealmCommunityScreen> {
SnRealm? _realm;
Future<void> _fetchRealm() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/realms/${widget.alias}');
_realm = SnRealm.fromJson(resp.data);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
rethrow;
} finally {
setState(() {});
}
}
bool _isBusy = false;
int? _totalCount;
final List<SnPost> _posts = List.empty(growable: true);
Future<void> _fetchPosts() async {
setState(() => _isBusy = true);
try {
final pt = context.read<SnPostContentProvider>();
final out = await pt.listPosts(
take: 10,
offset: _posts.length,
realm: _realm?.id.toString(),
);
_totalCount = out.$2;
_posts.addAll(out.$1);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchRealm();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text(_realm?.name ?? 'loading'.tr()),
),
floatingActionButton: _realm != null
? FloatingActionButton(
child: const Icon(Symbols.edit),
onPressed: () {
GoRouter.of(context).pushNamed(
'postEditor',
extra: PostEditorExtra(realm: _realm!),
);
},
)
: null,
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_realm == null)
Expanded(
child: Center(
child: CircularProgressIndicator().center(),
),
),
if (_realm != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('realmCommunity'.tr(args: [_realm!.name]))
.fontSize(17)
.padding(horizontal: 20, bottom: 4),
Text('postTotalCount'.plural(_totalCount ?? 0))
.fontSize(13)
.opacity(0.8)
.padding(horizontal: 20, bottom: 4),
],
).padding(horizontal: 20, vertical: 16),
const Divider(height: 1),
if (_realm != null)
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
onRefresh: _fetchPosts,
child: InfiniteList(
padding: const EdgeInsets.only(top: 8),
itemCount: _posts.length,
isLoading: _isBusy,
hasReachedMax:
_totalCount != null && _posts.length >= _totalCount!,
onFetchData: _fetchPosts,
itemBuilder: (context, idx) {
final post = _posts[idx];
return OpenablePostItem(
data: post,
maxWidth: 640,
onChanged: (data) {
setState(() => _posts[idx] = data);
},
onDeleted: () {
setState(() => _posts.removeAt(idx));
},
);
},
separatorBuilder: (_, __) =>
const Divider().padding(vertical: 2),
),
),
),
),
],
),
);
}
}

View File

@ -318,7 +318,7 @@ class _RealmPostListWidgetState extends State<_RealmPostListWidget> {
},
);
},
separatorBuilder: (_, __) => const Gap(8),
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
),
),
).padding(top: 8);

View File

@ -336,6 +336,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
setState(() {});
},
),
CheckboxListTile(
secondary: const Icon(Symbols.hide),
title: Text('settingsHideBottomNav').tr(),
subtitle: Text('settingsHideBottomNavDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
value: _prefs.getBool(kAppHideBottomNav) ?? false,
onChanged: (value) {
_prefs.setBool(kAppHideBottomNav, value ?? false);
final cfg = context.read<ConfigProvider>();
cfg.calcDrawerSize(context);
setState(() {});
},
),
ListTile(
leading: const Icon(Symbols.font_download),
title: Text('settingsCustomFonts').tr(),
@ -387,6 +400,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
.fontSize(17)
.tr()
.padding(horizontal: 20, bottom: 4),
CheckboxListTile(
secondary: const Icon(Symbols.translate),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
title: Text('settingsAutoTranslate').tr(),
subtitle: Text('settingsAutoTranslateDescription').tr(),
value: _prefs.getBool(kAppAutoTranslate) ?? false,
onChanged: (value) {
setState(() {
_prefs.setBool(kAppAutoTranslate, value ?? false);
});
},
),
CheckboxListTile(
secondary: const Icon(Symbols.vibration),
contentPadding: const EdgeInsets.only(left: 24, right: 17),

View File

@ -88,6 +88,8 @@ Future<ThemeData> createAppTheme(
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
},
),
progressIndicatorTheme: ProgressIndicatorThemeData(year2023: false),
sliderTheme: SliderThemeData(year2023: false),
);
}

View File

@ -162,3 +162,25 @@ abstract class SnAbuseReport with _$SnAbuseReport {
factory SnAbuseReport.fromJson(Map<String, Object?> json) =>
_$SnAbuseReportFromJson(json);
}
@freezed
abstract class SnActionEvent with _$SnActionEvent {
const factory SnActionEvent({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String type,
required Map<String, dynamic>? metadata,
required String? location,
required double? coordinateX,
required double? coordinateY,
required String ipAddress,
required String userAgent,
required SnAccount account,
required int accountId,
}) = _SnActionEvent;
factory SnActionEvent.fromJson(Map<String, Object?> json) =>
_$SnActionEventFromJson(json);
}

View File

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

View File

@ -283,3 +283,39 @@ Map<String, dynamic> _$SnAbuseReportToJson(_SnAbuseReport instance) =>
'status': instance.status,
'account_id': instance.accountId,
};
_SnActionEvent _$SnActionEventFromJson(Map<String, dynamic> json) =>
_SnActionEvent(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
type: json['type'] as String,
metadata: json['metadata'] as Map<String, dynamic>?,
location: json['location'] as String?,
coordinateX: (json['coordinate_x'] as num?)?.toDouble(),
coordinateY: (json['coordinate_y'] as num?)?.toDouble(),
ipAddress: json['ip_address'] as String,
userAgent: json['user_agent'] as String,
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$SnActionEventToJson(_SnActionEvent instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'type': instance.type,
'metadata': instance.metadata,
'location': instance.location,
'coordinate_x': instance.coordinateX,
'coordinate_y': instance.coordinateY,
'ip_address': instance.ipAddress,
'user_agent': instance.userAgent,
'account': instance.account.toJson(),
'account_id': instance.accountId,
};

View File

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

View File

@ -217,7 +217,9 @@ mixin _$SnAuthTicket {
String? get accessToken;
String? get refreshToken;
String get ipAddress;
String get location;
String? get location;
double? get coordinateX;
double? get coordinateY;
String get userAgent;
DateTime? get expiredAt;
DateTime? get lastGrantAt;
@ -261,6 +263,10 @@ mixin _$SnAuthTicket {
other.ipAddress == ipAddress) &&
(identical(other.location, location) ||
other.location == location) &&
(identical(other.coordinateX, coordinateX) ||
other.coordinateX == coordinateX) &&
(identical(other.coordinateY, coordinateY) ||
other.coordinateY == coordinateY) &&
(identical(other.userAgent, userAgent) ||
other.userAgent == userAgent) &&
(identical(other.expiredAt, expiredAt) ||
@ -278,29 +284,32 @@ mixin _$SnAuthTicket {
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
stepRemain,
grantToken,
accessToken,
refreshToken,
ipAddress,
location,
userAgent,
expiredAt,
lastGrantAt,
availableAt,
nonce,
accountId,
const DeepCollectionEquality().hash(factorTrail));
int get hashCode => Object.hashAll([
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
stepRemain,
grantToken,
accessToken,
refreshToken,
ipAddress,
location,
coordinateX,
coordinateY,
userAgent,
expiredAt,
lastGrantAt,
availableAt,
nonce,
accountId,
const DeepCollectionEquality().hash(factorTrail)
]);
@override
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? refreshToken,
String ipAddress,
String location,
String? location,
double? coordinateX,
double? coordinateY,
String userAgent,
DateTime? expiredAt,
DateTime? lastGrantAt,
@ -351,7 +362,9 @@ class _$SnAuthTicketCopyWithImpl<$Res> implements $SnAuthTicketCopyWith<$Res> {
Object? accessToken = freezed,
Object? refreshToken = freezed,
Object? ipAddress = null,
Object? location = null,
Object? location = freezed,
Object? coordinateX = freezed,
Object? coordinateY = freezed,
Object? userAgent = null,
Object? expiredAt = freezed,
Object? lastGrantAt = freezed,
@ -397,10 +410,18 @@ class _$SnAuthTicketCopyWithImpl<$Res> implements $SnAuthTicketCopyWith<$Res> {
? _self.ipAddress
: ipAddress // ignore: cast_nullable_to_non_nullable
as String,
location: null == location
location: freezed == location
? _self.location
: 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
? _self.userAgent
: userAgent // ignore: cast_nullable_to_non_nullable
@ -447,6 +468,8 @@ class _SnAuthTicket implements SnAuthTicket {
required this.refreshToken,
required this.ipAddress,
required this.location,
required this.coordinateX,
required this.coordinateY,
required this.userAgent,
required this.expiredAt,
required this.lastGrantAt,
@ -477,7 +500,11 @@ class _SnAuthTicket implements SnAuthTicket {
@override
final String ipAddress;
@override
final String location;
final String? location;
@override
final double? coordinateX;
@override
final double? coordinateY;
@override
final String userAgent;
@override
@ -538,6 +565,10 @@ class _SnAuthTicket implements SnAuthTicket {
other.ipAddress == ipAddress) &&
(identical(other.location, location) ||
other.location == location) &&
(identical(other.coordinateX, coordinateX) ||
other.coordinateX == coordinateX) &&
(identical(other.coordinateY, coordinateY) ||
other.coordinateY == coordinateY) &&
(identical(other.userAgent, userAgent) ||
other.userAgent == userAgent) &&
(identical(other.expiredAt, expiredAt) ||
@ -555,29 +586,32 @@ class _SnAuthTicket implements SnAuthTicket {
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
stepRemain,
grantToken,
accessToken,
refreshToken,
ipAddress,
location,
userAgent,
expiredAt,
lastGrantAt,
availableAt,
nonce,
accountId,
const DeepCollectionEquality().hash(_factorTrail));
int get hashCode => Object.hashAll([
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
stepRemain,
grantToken,
accessToken,
refreshToken,
ipAddress,
location,
coordinateX,
coordinateY,
userAgent,
expiredAt,
lastGrantAt,
availableAt,
nonce,
accountId,
const DeepCollectionEquality().hash(_factorTrail)
]);
@override
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? refreshToken,
String ipAddress,
String location,
String? location,
double? coordinateX,
double? coordinateY,
String userAgent,
DateTime? expiredAt,
DateTime? lastGrantAt,
@ -631,7 +667,9 @@ class __$SnAuthTicketCopyWithImpl<$Res>
Object? accessToken = freezed,
Object? refreshToken = freezed,
Object? ipAddress = null,
Object? location = null,
Object? location = freezed,
Object? coordinateX = freezed,
Object? coordinateY = freezed,
Object? userAgent = null,
Object? expiredAt = freezed,
Object? lastGrantAt = freezed,
@ -677,10 +715,18 @@ class __$SnAuthTicketCopyWithImpl<$Res>
? _self.ipAddress
: ipAddress // ignore: cast_nullable_to_non_nullable
as String,
location: null == location
location: freezed == location
? _self.location
: 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
? _self.userAgent
: 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?,
refreshToken: json['refresh_token'] as String?,
ipAddress: json['ip_address'] as String,
location: json['location'] as String,
location: json['location'] as String?,
coordinateX: (json['coordinate_x'] as num?)?.toDouble(),
coordinateY: (json['coordinate_y'] as num?)?.toDouble(),
userAgent: json['user_agent'] as String,
expiredAt: json['expired_at'] == null
? null
@ -64,6 +66,8 @@ Map<String, dynamic> _$SnAuthTicketToJson(_SnAuthTicket instance) =>
'refresh_token': instance.refreshToken,
'ip_address': instance.ipAddress,
'location': instance.location,
'coordinate_x': instance.coordinateX,
'coordinate_y': instance.coordinateY,
'user_agent': instance.userAgent,
'expired_at': instance.expiredAt?.toIso8601String(),
'last_grant_at': instance.lastGrantAt?.toIso8601String(),

View File

@ -166,3 +166,53 @@ abstract class SnSubscription with _$SnSubscription {
factory SnSubscription.fromJson(Map<String, Object?> 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

View File

@ -282,3 +282,77 @@ Map<String, dynamic> _$SnSubscriptionToJson(_SnSubscription instance) =>
'follower_id': instance.followerId,
'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

@ -14,6 +14,7 @@ class AccountImage extends StatelessWidget {
final Widget? fallbackWidget;
final Widget? badge;
final Offset? badgeOffset;
final FilterQuality? filterQuality;
const AccountImage({
super.key,
@ -25,6 +26,7 @@ class AccountImage extends StatelessWidget {
this.fallbackWidget,
this.badge,
this.badgeOffset,
this.filterQuality,
});
@override
@ -54,6 +56,7 @@ class AccountImage extends StatelessWidget {
)
: AutoResizeUniversalImage(
sn.getAttachmentUrl(url),
filterQuality: filterQuality,
key: Key('attachment-${content.hashCode}'),
fit: BoxFit.cover,
),

View File

@ -45,11 +45,25 @@ class AttachmentItem extends StatelessWidget {
case 'image':
return Hero(
tag: 'attachment-${data!.rid}-$tag',
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(data!.rid),
key: Key('attachment-${data!.rid}-$tag'),
fit: fit,
filterQuality: filterQuality,
child: Stack(
fit: StackFit.expand,
children: [
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':
@ -251,6 +265,7 @@ class _AttachmentItemContentVideoState
return GestureDetector(
behavior: HitTestBehavior.opaque,
child: Stack(
fit: StackFit.expand,
children: [
if (widget.data.thumbnail != null)
AutoResizeUniversalImage(
@ -455,6 +470,7 @@ class _AttachmentItemContentAudioState
return GestureDetector(
behavior: HitTestBehavior.opaque,
child: Stack(
fit: StackFit.expand,
children: [
if (widget.data.thumbnail != null)
AspectRatio(

View File

@ -95,8 +95,9 @@ class _AttachmentListState extends State<AttachmentList> {
),
),
onTap: () {
if (widget.data.firstOrNull?.mediaType != SnMediaType.image)
if (widget.data.firstOrNull?.mediaType != SnMediaType.image) {
return;
}
context.pushTransparentRoute(
AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(),
@ -209,7 +210,7 @@ class _AttachmentListState extends State<AttachmentList> {
child: AspectRatio(
aspectRatio: widget.data[0]?.data['ratio']?.toDouble() ?? 1,
child: ScrollConfiguration(
behavior: _AttachmentListScrollBehavior(),
behavior: AttachmentListScrollBehavior(),
child: ListView.separated(
padding: widget.padding,
shrinkWrap: true,
@ -283,7 +284,7 @@ class _AttachmentListState extends State<AttachmentList> {
}
}
class _AttachmentListScrollBehavior extends MaterialScrollBehavior {
class AttachmentListScrollBehavior extends MaterialScrollBehavior {
@override
Set<PointerDeviceKind> get dragDevices =>
{PointerDeviceKind.touch, PointerDeviceKind.mouse};

View File

@ -10,6 +10,7 @@ import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/keypair.dart';
import 'package:surface/providers/translation.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart';
@ -18,6 +19,7 @@ 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/context_menu.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/link_preview.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:flutter_animate/flutter_animate.dart';
@ -63,7 +65,7 @@ class ChatMessage extends StatelessWidget {
key: Key('chat-message-${data.id}'),
iconOnLeftSwipe: Symbols.reply,
iconOnRightSwipe: Symbols.edit,
swipeSensitivity: 20,
swipeSensitivity: 10,
onLeftSwipe: onReply != null ? (_) => onReply!(data) : null,
onRightSwipe: (onEdit != null && isOwner) ? (_) => onEdit!(data) : null,
child: ContextMenuArea(
@ -228,7 +230,7 @@ class ChatMessage extends StatelessWidget {
}
}
class _ChatMessageText extends StatelessWidget {
class _ChatMessageText extends StatefulWidget {
final SnChatMessage data;
final Function(SnChatMessage)? onReply;
final Function(SnChatMessage)? onEdit;
@ -237,13 +239,56 @@ class _ChatMessageText extends StatelessWidget {
const _ChatMessageText(
{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
Widget build(BuildContext context) {
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -252,38 +297,50 @@ class _ChatMessageText extends StatelessWidget {
final List<ContextMenuButtonItem> items =
editableTextState.contextMenuButtonItems;
if (onReply != null) {
if (widget.onReply != null) {
items.insert(
0,
ContextMenuButtonItem(
label: 'reply'.tr(),
onPressed: () {
ContextMenuController.removeAny();
onReply?.call(data);
widget.onReply?.call(widget.data);
},
),
);
}
if (isOwner && onEdit != null) {
if (isOwner && widget.onEdit != null) {
items.insert(
1,
ContextMenuButtonItem(
label: 'edit'.tr(),
onPressed: () {
ContextMenuController.removeAny();
onEdit?.call(data);
widget.onEdit?.call(widget.data);
},
),
);
}
if (isOwner && onDelete != null) {
if (isOwner && widget.onDelete != null) {
items.insert(
2,
ContextMenuButtonItem(
label: 'delete'.tr(),
onPressed: () {
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();
},
),
);
@ -294,26 +351,47 @@ class _ChatMessageText extends StatelessWidget {
buttonItems: items,
);
},
child: switch (data.body['algorithm']) {
'rsa' => _ChatDecryptMessage(message: data),
child: switch (widget.data.body['algorithm']) {
'rsa' => _ChatDecryptMessage(message: widget.data),
_ => MarkdownTextContent(
content: data.body['text'],
content: _displayText,
isAutoWarp: true,
isEnlargeSticker:
RegExp(r"^:([-\w]+):$").hasMatch(data.body['text'] ?? ''),
isEnlargeSticker: RegExp(r"^:([-\w]+):$")
.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),
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(
children: [
const Icon(Symbols.file_present, size: 20),
const Gap(4),
Text('messageFileHint'.plural(data.body['attachments']!.length)),
Text('messageFileHint'
.plural(widget.data.body['attachments']!.length)),
],
).opacity(0.8);
}

View File

@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:material_symbols_icons/symbols.dart';
@ -445,6 +446,61 @@ class _StickerPicker extends StatelessWidget {
@override
Widget build(BuildContext context) {
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(
onTap: () {
onDismiss?.call();

View File

@ -36,10 +36,12 @@ class ChatTypingIndicator extends StatelessWidget {
'messageTyping'
.plural(controller.typingMembers.length, args: [
controller.typingMembers
.map((ele) => (ele.nick?.isNotEmpty ?? false)
? ele.nick!
: ud.getFromCache(ele.accountId)?.name ??
'unknown')
.map(
(ele) => (ele.nick?.isNotEmpty ?? false)
? ele.nick!
: ud.getFromCache(ele.accountId)?.nick ??
'unknown',
)
.join(', '),
]),
),

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

@ -76,7 +76,10 @@ class _LoadingIndicatorState extends State<LoadingIndicator>
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2.5),
child: CircularProgressIndicator(
strokeWidth: 2.5,
padding: EdgeInsets.zero,
),
),
const Gap(16),
Text('loading').tr(),

View File

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
@ -174,7 +176,7 @@ class MarkdownTextContent extends StatelessWidget {
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: attachment.metadata['ratio'] ??
aspectRatio: attachment.metadata['ratio']?.toDouble() ??
switch (attachment.mimetype
.split('/')
.firstOrNull) {
@ -207,10 +209,14 @@ class MarkdownTextContent extends StatelessWidget {
}
return const SizedBox.shrink();
}
width ??= math.min(MediaQuery.of(context).size.width, 640);
height ??= width;
return UniversalImage(
url,
width: width,
height: height,
cacheHeight: height,
cacheWidth: width,
fit: fit,
);
},

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

@ -0,0 +1,114 @@
import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart';
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');
},
),
],
),
PlatformMenuItem(
shortcut: const SingleActivator(
LogicalKeyboardKey.keyH,
meta: true,
),
label: 'trayMenuHide'.tr(),
onSelected: () {
appWindow.hide();
},
),
if (onQuit != null)
PlatformMenuItem(
shortcut: const SingleActivator(
LogicalKeyboardKey.keyQ,
meta: true,
),
label: 'trayMenuExit'.tr(),
onSelected: () {
onQuit?.call();
},
),
],
),
],
child: child,
);
}
}

View File

@ -37,17 +37,15 @@ class _AppBottomNavigationBarState extends State<AppBottomNavigationBar> {
...nav.destinations.where((ele) => ele.isPinned),
];
return BottomNavigationBar(
currentIndex: nav.getIndexInRange(0, nav.pinnedDestinationCount),
type: BottomNavigationBarType.fixed,
showUnselectedLabels: false,
items: destinations.map((ele) {
return BottomNavigationBarItem(
return NavigationBar(
selectedIndex: nav.getIndexInRange(0, nav.pinnedDestinationCount),
destinations: destinations.map((ele) {
return NavigationDestination(
icon: ele.icon,
label: ele.label.tr(),
);
}).toList(),
onTap: (idx) {
onDestinationSelected: (idx) {
nav.setIndex(idx);
GoRouter.of(context).goNamed(destinations[idx].screen);
},

View File

@ -1,14 +1,23 @@
import 'dart:io';
import 'package:animations/animations.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/navigation.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:surface/widgets/version_label.dart';
class AppNavigationDrawer extends StatefulWidget {
@ -25,12 +34,15 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<NavigationProvider>().autoDetectIndex(GoRouter.maybeOf(context));
context
.read<NavigationProvider>()
.autoDetectIndex(GoRouter.maybeOf(context));
});
}
@override
Widget build(BuildContext context) {
final ua = context.read<UserProvider>();
final nav = context.watch<NavigationProvider>();
final cfg = context.watch<ConfigProvider>();
@ -39,60 +51,258 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
return ListenableBuilder(
listenable: nav,
builder: (context, _) {
final destinations = [
...nav.destinations.where((ele) => ele.isPinned),
...nav.destinations.where((ele) => !ele.isPinned),
];
return NavigationDrawer(
return Drawer(
elevation: widget.elevation,
backgroundColor: backgroundColor,
selectedIndex: nav.currentIndex,
children: [
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS) && !cfg.drawerIsExpanded)
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / MediaQuery.of(context).devicePixelRatio,
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!kIsWeb &&
(Platform.isWindows ||
Platform.isLinux ||
Platform.isMacOS) &&
!cfg.drawerIsExpanded)
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / MediaQuery.of(context).devicePixelRatio,
),
),
),
child: WindowTitleBarBox(),
),
child: WindowTitleBarBox(),
Gap(MediaQuery.of(context).padding.top),
Expanded(
child: _DrawerContentList(),
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Solar Network').bold(),
AppVersionLabel(),
],
).padding(
horizontal: 32,
vertical: 12,
),
...destinations.where((ele) => ele.isPinned).map((ele) {
return NavigationDrawerDestination(
icon: ele.icon,
label: Text(ele.label).tr(),
);
}),
const Divider(),
...destinations.where((ele) => !ele.isPinned).map((ele) {
return NavigationDrawerDestination(
icon: ele.icon,
label: Text(ele.label).tr(),
);
}),
],
onDestinationSelected: (idx) {
nav.setIndex(idx);
GoRouter.of(context).goNamed(destinations[idx].screen);
Scaffold.of(context).closeDrawer();
},
if (cfg.hideBottomNav)
Row(
spacing: 8,
children: nav.destinations.where((ele) => ele.isPinned).map(
(ele) {
return Expanded(
child: Tooltip(
message: ele.label.tr(),
child: IconButton.filledTonal(
icon: ele.icon,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
onPressed: () {
GoRouter.of(context).goNamed(ele.screen);
Scaffold.of(context).closeDrawer();
},
),
),
);
},
).toList(),
).padding(horizontal: 16),
Align(
alignment: Alignment.bottomCenter,
child: ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 24),
leading: AccountImage(content: ua.user?.avatar),
title: Text(ua.user?.nick ?? 'unknown'.tr()).fontSize(15),
subtitle:
Text('@${ua.user?.name ?? 'unknown'.tr()}').fontSize(13),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.notifications, fill: 1),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
onPressed: () {
GoRouter.of(context).pushNamed('notification');
Scaffold.of(context).closeDrawer();
},
),
IconButton(
icon: const Icon(Symbols.settings, fill: 1),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
onPressed: () {
GoRouter.of(context).pushNamed('settings');
Scaffold.of(context).closeDrawer();
},
),
],
),
onTap: () {
GoRouter.of(context).pushNamed('account');
Scaffold.of(context).closeDrawer();
},
),
),
Gap(MediaQuery.of(context).padding.bottom),
],
),
);
},
);
}
}
class _DrawerContentList extends StatelessWidget {
const _DrawerContentList();
@override
Widget build(BuildContext context) {
final ct = context.read<ChatChannelProvider>();
final sn = context.read<SnNetworkProvider>();
final nav = context.watch<NavigationProvider>();
final rel = context.read<SnRealmProvider>();
return PageTransitionSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (Widget child, Animation<double> primaryAnimation,
Animation<double> secondaryAnimation) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: nav.focusedRealm == null
? ListView(
key: const Key('realm-list-view'),
padding: EdgeInsets.zero,
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Solar Network').bold(),
AppVersionLabel(),
],
).padding(
horizontal: 32,
vertical: 12,
),
ListTile(
minTileHeight: 48,
contentPadding: EdgeInsets.only(left: 28, right: 16),
leading: const Icon(Symbols.home),
title: Text('screenHome').tr(),
onTap: () {
GoRouter.of(context).goNamed('home');
Scaffold.of(context).closeDrawer();
},
),
...rel.availableRealms.map((ele) {
return ListTile(
minTileHeight: 48,
contentPadding: EdgeInsets.symmetric(horizontal: 24),
leading: AccountImage(
content: ele.avatar,
radius: 16,
),
title: Text(ele.name),
onTap: () {
nav.setFocusedRealm(ele);
},
);
}),
],
)
: ListView(
key: ValueKey(nav.focusedRealm),
padding: EdgeInsets.zero,
children: [
if (nav.focusedRealm!.banner != null)
AspectRatio(
aspectRatio: 16 / 9,
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(
nav.focusedRealm!.banner!,
),
fit: BoxFit.cover,
),
),
ListTile(
minTileHeight: 48,
tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: EdgeInsets.only(
left: 24,
right: 16,
),
leading: AccountImage(
content: nav.focusedRealm!.avatar,
radius: 16,
),
trailing: IconButton(
icon: const Icon(Symbols.close),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
onPressed: () {
nav.setFocusedRealm(null);
},
),
title: Text(nav.focusedRealm!.name),
onTap: () {
GoRouter.of(context).pushNamed(
'realmDetail',
pathParameters: {
'alias': nav.focusedRealm!.alias,
},
);
Scaffold.of(context).closeDrawer();
},
),
ListTile(
minTileHeight: 48,
contentPadding: EdgeInsets.only(
left: 28,
right: 8,
),
leading: const Icon(Symbols.globe),
title: Text('community').tr(),
onTap: () {
GoRouter.of(context).pushNamed(
'realmCommunity',
pathParameters: {
'alias': nav.focusedRealm!.alias,
},
);
Scaffold.of(context).closeDrawer();
},
),
if (ct.availableChannels
.where((ele) => ele.realmId == nav.focusedRealm?.id)
.isNotEmpty)
const Divider(height: 1),
...(ct.availableChannels
.where((ele) => ele.realmId == nav.focusedRealm?.id)
.map((ele) {
return ListTile(
minTileHeight: 48,
contentPadding: EdgeInsets.only(
left: 28,
right: 8,
),
leading: const Icon(Symbols.tag),
title: Text(ele.name),
onTap: () {
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': ele.realm?.alias ?? 'global',
'alias': ele.alias,
},
);
Scaffold.of(context).closeDrawer();
},
);
}))
],
),
);
}
}

View File

@ -107,6 +107,7 @@ class AppRootScaffold extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
final nav = context.watch<NavigationProvider>();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final isCollapseDrawer = cfg.drawerIsCollapsed;
@ -118,8 +119,9 @@ class AppRootScaffold extends StatelessWidget {
.last
.route
.name;
final isShowBottomNavigation =
NavigationProvider.kShowBottomNavScreen.contains(routeName)
final isShowBottomNavigation = cfg.hideBottomNav
? false
: nav.showBottomNavScreen.contains(routeName)
? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
: false;
final isPopable = !NavigationProvider.kAllDestination

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:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.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:very_good_infinite_list/very_good_infinite_list.dart';
import '../../providers/sn_network.dart';
class PostCommentQuickAction extends StatelessWidget {
final double? maxWidth;
final SnPost parentPost;
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
Widget build(BuildContext context) {
@ -30,7 +30,9 @@ class PostCommentQuickAction extends StatelessWidget {
return Container(
height: 240,
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(
borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? const BorderRadius.all(Radius.circular(8))
@ -99,7 +101,8 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
Future<void> _selectAnswer(SnPost answer) async {
try {
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,
'answer_id': answer.id,
});
@ -135,7 +138,10 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
child: PostItem(
data: _posts[idx],
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) {
setState(() => _posts[idx] = data);
},
@ -145,6 +151,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
},
),
onTap: () {
Navigator.pop(context);
GoRouter.of(context).pushNamed(
'postDetail',
pathParameters: {'slug': _posts[idx].id.toString()},
@ -153,7 +160,8 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
},
);
},
separatorBuilder: (context, index) => const Divider(height: 1),
separatorBuilder: (context, index) =>
const Divider().padding(vertical: 2),
);
}
}
@ -161,11 +169,13 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
class PostCommentListPopup extends StatefulWidget {
final SnPost post;
final int commentCount;
final int depth;
const PostCommentListPopup({
super.key,
required this.post,
this.commentCount = 0,
this.depth = 1,
});
@override
@ -180,48 +190,57 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
final ua = context.watch<UserProvider>();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.comment, size: 24),
const Gap(16),
Text('postCommentsDetailed').plural(widget.commentCount).textStyle(Theme.of(context).textTheme.titleLarge!),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Expanded(
child: CustomScrollView(
slivers: [
if (ua.isAuthorized)
SliverToBoxAdapter(
child: Container(
height: 240,
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
return SizedBox(
height: MediaQuery.of(context).size.height * 0.85,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.comment, size: 24),
const Gap(16),
Text('postCommentsDetailed')
.plural(widget.commentCount)
.textStyle(Theme.of(context).textTheme.titleLarge!),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Expanded(
child: CustomScrollView(
slivers: [
if (ua.isAuthorized)
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.only(bottom: 8),
height: 240,
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
),
),
),
child: PostMiniEditor(
postReplyId: widget.post.id,
onPost: () {
_childListKey.currentState!.refresh();
},
child: PostMiniEditor(
postReplyId: widget.post.id,
onPost: () {
_childListKey.currentState!.refresh();
},
onExpand: () {
Navigator.pop(context);
},
),
),
),
PostCommentSliverList(
parentPost: widget.post,
key: _childListKey,
),
PostCommentSliverList(
parentPost: widget.post,
key: _childListKey,
),
],
],
),
),
),
],
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import 'dart:ui';
import 'package:croppy/croppy.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -491,6 +492,14 @@ class AddPostMediaButton extends StatelessWidget {
);
}
void _selectFile() async {
final result = await FilePicker.platform.pickFiles(type: FileType.any);
if (result == null) return;
onAdd(
result.files.map((e) => PostWriteMedia.fromFile(e.xFile)),
);
}
void _pasteMedia() async {
final imageBytes = await Pasteboard.image;
if (imageBytes == null) return;
@ -605,6 +614,18 @@ class AddPostMediaButton extends StatelessWidget {
_selectMedia();
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.file_upload),
const Gap(16),
Text('addAttachmentFromFiles').tr(),
],
),
onTap: () {
_selectFile();
},
),
PopupMenuItem(
child: Row(
children: [

View File

@ -9,6 +9,7 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
@ -17,8 +18,10 @@ import 'package:surface/widgets/loading_indicator.dart';
class PostMiniEditor extends StatefulWidget {
final int? postReplyId;
final Function? onPost;
final Function? onExpand;
const PostMiniEditor({super.key, this.postReplyId, this.onPost});
const PostMiniEditor(
{super.key, this.postReplyId, this.onPost, this.onExpand});
@override
State<PostMiniEditor> createState() => _PostMiniEditorState();
@ -214,12 +217,16 @@ class _PostMiniEditorState extends State<PostMiniEditor> {
onPressed: () {
GoRouter.of(context).pushNamed(
'postEditor',
extra: PostEditorExtra(
text: _writeController.contentController.text,
),
queryParameters: {
if (widget.postReplyId != null)
'replying': widget.postReplyId.toString(),
'mode': 'stories',
},
);
widget.onExpand?.call();
},
),
TextButton.icon(

View File

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

View File

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

View File

@ -102,6 +102,6 @@ static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID,
"flags", G_APPLICATION_NON_UNIQUE,
"flags", G_APPLICATION_DEFAULT_FLAGS,
nullptr));
}

View File

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

View File

@ -90,8 +90,6 @@ PODS:
- gal (1.0.0):
- Flutter
- FlutterMacOS
- geolocator_apple (1.2.0):
- FlutterMacOS
- GoogleAppMeasurement (11.8.0):
- GoogleAppMeasurement/AdIdSupport (= 11.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
@ -148,7 +146,7 @@ PODS:
- HotKey
- in_app_review (2.0.0):
- FlutterMacOS
- livekit_client (2.4.0):
- livekit_client (2.4.1):
- flutter_webrtc
- FlutterMacOS
- WebRTC-SDK (= 125.6422.06)
@ -192,6 +190,8 @@ PODS:
- sqlite3/common
- sqlite3/fts5 (3.49.1):
- sqlite3/common
- sqlite3/math (3.49.1):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.1):
- sqlite3/common
- sqlite3/rtree (3.49.1):
@ -202,6 +202,7 @@ PODS:
- sqlite3 (~> 3.49.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- tray_manager (0.0.1):
@ -232,7 +233,6 @@ DEPENDENCIES:
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- 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`)
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
- livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
@ -307,8 +307,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
gal:
:path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
geolocator_apple:
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos
hotkey_manager_macos:
:path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos
in_app_review:
@ -372,14 +370,13 @@ SPEC CHECKSUMS:
flutter_webrtc: d55fd3f5c75b42940b6b4b2cf376a5797398d1f8
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
geolocator_apple: 72a78ae3f3e4ec0db62117bd93e34523f5011d58
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager_macos: 1e2edb0c7ae4fe67108af44a9d3445de41404160
in_app_review: a6a031b9acd03c7d103e341aa334adf2c493fb93
livekit_client: 2e766be2c3ee6274a8e2633b356b98b5eb842987
livekit_client: d03409f83df069a1bb00a4c8dc78c54fb2287262
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5
@ -396,7 +393,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db
sqlite3_flutter_libs: 487032b9008b28de37c72a3aa66849ef3745f3e6
tray_manager: 9064e219c56d75c476e46b9a21182087930baf90
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
video_compress: c896234f100791b5fef7f049afa38f6d2ef7b42f

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>

View File

@ -53,10 +53,10 @@ packages:
dependency: transitive
description:
name: args
sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.6.0"
version: "2.7.0"
async:
dependency: transitive
description:
@ -213,10 +213,10 @@ packages:
dependency: transitive
description:
name: chalkdart
sha256: "08c910ee341fcdd1e46f20ddce59b13c1d85f5d97f2fd2f12014c46ede670e40"
sha256: "82dfa884e3cf97641eb0742a3b9ffd41490666b9ece548b2e32cbfefe540bf86"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
version: "2.4.0"
characters:
dependency: transitive
description:
@ -301,10 +301,10 @@ packages:
dependency: "direct main"
description:
name: croppy
sha256: "99f4fbb4a4b44d2712e8dcd61c57c1acac151bd53cab11de3babec80407ed266"
sha256: "2a69059d9ec007b79d6a494854094b2e3c0a4f7ed609cf55a4805c9de9ec171d"
url: "https://pub.dev"
source: hosted
version: "1.3.5"
version: "1.3.6"
cross_file:
dependency: "direct main"
description:
@ -314,7 +314,7 @@ packages:
source: hosted
version: "0.3.4+2"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
@ -517,10 +517,10 @@ packages:
dependency: "direct main"
description:
name: fast_rsa
sha256: "205a36c0412b9fabebf3e18ccb5221d819cc28cfb3da988c0bf7b646368d0270"
sha256: a26ad752734dc52fd51abd55248df868d7480e68d8cc8dd12413b0124bba0a7e
url: "https://pub.dev"
source: hosted
version: "3.8.0"
version: "3.8.1"
ffi:
dependency: transitive
description:
@ -541,10 +541,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "7423298f08f6fc8cce05792bae329f9a93653fc9c08712831b1a55540127995d"
sha256: "8d938fd5c11dc81bf1acd4f7f0486c683fe9e79a0b13419e27730f9ce4d8a25b"
url: "https://pub.dev"
source: hosted
version: "9.0.2"
version: "9.2.1"
file_saver:
dependency: "direct main"
description:
@ -702,6 +702,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@ -738,10 +746,10 @@ packages:
dependency: "direct main"
description:
name: flutter_expandable_fab
sha256: "85275279d19faf4fbe5639dc1f139b4555b150e079d056f085601a45688af12c"
sha256: b14caf78720a48f650e6e1a38d724e33b1f5348d646fa1c266570c31a7f87ef3
url: "https://pub.dev"
source: hosted
version: "2.3.0"
version: "2.4.0"
flutter_highlight:
dependency: "direct main"
description:
@ -937,18 +945,18 @@ packages:
dependency: "direct main"
description:
name: flutter_webrtc
sha256: "6ea3a86d95b61cfe42d5715426d355b3cece6c88d0119de428d56f6c653811ce"
sha256: b832dc76c0d1577f14aaf35e9c38d4ed7667cbc89c492b7bf4505d8d5f62e08b
url: "https://pub.dev"
source: hosted
version: "0.12.11"
version: "0.12.12+hotfix.1"
freezed:
dependency: "direct dev"
description:
name: freezed
sha256: a6274c34d61b3d68082f2b0e9a641a3ec197e525d269f35b82f62d5b2c6d9f75
sha256: "7ed2ddaa47524976d5f2aa91432a79da36a76969edd84170777ac5bea82d797c"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "3.0.4"
freezed_annotation:
dependency: "direct main"
description:
@ -981,54 +989,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -1133,6 +1093,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.15.5"
html2md:
dependency: "direct main"
description:
name: html2md
sha256: "465cf8ffa1b510fe0e97941579bf5b22e2d575f2cecb500a9c0254efe33a8036"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
http:
dependency: transitive
description:
@ -1209,10 +1177,10 @@ packages:
dependency: transitive
description:
name: image_picker_linux
sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa"
sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
version: "0.2.1+2"
image_picker_macos:
dependency: transitive
description:
@ -1294,7 +1262,7 @@ packages:
source: hosted
version: "6.9.4"
latlong2:
dependency: transitive
dependency: "direct main"
description:
name: latlong2
sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe"
@ -1353,10 +1321,10 @@ packages:
dependency: "direct main"
description:
name: livekit_client
sha256: "753bbf484c6b70f10f3dc1dc808dfe3755f472d80eb9682323cff07ad8e2609d"
sha256: "7f489fa415253d8d99c649b7efc95a733c5e5ac38dcfb02362ced99feb139376"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
version: "2.4.1"
local_notifier:
dependency: "direct main"
description:
@ -1417,10 +1385,10 @@ packages:
dependency: "direct main"
description:
name: material_symbols_icons
sha256: ca30ccbd97763353bde6bb1076aa4f4d17a40db0804384da77df142102aa225d
sha256: "99d5b0e7c65232dfe1247e0ac67eeeee2cab9da2d860748fc495d34f5e9e6397"
url: "https://pub.dev"
source: hosted
version: "4.2808.0"
version: "4.2811.0"
media_kit:
dependency: "direct main"
description:
@ -1553,10 +1521,10 @@ packages:
dependency: transitive
description:
name: package_config
sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67"
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
@ -2134,10 +2102,10 @@ packages:
dependency: transitive
description:
name: sqlite3_flutter_libs
sha256: "7adb4cc96dc08648a5eb1d80a7619070796ca6db03901ff2b6dcb15ee30468f3"
sha256: "1a96b59227828d9eb1463191d684b37a27d66ee5ed7597fcf42eee6452c88a14"
url: "https://pub.dev"
source: hosted
version: "0.5.31"
version: "0.5.32"
sqlparser:
dependency: transitive
description:
@ -2206,34 +2174,34 @@ packages:
dependency: "direct main"
description:
name: talker
sha256: "5ab7d974ad92042b3e2382441c41ec4c6e5b3fa2b4b024d8ccbfc4bc2244b7bb"
sha256: "45abef5b92f9b9bd42c3f20133ad4b20ab12e1da2aa206fc0a40ea874bed7c5d"
url: "https://pub.dev"
source: hosted
version: "4.6.14"
version: "4.7.1"
talker_dio_logger:
dependency: "direct main"
description:
name: talker_dio_logger
sha256: "71780c52951d36e94964ca06158d827dfc67aa2fb75c8b880603cfefa4377b39"
sha256: "52c1b554cccedec6073637a6d4f6a3e267dd4451c1545fe57e1b26897a560ccb"
url: "https://pub.dev"
source: hosted
version: "4.6.14"
version: "4.7.1"
talker_flutter:
dependency: "direct main"
description:
name: talker_flutter
sha256: "0cc816260b226c0ff930909c9f22984316b652b140f5eabb97ae9813ee0de135"
sha256: "77458ca11638dfefb651e898a26101ee54e60dc0b168ad7481a05b1c97ce2680"
url: "https://pub.dev"
source: hosted
version: "4.6.14"
version: "4.7.1"
talker_logger:
dependency: transitive
description:
name: talker_logger
sha256: "16ff0cfdf011f65b37957c9ff7ef7043dd9f1c8af3ccb4a44ac4a448defb9eb5"
sha256: ed9b20b8c09efff9f6b7c63fc6630ee2f84aa92661ae09e5ba04e77272bf2ad2
url: "https://pub.dev"
source: hosted
version: "4.6.14"
version: "4.7.1"
term_glyph:
dependency: transitive
description:
@ -2250,6 +2218,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -2526,10 +2502,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: b89e6e24d1454e149ab20fbb225af58660f0c0bf4475544650700d8e2da54aef
sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f
url: "https://pub.dev"
source: hosted
version: "5.11.0"
version: "5.12.0"
win32_registry:
dependency: transitive
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
# 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.
version: 2.4.2+78
version: 2.4.2+83
environment:
sdk: ^3.5.4
@ -135,10 +135,14 @@ dependencies:
talker: ^4.6.14
flutter_cache_manager: ^3.4.1
flutter_timezone: ^4.1.0
flutter_map: ^8.1.0
geolocator: ^13.0.2
flutter_map: ^8.1.1
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:
flutter_test:

View File

@ -6,6 +6,7 @@ import 'package:drift/internal/migrations.dart';
import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2;
import 'schema_v3.dart' as v3;
import 'schema_v4.dart' as v4;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -17,10 +18,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v2.DatabaseAtV2(db);
case 3:
return v3.DatabaseAtV3(db);
case 4:
return v4.DatabaseAtV4(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3];
static const versions = const [1, 2, 3, 4];
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -8,8 +8,29 @@ auto bdw = bitsdojo_window_configure(BDW_CUSTOM_FRAME | BDW_HIDE_ON_STARTUP);
#include "flutter_window.h"
#include "utils.h"
HANDLE g_hMutex = NULL;
bool CheckIfAlreadyRunning() {
g_hMutex = CreateMutex(NULL, FALSE, L"Global\\SolianDesktop");
if (g_hMutex == NULL) {
return true; // Mutex creation failed
}
if (GetLastError() == ERROR_ALREADY_EXISTS) {
CloseHandle(g_hMutex);
return true; // Another instance is running
}
return false;
}
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
_In_ wchar_t *command_line, _In_ int show_command) {
if (CheckIfAlreadyRunning()) {
return EXIT_SUCCESS;
}
// Attach to console when present (e.g., 'flutter run') or create a
// new console when running with a debugger.
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {