Compare commits
55 Commits
Author | SHA1 | Date | |
---|---|---|---|
ceb5c53229 | |||
908f0cb59e | |||
7c2b8de931 | |||
6bb9c21759 | |||
8f2fc55608 | |||
a1c4e5eca0 | |||
595050f89f | |||
0722c99f21 | |||
12d03836f9 | |||
f78d3f4fd5 | |||
e798a8ba76 | |||
c28a664373 | |||
4589722c3b | |||
38e1c51b45 | |||
610ddec05c | |||
d0276f9ac6 | |||
c1e89a2ee6 | |||
ecc79368a1 | |||
e6d732c86a | |||
dd055fb077 | |||
280840c6d8 | |||
bde62a7b2c | |||
5445c570a2 | |||
b2302f5b3c | |||
d7359cfd0d | |||
9cc577adbe | |||
dd196b7754 | |||
16c07c2133 | |||
6bcb658d44 | |||
9311bfc3b5 | |||
8dd6435a30 | |||
21a1d4a2ad | |||
603875b1af | |||
4209a13c84 | |||
55b79bfd8f | |||
6e6c3f42f6 | |||
dc38b46b2c | |||
b4990308e9 | |||
237abe564d | |||
71b41d470a | |||
7052b5b635 | |||
f356e08f79 | |||
152872db65 | |||
dfe117d04f | |||
caf63f0cbe | |||
b8f5cc82f9 | |||
360bc50f21 | |||
2de93a0486 | |||
02227852f8 | |||
ad16de595b | |||
9f8c8923d9 | |||
060bfa4887 | |||
e68ada2d04 | |||
d6013078bd | |||
5976d61997 |
12
.github/workflows/nightly.yml
vendored
12
.github/workflows/nightly.yml
vendored
@ -52,14 +52,16 @@ jobs:
|
||||
- run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev
|
||||
sudo apt-get install libmpv-dev mpv
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
sudo apt-get install keybinder-3.0
|
||||
sudo apt-get install libnotify-dev
|
||||
sudo apt-get install -y libmpv-dev mpv
|
||||
sudo apt-get install -y libayatana-appindicator3-dev
|
||||
sudo apt-get install -y keybinder-3.0
|
||||
sudo apt-get install -y libnotify-dev
|
||||
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
sudo apt-get install -y gstreamer-1.0
|
||||
- run: flutter pub get
|
||||
- run: flutter build linux
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-output-linux
|
||||
path: build/linux/x64/release/bundle
|
||||
path: build/linux/x64/release/bundle
|
||||
|
BIN
assets/audio/notify/metal-pipe.mp3
Normal file
BIN
assets/audio/notify/metal-pipe.mp3
Normal file
Binary file not shown.
BIN
assets/audio/sfx/launch-done.mp3
Normal file
BIN
assets/audio/sfx/launch-done.mp3
Normal file
Binary file not shown.
BIN
assets/audio/sfx/launch-intro.mp3
Normal file
BIN
assets/audio/sfx/launch-intro.mp3
Normal file
Binary file not shown.
BIN
assets/icon/kanban-1st.jpg
Executable file
BIN
assets/icon/kanban-1st.jpg
Executable file
Binary file not shown.
After Width: | Height: | Size: 509 KiB |
@ -130,7 +130,7 @@
|
||||
"accountPublishersSubtitle": "Manage your publish identities.",
|
||||
"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.",
|
||||
@ -338,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",
|
||||
@ -638,6 +639,7 @@
|
||||
"postQuestionUnansweredWithReward": "Unanswered Question, reward source points {}",
|
||||
"postQuestionAnswered": "Answered Question",
|
||||
"postQuestionAnswerSelect": "Select as Answer",
|
||||
"postQuestionAnswerTitle": "Selected Question",
|
||||
"postQuestionAnswerSelected": "Answer has been selected, reward has been applied.",
|
||||
"postVideoUpload": "Upload Video",
|
||||
"realmJoin": "Join Realm",
|
||||
@ -846,5 +848,98 @@
|
||||
"translating": "Translating…",
|
||||
"translated": "Translated",
|
||||
"settingsAutoTranslate": "Auto Translate",
|
||||
"settingsAutoTranslateDescription": "Automatically translate text when viewing posts and messages."
|
||||
"settingsAutoTranslateDescription": "Automatically translate text when viewing posts and messages.",
|
||||
"trayMenuHide": "Hide",
|
||||
"accountSettingsNotify": "Notify Settings",
|
||||
"accountSettingsNotifyDescription": "Adjust the types of notifications you receive.",
|
||||
"accountSettingsSecurity": "Security Settings",
|
||||
"accountSettingsSecurityDescription": "Adjust your account security settings.",
|
||||
"save": "Save",
|
||||
"notificationTopicPostFeedback": "Post Feedback",
|
||||
"notificationTopicPostReply": "Post Replies",
|
||||
"notificationTopicPostSubscription": "Post Subscriptions",
|
||||
"notificationTopicMessaging": "New Messages",
|
||||
"notificationTopicMessagingCall": "Incoming Calls",
|
||||
"notificationTopicGeneral": "General",
|
||||
"authMaximumAuthSteps": "Maximum Authenticate Steps",
|
||||
"authMaximumAuthStepsDescription": {
|
||||
"one": "Maximum ask for {} step authenticate",
|
||||
"other": "Maximum ask for {} steps authenticate"
|
||||
},
|
||||
"authAlwaysRisky": "Always Risky",
|
||||
"authAlwaysRiskyDescription": "Always ask for the highest steps count of authentication when logging in.",
|
||||
"chatUnjoined": "Unjoined Channel",
|
||||
"chatUnjoinedDescription": "You haven't joined this channel, so you can't send messages either view messages in it.",
|
||||
"chatUnjoinedPublicDescription": "Fortunately, this is a public channel, so you can join it as you want.",
|
||||
"chatJoin": "Join the Channel",
|
||||
"appInitStarting": "Starting",
|
||||
"appInitNetwork": "Initializing Network",
|
||||
"appInitUserdata": "Initializing User Data",
|
||||
"appInitWebsocket": "Establishing Solar Link",
|
||||
"appInitNotification": "Initializing Push Notifications",
|
||||
"appInitKeyPair": "Initializing Key Pairs",
|
||||
"appInitStickers": "Initializing Stickers",
|
||||
"appInitUserDirectory": "Initializing User Directory",
|
||||
"appInitRealm": "Initializing Realms",
|
||||
"appInitChat": "Initializing Chat",
|
||||
"appInitDone": "Completed",
|
||||
"community": "Community",
|
||||
"realmCommunity": "{}'s Community",
|
||||
"postTotalCount": {
|
||||
"one": "Total {} post",
|
||||
"other": "Total {} posts"
|
||||
},
|
||||
"settingsHideBottomNav": "Hide Bottom Navigation",
|
||||
"settingsHideBottomNavDescription": "Hide the bottom navigation bar, and show the navigation buttons in the drawer.",
|
||||
"reCaptcha": "reCaptcha",
|
||||
"friends": "Friends",
|
||||
"friendsDescription": "Manage your friendships.",
|
||||
"album": "Album",
|
||||
"albumDescription": "View albums and manage attachments.",
|
||||
"stickers": "Stickers",
|
||||
"stickersDescription": "View sticker packs and manage stickers.",
|
||||
"navBottomUnauthorizedCaption": "Or create an account",
|
||||
"walletCurrencyGoldenShort": "GDP",
|
||||
"walletCurrencyGolden": {
|
||||
"one": "{} Golden Point",
|
||||
"other": "{} Golden Points"
|
||||
},
|
||||
"walletTransactionTypeNormal": "Source Point",
|
||||
"walletTransactionTypeGolden": "Golden Point",
|
||||
"accountProgram": "Programs",
|
||||
"accountProgramDescription": "Explore the available member programs.",
|
||||
"accountProgramJoin": "Join Program",
|
||||
"accountProgramJoinRequirements": "Requirements",
|
||||
"accountProgramJoinPricing": "Pricing",
|
||||
"accountProgramJoinPricingHint": "Billed every (30 days) month.",
|
||||
"accountProgramLeaveHint": "After leaving the program, the source points will not be refunded.",
|
||||
"accountProgramJoined": "Joined Program.",
|
||||
"accountProgramAlreadyJoined": "Joined",
|
||||
"accountProgramLeft": "Left Program.",
|
||||
"leave": "Leave",
|
||||
"attachmentFailedToLoadMedia": "Unable to load media file, please try again later. If this error occurs repeatedly, the source file may not exist or the network connection may be abnormal.",
|
||||
"accountPunishments": "Punishments",
|
||||
"accountPunishmentsDescription": "View your account's reputation status.",
|
||||
"punishmentType0": "Strike",
|
||||
"punishmentType1": "Limited",
|
||||
"punishmentType2": "Banned",
|
||||
"punishmentOverall": "Overall Status",
|
||||
"punishmentStatusNormal": "All abilities normal",
|
||||
"punishmentStatusWarned": "All abilities normal, but at least one strike is in effect",
|
||||
"punishmentStatusLimited": "Some abilities limited, at least one limited punishment is in effect",
|
||||
"punishmentStatusLimitedFully": "All abilities limited, at least one completely limited punishment is in effect",
|
||||
"punishmentStatusBanned": "All services are terminated, banned",
|
||||
"punishmentCreatedAt": "Applied since {}",
|
||||
"punishmentExpiredAt": "Expired at {}",
|
||||
"punishmentExpiredNever": "Never expired",
|
||||
"punishmentModerator": "Moderator who made this punishment",
|
||||
"punishmentMadeBySystem": "Made by auto-mod system",
|
||||
"settingsAprilFoolFeatures": "April Fool Features",
|
||||
"settingsAprilFoolFeaturesDescription": "Enable April Fool features during April Fool, this option will only be visible during April Fool.",
|
||||
"settingsSoundEffects": "Sound Effects",
|
||||
"settingsSoundEffectsDescription": "Enable the sound effects around the app.",
|
||||
"settingsResetMemorizedWindowSize": "Reset Window Size",
|
||||
"settingsResetMemorizedWindowSizeDescription": "Reset the memorized window size, and set it to the default size.",
|
||||
"chatDirect": "Direct Messages",
|
||||
"back": "返回"
|
||||
}
|
||||
|
@ -336,6 +336,7 @@
|
||||
"fieldAttachmentRandomId": "访问 ID",
|
||||
"fieldAttachmentAlt": "概述文字",
|
||||
"addAttachmentFromAlbum": "从相册中添加附件",
|
||||
"addAttachmentFromFiles": "从文件中添加附件",
|
||||
"addAttachmentFromClipboard": "粘贴附件",
|
||||
"addAttachmentFromCameraPhoto": "拍摄照片",
|
||||
"addAttachmentFromCameraVideo": "拍摄视频",
|
||||
@ -844,5 +845,98 @@
|
||||
"translating": "正在翻译……",
|
||||
"translated": "已翻译",
|
||||
"settingsAutoTranslate": "自动翻译",
|
||||
"settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。"
|
||||
"settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。",
|
||||
"trayMenuHide": "隐藏",
|
||||
"accountSettingsNotify": "通知设置",
|
||||
"accountSettingsNotifyDescription": "调整你所收到的通知种类。",
|
||||
"accountSettingsSecurity": "安全设置",
|
||||
"accountSettingsSecurityDescription": "调整你的帐户安全设置。",
|
||||
"save": "保存",
|
||||
"notificationTopicPostFeedback": "帖子数据反馈",
|
||||
"notificationTopicPostReply": "帖子回复",
|
||||
"notificationTopicPostSubscription": "帖子订阅",
|
||||
"notificationTopicMessaging": "消息",
|
||||
"notificationTopicMessagingCall": "通话",
|
||||
"notificationTopicGeneral": "杂项",
|
||||
"authMaximumAuthSteps": "最大验证步骤",
|
||||
"authMaximumAuthStepsDescription": {
|
||||
"one": "登入时最多要求 {} 步验证",
|
||||
"other": "登入时最多要求 {} 步验证"
|
||||
},
|
||||
"authAlwaysRisky": "总是风险",
|
||||
"authAlwaysRiskyDescription": "在登入时始终按最高标准要求验证。",
|
||||
"chatUnjoined": "未加入频道",
|
||||
"chatUnjoinedDescription": "你没有加入这个频道,所以你也无法发送消息或者查看这个频道中的消息。",
|
||||
"chatUnjoinedPublicDescription": "但幸运的是,这是一个公开频道,所以你可以主动加入。",
|
||||
"chatJoin": "加入频道",
|
||||
"appInitStarting": "启动中",
|
||||
"appInitNetwork": "正在初始化网络",
|
||||
"appInitUserdata": "正在初始化用户数据",
|
||||
"appInitWebsocket": "正在建立 Solar Link",
|
||||
"appInitNotification": "正在初始化推送通知",
|
||||
"appInitKeyPair": "正在初始化密钥对",
|
||||
"appInitStickers": "正在初始化贴图包",
|
||||
"appInitUserDirectory": "正在初始化用户目录",
|
||||
"appInitRealm": "正在初始化领域信息",
|
||||
"appInitChat": "正在初始化聊天",
|
||||
"appInitDone": "完成",
|
||||
"community": "社区",
|
||||
"realmCommunity": "{}的社区",
|
||||
"postTotalCount": {
|
||||
"zero": "没有帖子",
|
||||
"one": "共 {} 条帖子"
|
||||
},
|
||||
"settingsHideBottomNav": "隐藏底部导航栏",
|
||||
"settingsHideBottomNavDescription": "隐藏底部导航栏,在侧边栏抽屉显示导航按钮。",
|
||||
"reCaptcha": "人机验证",
|
||||
"friends": "好友",
|
||||
"friendsDescription": "管理好友关系。",
|
||||
"album": "相册",
|
||||
"albumDescription": "查看相册与管理上传附件。",
|
||||
"stickers": "贴图",
|
||||
"stickersDescription": "查看贴图包与管理贴图。",
|
||||
"navBottomUnauthorizedCaption": "或者注册一个账号",
|
||||
"walletCurrencyGoldenShort": "金点",
|
||||
"walletCurrencyGolden": {
|
||||
"one": "{} 金点",
|
||||
"other": "{} 金点"
|
||||
},
|
||||
"walletTransactionTypeNormal": "源点",
|
||||
"walletTransactionTypeGolden": "金点",
|
||||
"accountProgram": "计划",
|
||||
"accountProgramDescription": "了解可用的成员计划。",
|
||||
"accountProgramJoin": "加入计划",
|
||||
"accountProgramJoinRequirements": "要求",
|
||||
"accountProgramJoinPricing": "价格",
|
||||
"accountProgramJoinPricingHint": "按月(30 天)收费",
|
||||
"accountProgramLeaveHint": "离开计划后,之前花费的源点不会退款。",
|
||||
"accountProgramJoined": "已加入计划。",
|
||||
"accountProgramLeft": "已离开计划。",
|
||||
"accountProgramAlreadyJoined": "已加入",
|
||||
"leave": "离开",
|
||||
"attachmentFailedToLoadMedia": "无法加载媒体文件,请稍后重试。若此错误重复出现,可能源文件不存在或者网络连接异常。",
|
||||
"accountPunishments": "处分",
|
||||
"accountPunishmentsDescription": "查看你帐号的信誉状态。",
|
||||
"punishmentType0": "警告",
|
||||
"punishmentType1": "停权",
|
||||
"punishmentType2": "封禁",
|
||||
"punishmentOverall": "总体状态",
|
||||
"punishmentStatusNormal": "所有功能正常",
|
||||
"punishmentStatusWarned": "所有功能正常,但有警告生效",
|
||||
"punishmentStatusLimited": "部份功能暂时受限,有至少一个停权生效",
|
||||
"punishmentStatusLimitedFully": "所有功能暂时受限,有至少一个完全停权生效",
|
||||
"punishmentStatusBanned": "所有服务终止,已被封禁",
|
||||
"punishmentCreatedAt": "宣布于 {}",
|
||||
"punishmentExpiredAt": "到期于 {}",
|
||||
"punishmentExpiredNever": "永久生效",
|
||||
"punishmentModerator": "责任管理员",
|
||||
"punishmentMadeBySystem": "由系统自动裁决",
|
||||
"settingsAprilFoolFeatures": "愚人节特性",
|
||||
"settingsAprilFoolFeaturesDescription": "在愚人节期间启用愚人节特性,该选项只会在愚人节期间显示。",
|
||||
"settingsSoundEffects": "声音效果",
|
||||
"settingsSoundEffectsDescription": "在一些场合下启用声音特效。",
|
||||
"settingsResetMemorizedWindowSize": "重置窗口大小",
|
||||
"settingsResetMemorizedWindowSizeDescription": "重置记忆的窗口大小,以重新设置为默认大小。",
|
||||
"chatDirect": "私信",
|
||||
"back": "返回"
|
||||
}
|
||||
|
@ -336,6 +336,7 @@
|
||||
"fieldAttachmentRandomId": "訪問 ID",
|
||||
"fieldAttachmentAlt": "概述文字",
|
||||
"addAttachmentFromAlbum": "從相冊中添加附件",
|
||||
"addAttachmentFromFiles": "從文件中添加附件",
|
||||
"addAttachmentFromClipboard": "粘貼附件",
|
||||
"addAttachmentFromCameraPhoto": "拍攝照片",
|
||||
"addAttachmentFromCameraVideo": "拍攝視頻",
|
||||
@ -844,5 +845,55 @@
|
||||
"translating": "正在翻譯……",
|
||||
"translated": "已翻譯",
|
||||
"settingsAutoTranslate": "自動翻譯",
|
||||
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。"
|
||||
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
|
||||
"trayMenuHide": "隱藏",
|
||||
"accountSettingsNotify": "通知設置",
|
||||
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
|
||||
"accountSettingsSecurity": "安全設置",
|
||||
"accountSettingsSecurityDescription": "調整你的帳户安全設置。",
|
||||
"save": "保存",
|
||||
"notificationTopicPostFeedback": "帖子數據反饋",
|
||||
"notificationTopicPostReply": "帖子回覆",
|
||||
"notificationTopicPostSubscription": "帖子訂閲",
|
||||
"notificationTopicMessaging": "消息",
|
||||
"notificationTopicMessagingCall": "通話",
|
||||
"notificationTopicGeneral": "雜項",
|
||||
"authMaximumAuthSteps": "最大驗證步驟",
|
||||
"authMaximumAuthStepsDescription": {
|
||||
"one": "登入時最多要求 {} 步驗證",
|
||||
"other": "登入時最多要求 {} 步驗證"
|
||||
},
|
||||
"authAlwaysRisky": "總是風險",
|
||||
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
|
||||
"chatUnjoined": "未加入頻道",
|
||||
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
|
||||
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
|
||||
"chatJoin": "加入頻道",
|
||||
"appInitStarting": "啓動中",
|
||||
"appInitNetwork": "正在初始化網絡",
|
||||
"appInitUserdata": "正在初始化用户數據",
|
||||
"appInitWebsocket": "正在建立 Solar Link",
|
||||
"appInitNotification": "正在初始化推送通知",
|
||||
"appInitKeyPair": "正在初始化密鑰對",
|
||||
"appInitStickers": "正在初始化貼圖包",
|
||||
"appInitUserDirectory": "正在初始化用户目錄",
|
||||
"appInitRealm": "正在初始化領域信息",
|
||||
"appInitChat": "正在初始化聊天",
|
||||
"appInitDone": "完成",
|
||||
"community": "社區",
|
||||
"realmCommunity": "{}的社區",
|
||||
"postTotalCount": {
|
||||
"zero": "沒有帖子",
|
||||
"one": "共 {} 條帖子"
|
||||
},
|
||||
"settingsHideBottomNav": "隱藏底部導航欄",
|
||||
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
|
||||
"reCaptcha": "人機驗證",
|
||||
"friends": "好友",
|
||||
"friendsDescription": "管理好友關係。",
|
||||
"album": "相冊",
|
||||
"albumDescription": "查看相冊與管理上傳附件。",
|
||||
"stickers": "貼圖",
|
||||
"stickersDescription": "查看貼圖包與管理貼圖。",
|
||||
"navBottomUnauthorizedCaption": "或者註冊一個賬號"
|
||||
}
|
||||
|
@ -336,6 +336,7 @@
|
||||
"fieldAttachmentRandomId": "訪問 ID",
|
||||
"fieldAttachmentAlt": "概述文字",
|
||||
"addAttachmentFromAlbum": "從相冊中添加附件",
|
||||
"addAttachmentFromFiles": "從文件中添加附件",
|
||||
"addAttachmentFromClipboard": "粘貼附件",
|
||||
"addAttachmentFromCameraPhoto": "拍攝照片",
|
||||
"addAttachmentFromCameraVideo": "拍攝視頻",
|
||||
@ -844,5 +845,55 @@
|
||||
"translating": "正在翻譯……",
|
||||
"translated": "已翻譯",
|
||||
"settingsAutoTranslate": "自動翻譯",
|
||||
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。"
|
||||
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
|
||||
"trayMenuHide": "隱藏",
|
||||
"accountSettingsNotify": "通知設置",
|
||||
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
|
||||
"accountSettingsSecurity": "安全設置",
|
||||
"accountSettingsSecurityDescription": "調整你的帳戶安全設置。",
|
||||
"save": "保存",
|
||||
"notificationTopicPostFeedback": "帖子數據反饋",
|
||||
"notificationTopicPostReply": "帖子回覆",
|
||||
"notificationTopicPostSubscription": "帖子訂閱",
|
||||
"notificationTopicMessaging": "消息",
|
||||
"notificationTopicMessagingCall": "通話",
|
||||
"notificationTopicGeneral": "雜項",
|
||||
"authMaximumAuthSteps": "最大驗證步驟",
|
||||
"authMaximumAuthStepsDescription": {
|
||||
"one": "登入時最多要求 {} 步驗證",
|
||||
"other": "登入時最多要求 {} 步驗證"
|
||||
},
|
||||
"authAlwaysRisky": "總是風險",
|
||||
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
|
||||
"chatUnjoined": "未加入頻道",
|
||||
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
|
||||
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
|
||||
"chatJoin": "加入頻道",
|
||||
"appInitStarting": "啟動中",
|
||||
"appInitNetwork": "正在初始化網絡",
|
||||
"appInitUserdata": "正在初始化用戶數據",
|
||||
"appInitWebsocket": "正在建立 Solar Link",
|
||||
"appInitNotification": "正在初始化推送通知",
|
||||
"appInitKeyPair": "正在初始化密鑰對",
|
||||
"appInitStickers": "正在初始化貼圖包",
|
||||
"appInitUserDirectory": "正在初始化用戶目錄",
|
||||
"appInitRealm": "正在初始化領域信息",
|
||||
"appInitChat": "正在初始化聊天",
|
||||
"appInitDone": "完成",
|
||||
"community": "社區",
|
||||
"realmCommunity": "{}的社區",
|
||||
"postTotalCount": {
|
||||
"zero": "沒有帖子",
|
||||
"one": "共 {} 條帖子"
|
||||
},
|
||||
"settingsHideBottomNav": "隱藏底部導航欄",
|
||||
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
|
||||
"reCaptcha": "人機驗證",
|
||||
"friends": "好友",
|
||||
"friendsDescription": "管理好友關係。",
|
||||
"album": "相冊",
|
||||
"albumDescription": "查看相冊與管理上傳附件。",
|
||||
"stickers": "貼圖",
|
||||
"stickersDescription": "查看貼圖包與管理貼圖。",
|
||||
"navBottomUnauthorizedCaption": "或者註冊一個賬號"
|
||||
}
|
||||
|
1
drift_schemas/my_database/drift_schema_v4.json
Normal file
1
drift_schemas/my_database/drift_schema_v4.json
Normal file
File diff suppressed because one or more lines are too long
@ -1,5 +1,7 @@
|
||||
PODS:
|
||||
- Alamofire (5.10.2)
|
||||
- audioplayers_darwin (0.0.1):
|
||||
- Flutter
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- croppy (0.0.1):
|
||||
@ -189,8 +191,6 @@ PODS:
|
||||
- WebRTC-SDK (= 125.6422.06)
|
||||
- media_kit_libs_ios_video (1.0.4):
|
||||
- Flutter
|
||||
- media_kit_native_event_loop (1.0.0):
|
||||
- Flutter
|
||||
- media_kit_video (0.0.1):
|
||||
- Flutter
|
||||
- nanopb (3.30910.0):
|
||||
@ -212,8 +212,6 @@ PODS:
|
||||
- receive_sharing_intent (1.8.1):
|
||||
- Flutter
|
||||
- SAMKeychain (1.5.3)
|
||||
- screen_brightness_ios (0.1.0):
|
||||
- Flutter
|
||||
- SDWebImage (5.20.1):
|
||||
- SDWebImage/Core (= 5.20.1)
|
||||
- SDWebImage/Core (5.20.1)
|
||||
@ -232,6 +230,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):
|
||||
@ -242,6 +242,7 @@ PODS:
|
||||
- sqlite3 (~> 3.49.1)
|
||||
- sqlite3/dbstatvtab
|
||||
- sqlite3/fts5
|
||||
- sqlite3/math
|
||||
- sqlite3/perf-threadsafe
|
||||
- sqlite3/rtree
|
||||
- SwiftyGif (5.4.5)
|
||||
@ -259,6 +260,7 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- Alamofire
|
||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- croppy (from `.symlinks/plugins/croppy/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
@ -282,14 +284,12 @@ DEPENDENCIES:
|
||||
- Kingfisher (~> 8.0)
|
||||
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
|
||||
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
||||
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
|
||||
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
@ -325,6 +325,8 @@ SPEC REPOS:
|
||||
- WebRTC-SDK
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audioplayers_darwin:
|
||||
:path: ".symlinks/plugins/audioplayers_darwin/ios"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
croppy:
|
||||
@ -369,8 +371,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/livekit_client/ios"
|
||||
media_kit_libs_ios_video:
|
||||
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
|
||||
media_kit_native_event_loop:
|
||||
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
|
||||
media_kit_video:
|
||||
:path: ".symlinks/plugins/media_kit_video/ios"
|
||||
package_info_plus:
|
||||
@ -383,8 +383,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
screen_brightness_ios:
|
||||
:path: ".symlinks/plugins/screen_brightness_ios/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
@ -406,65 +404,64 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
||||
croppy: b6199bc8d56bd2e03cc11609d1c47ad9875c1321
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
audioplayers_darwin: ccf9c770ee768abb07e26d90af093f7bab1c12ab
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
fast_rsa: dc48fb05f26bb108863de122b2a9f5554e8e2591
|
||||
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||
fast_rsa: d99f8e1809a4a312fa9216d830186869b2e9eb65
|
||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||
Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf
|
||||
firebase_analytics: e3b6782e70e32b7fa18f7cd233e3201975dd86aa
|
||||
firebase_core: ac395f994af4e28f6a38b59e05a88ca57abeb874
|
||||
firebase_messaging: 7e223f4ee7ca053bf4ce43748e84a6d774ec9728
|
||||
firebase_analytics: 4e93dbe66872104d28ae9750fec1800e1fd66858
|
||||
firebase_core: 8d552814f6c01ccde5d88939fced4ec26f2f5510
|
||||
firebase_messaging: 8b96a4f09841c15a16b96973ef5c3dcfc1a064e4
|
||||
FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b
|
||||
FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d
|
||||
FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629
|
||||
FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917
|
||||
FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
|
||||
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
|
||||
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
|
||||
flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
|
||||
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
|
||||
flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
|
||||
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
|
||||
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
|
||||
flutter_webrtc: 57f32415b8744e806f9c2a96ccdb60c6a627ba33
|
||||
gal: baecd024ebfd13c441269ca7404792a7152fde89
|
||||
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
||||
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||
in_app_review: 5596fe56fab799e8edb3561c03d053363ab13457
|
||||
Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
|
||||
livekit_client: 170022ce5f7c8c70d7f862ac9c17e11508ad5fbc
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
livekit_client: 08755cabfa4da4ed455642f460cfbb39bc518070
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
|
||||
SDWebImage: 33d0f23bddeb5d209ae959153883247be6703713
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
|
||||
sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db
|
||||
sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
|
||||
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
|
||||
wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
video_compress: f2133a07762889d67f0711ac831faa26f956980e
|
||||
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
|
||||
wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
|
||||
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
|
||||
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
||||
workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e
|
||||
|
||||
PODFILE CHECKSUM: 9b244e02f87527430136c8d21cbdcf1cd586b6bc
|
||||
|
||||
|
@ -79,6 +79,8 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
45
lib/database/realm.dart
Normal file
@ -0,0 +1,45 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
|
||||
class SnRealmConverter extends TypeConverter<SnRealm, String>
|
||||
with JsonTypeConverter2<SnRealm, String, Map<String, Object?>> {
|
||||
const SnRealmConverter();
|
||||
|
||||
@override
|
||||
SnRealm fromSql(String fromDb) {
|
||||
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(SnRealm value) {
|
||||
return jsonEncode(toJson(value));
|
||||
}
|
||||
|
||||
@override
|
||||
SnRealm fromJson(Map<String, Object?> json) {
|
||||
return SnRealm.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson(SnRealm value) {
|
||||
return value.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
@TableIndex(name: 'idx_realm_alias', columns: {#alias})
|
||||
@TableIndex(name: 'idx_realm_account', columns: {#accountId})
|
||||
class SnLocalRealm extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get alias => text().unique()();
|
||||
|
||||
TextColumn get content => text().map(const SnRealmConverter())();
|
||||
|
||||
IntColumn get accountId => integer()();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
DateTimeColumn get cacheExpiredAt => dateTime()();
|
||||
}
|
209
lib/main.dart
209
lib/main.dart
@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:croppy/croppy.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
@ -12,6 +13,7 @@ import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/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';
|
||||
@ -46,6 +49,7 @@ 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';
|
||||
@ -71,13 +75,40 @@ void appBackgroundDispatcher() {
|
||||
});
|
||||
}
|
||||
|
||||
// Desktop size tools
|
||||
|
||||
Future<Size> _getSavedWindowSize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
String? sizeString = prefs.getString(kAppWindowSize);
|
||||
|
||||
if (sizeString != null) {
|
||||
List<String> parts = sizeString.split('x');
|
||||
if (parts.length == 2) {
|
||||
double? width = double.tryParse(parts[0]);
|
||||
double? height = double.tryParse(parts[1]);
|
||||
if (width != null && height != null) {
|
||||
return Size(width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return const Size(1280, 720); // Default size
|
||||
}
|
||||
|
||||
Future<void> _saveWindowSize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final size = appWindow.size;
|
||||
await prefs.setString(kAppWindowSize, '${size.width}x${size.height}');
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
|
||||
final Size savedSize = await _getSavedWindowSize();
|
||||
doWhenWindowReady(() {
|
||||
appWindow.minSize = Size(480, 640);
|
||||
appWindow.size = Size(1280, 720);
|
||||
appWindow.size = savedSize;
|
||||
appWindow.alignment = Alignment.center;
|
||||
appWindow.show();
|
||||
});
|
||||
@ -87,18 +118,15 @@ void main() async {
|
||||
|
||||
if (!kIsWeb && !Platform.isLinux) {
|
||||
await Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
);
|
||||
options: DefaultFirebaseOptions.currentPlatform);
|
||||
}
|
||||
|
||||
GoRouter.optionURLReflectsImperativeAPIs = true;
|
||||
usePathUrlStrategy();
|
||||
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||
Workmanager().initialize(
|
||||
appBackgroundDispatcher,
|
||||
isInDebugMode: kDebugMode,
|
||||
);
|
||||
Workmanager()
|
||||
.initialize(appBackgroundDispatcher, isInDebugMode: kDebugMode);
|
||||
if (Platform.isAndroid) {
|
||||
Workmanager().registerPeriodicTask(
|
||||
"widget-update-random-post",
|
||||
@ -133,7 +161,7 @@ class SolianApp extends StatelessWidget {
|
||||
Locale('en', 'US'),
|
||||
Locale('zh', 'CN'),
|
||||
Locale('zh', 'TW'),
|
||||
Locale('zh', 'HK'),
|
||||
Locale('zh', 'HK')
|
||||
],
|
||||
fallbackLocale: Locale('en', 'US'),
|
||||
useFallbackTranslations: true,
|
||||
@ -157,7 +185,7 @@ class SolianApp extends StatelessWidget {
|
||||
Provider(create: (ctx) => SnNetworkProvider(ctx)),
|
||||
Provider(create: (ctx) => UserDirectoryProvider(ctx)),
|
||||
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
|
||||
Provider(create: (ctx) => SnRealmProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => SnRealmProvider(ctx)),
|
||||
Provider(create: (ctx) => SnPostContentProvider(ctx)),
|
||||
Provider(create: (ctx) => SnRelationshipProvider(ctx)),
|
||||
Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
|
||||
@ -228,6 +256,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')) {
|
||||
@ -256,12 +287,10 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
final localVersionString = '${info.version}+${info.buildNumber}';
|
||||
final resp = await Dio(
|
||||
BaseOptions(
|
||||
sendTimeout: const Duration(seconds: 60),
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
),
|
||||
sendTimeout: const Duration(seconds: 60),
|
||||
receiveTimeout: const Duration(seconds: 60)),
|
||||
).get(
|
||||
'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest',
|
||||
);
|
||||
'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest');
|
||||
final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0';
|
||||
final remoteVersion = Version.parse(remoteVersionString.split('+').first);
|
||||
final localVersion = Version.parse(localVersionString.split('+').first);
|
||||
@ -276,9 +305,7 @@ 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) {
|
||||
@ -287,6 +314,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>();
|
||||
@ -299,31 +331,52 @@ 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;
|
||||
final notify = context.read<NotificationProvider>();
|
||||
notify.listen();
|
||||
await notify.registerPushNotifications();
|
||||
if (!mounted) return;
|
||||
final kp = context.read<KeyPairProvider>();
|
||||
await kp.reloadActive();
|
||||
kp.listen();
|
||||
if (!mounted) return;
|
||||
final sticker = context.read<SnStickerProvider>();
|
||||
await sticker.listSticker();
|
||||
if (!mounted) return;
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final userCacheSize = await ud.loadAccountCache();
|
||||
logging.info('[Users] Loaded local user cache, size: $userCacheSize');
|
||||
logging.info('[Bootstrap] Everything initialized!');
|
||||
try {
|
||||
if (!mounted) return;
|
||||
_setPhaseText('keyPair');
|
||||
final kp = context.read<KeyPairProvider>();
|
||||
kp.reloadActive();
|
||||
kp.listen();
|
||||
} catch (_) {}
|
||||
if (ua.isAuthorized) {
|
||||
if (!mounted) return;
|
||||
_setPhaseText('notification');
|
||||
final notify = context.read<NotificationProvider>();
|
||||
notify.listen();
|
||||
try {
|
||||
notify.registerPushNotifications();
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
_setPhaseText('stickers');
|
||||
final sticker = context.read<SnStickerProvider>();
|
||||
await sticker.listSticker();
|
||||
if (!mounted) return;
|
||||
_setPhaseText('userDirectory');
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
await ud.loadAccountCache();
|
||||
if (!mounted) return;
|
||||
_setPhaseText('realm');
|
||||
final rm = context.read<SnRealmProvider>();
|
||||
await rm.refreshAvailableRealms();
|
||||
if (!mounted) return;
|
||||
_setPhaseText('chat');
|
||||
final ct = context.read<ChatChannelProvider>();
|
||||
await ct.refreshAvailableChannels();
|
||||
_setPhaseText('done');
|
||||
_playIntro();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
await context.showErrorDialog(err);
|
||||
@ -339,28 +392,28 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
// The quit key has been removed, and the logic of the quit key is moved to system menu bar activator.
|
||||
}
|
||||
|
||||
void _playIntro() async {
|
||||
final cfg = context.read<ConfigProvider>();
|
||||
if (!cfg.soundEffects) return;
|
||||
|
||||
final player = AudioPlayer(playerId: 'launch-done-player');
|
||||
await player.play(AssetSource('audio/sfx/launch-done.mp3'), volume: 0.8);
|
||||
player.onPlayerComplete.listen((_) {
|
||||
player.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
final Menu _appTrayMenu = Menu(
|
||||
items: [
|
||||
MenuItem(
|
||||
key: 'version_label',
|
||||
label: 'Solian',
|
||||
disabled: true,
|
||||
),
|
||||
MenuItem(key: 'version_label', label: 'Solian', disabled: true),
|
||||
MenuItem.separator(),
|
||||
MenuItem.checkbox(
|
||||
checked: false,
|
||||
key: 'mute_notification',
|
||||
label: 'trayMenuMuteNotification'.tr(),
|
||||
),
|
||||
checked: false,
|
||||
key: 'mute_notification',
|
||||
label: 'trayMenuMuteNotification'.tr()),
|
||||
MenuItem.separator(),
|
||||
MenuItem(
|
||||
key: 'window_show',
|
||||
label: 'trayMenuShow'.tr(),
|
||||
),
|
||||
MenuItem(
|
||||
key: 'exit',
|
||||
label: 'trayMenuExit'.tr(),
|
||||
),
|
||||
MenuItem(key: 'window_show', label: 'trayMenuShow'.tr()),
|
||||
MenuItem(key: 'exit', label: 'trayMenuExit'.tr()),
|
||||
],
|
||||
);
|
||||
|
||||
@ -388,9 +441,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
|
||||
|
||||
await localNotifier.setup(
|
||||
appName: 'Solian',
|
||||
shortcutPolicy: ShortcutPolicy.requireCreate,
|
||||
);
|
||||
appName: 'Solian', shortcutPolicy: ShortcutPolicy.requireCreate);
|
||||
}
|
||||
|
||||
AppLifecycleListener? _appLifecycleListener;
|
||||
@ -399,10 +450,10 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_isBusy = true;
|
||||
if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
|
||||
_appLifecycleListener = AppLifecycleListener(
|
||||
onExitRequested: _onExitRequested,
|
||||
);
|
||||
_appLifecycleListener =
|
||||
AppLifecycleListener(onExitRequested: _onExitRequested);
|
||||
}
|
||||
|
||||
_trayInitialization();
|
||||
@ -412,6 +463,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
_postInitialization();
|
||||
_tryRequestRating();
|
||||
_checkForUpdate();
|
||||
setState(() => _isBusy = false);
|
||||
});
|
||||
}
|
||||
|
||||
@ -421,6 +473,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
}
|
||||
|
||||
void _quitApp() {
|
||||
_saveWindowSize();
|
||||
_appLifecycleListener?.dispose();
|
||||
if (Platform.isWindows) {
|
||||
appWindow.close();
|
||||
@ -501,7 +554,49 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
}
|
||||
});
|
||||
return SizeChangedLayoutNotifier(
|
||||
child: widget.child,
|
||||
child: _isBusy
|
||||
? Material(
|
||||
key: Key('app-splash-screen-$_isBusy'),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage('assets/icon/kanban-1st.jpg'),
|
||||
fit: BoxFit.cover,
|
||||
opacity: 0.1,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
backgroundBlendMode: BlendMode.darken,
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icon/icon.png',
|
||||
width: 64,
|
||||
height: 64,
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
Text('Solar Network').bold(),
|
||||
AppVersionLabel(),
|
||||
Gap(8),
|
||||
Text(_phaseText, textAlign: TextAlign.center),
|
||||
Gap(16),
|
||||
const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: widget.child,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -28,6 +28,24 @@ 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();
|
||||
});
|
||||
}
|
||||
|
||||
void addAvailableChannel(SnChannel channel) {
|
||||
_availableChannels.add(channel);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
|
||||
await Future.wait(
|
||||
channels.map(
|
||||
|
@ -13,7 +13,6 @@ const kNetworkServerStoreKey = 'app_server_url';
|
||||
const kAppbarTransparentStoreKey = 'app_bar_transparent';
|
||||
const kAppBackgroundStoreKey = 'app_has_background';
|
||||
const kAppColorSchemeStoreKey = 'app_color_scheme';
|
||||
const kAppDrawerPreferCollapse = 'app_drawer_prefer_collapse';
|
||||
const kAppNotifyWithHaptic = 'app_notify_with_haptic';
|
||||
const kAppExpandPostLink = 'app_expand_post_link';
|
||||
const kAppExpandChatLink = 'app_expand_chat_link';
|
||||
@ -21,6 +20,10 @@ const kAppRealmCompactView = 'app_realm_compact_view';
|
||||
const kAppCustomFonts = 'app_custom_fonts';
|
||||
const kAppMixedFeed = 'app_mixed_feed';
|
||||
const kAppAutoTranslate = 'app_auto_translate';
|
||||
const kAppHideBottomNav = 'app_hide_bottom_nav';
|
||||
const kAppSoundEffects = 'app_sound_effects';
|
||||
const kAppAprilFoolFeatures = 'app_april_fool_features';
|
||||
const kAppWindowSize = 'app_window_size';
|
||||
|
||||
const Map<String, FilterQuality> kImageQualityLevel = {
|
||||
'settingsImageQualityLowest': FilterQuality.none,
|
||||
@ -43,27 +46,17 @@ class ConfigProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
bool drawerIsCollapsed = false;
|
||||
bool drawerIsExpanded = false;
|
||||
|
||||
void calcDrawerSize(BuildContext context, {bool withMediaQuery = false}) {
|
||||
bool newDrawerIsCollapsed = false;
|
||||
bool newDrawerIsExpanded = false;
|
||||
if (withMediaQuery) {
|
||||
newDrawerIsCollapsed = MediaQuery.of(context).size.width < 600;
|
||||
newDrawerIsExpanded = MediaQuery.of(context).size.width >= 601;
|
||||
} else {
|
||||
final rpb = ResponsiveBreakpoints.of(context);
|
||||
newDrawerIsCollapsed = rpb.smallerOrEqualTo(MOBILE);
|
||||
newDrawerIsExpanded = rpb.largerThan(TABLET)
|
||||
? (prefs.getBool(kAppDrawerPreferCollapse) ?? false)
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
}
|
||||
|
||||
if (newDrawerIsExpanded != drawerIsExpanded ||
|
||||
newDrawerIsCollapsed != drawerIsCollapsed) {
|
||||
drawerIsExpanded = newDrawerIsExpanded;
|
||||
if (newDrawerIsCollapsed != drawerIsCollapsed) {
|
||||
drawerIsCollapsed = newDrawerIsCollapsed;
|
||||
notifyListeners();
|
||||
}
|
||||
@ -91,6 +84,33 @@ class ConfigProvider extends ChangeNotifier {
|
||||
return prefs.getBool(kAppAutoTranslate) ?? false;
|
||||
}
|
||||
|
||||
bool get hideBottomNav {
|
||||
return prefs.getBool(kAppHideBottomNav) ?? false;
|
||||
}
|
||||
|
||||
bool get aprilFoolFeatures {
|
||||
return prefs.getBool(kAppAprilFoolFeatures) ?? true;
|
||||
}
|
||||
|
||||
bool get soundEffects {
|
||||
return prefs.getBool(kAppSoundEffects) ?? true;
|
||||
}
|
||||
|
||||
set soundEffects(bool value) {
|
||||
prefs.setBool(kAppSoundEffects, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set aprilFoolFeatures(bool value) {
|
||||
prefs.setBool(kAppAprilFoolFeatures, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set hideBottomNav(bool value) {
|
||||
prefs.setBool(kAppHideBottomNav, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set autoTranslate(bool value) {
|
||||
prefs.setBool(kAppAutoTranslate, value);
|
||||
notifyListeners();
|
||||
|
@ -5,6 +5,20 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppNavListItem {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String screen;
|
||||
final IconData icon;
|
||||
|
||||
const AppNavListItem({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.screen,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
class AppNavDestination {
|
||||
final String label;
|
||||
final String screen;
|
||||
@ -24,13 +38,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(
|
||||
@ -48,11 +59,6 @@ class NavigationProvider extends ChangeNotifier {
|
||||
screen: 'chat',
|
||||
label: 'screenChat',
|
||||
),
|
||||
AppNavDestination(
|
||||
icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
|
||||
screen: 'account',
|
||||
label: 'screenAccount',
|
||||
),
|
||||
AppNavDestination(
|
||||
icon: Icon(Symbols.group, weight: 400, opticalSize: 20),
|
||||
screen: 'realm',
|
||||
@ -64,31 +70,16 @@ class NavigationProvider extends ChangeNotifier {
|
||||
label: 'screenNews',
|
||||
),
|
||||
AppNavDestination(
|
||||
icon: Icon(Symbols.emoji_emotions, weight: 400, opticalSize: 20),
|
||||
screen: 'stickers',
|
||||
label: 'screenStickers',
|
||||
),
|
||||
AppNavDestination(
|
||||
icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20),
|
||||
screen: 'album',
|
||||
label: 'screenAlbum',
|
||||
),
|
||||
AppNavDestination(
|
||||
icon: Icon(Symbols.diversity_4, weight: 400, opticalSize: 20),
|
||||
screen: 'friend',
|
||||
label: 'screenFriend',
|
||||
),
|
||||
AppNavDestination(
|
||||
icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20),
|
||||
screen: 'notification',
|
||||
label: 'screenNotification',
|
||||
icon: Icon(Symbols.settings, weight: 400, opticalSize: 20),
|
||||
screen: 'settings',
|
||||
label: 'screenSettings',
|
||||
),
|
||||
];
|
||||
static const List<String> kDefaultPinnedDestination = [
|
||||
'home',
|
||||
'explore',
|
||||
'chat',
|
||||
'account',
|
||||
'realm',
|
||||
];
|
||||
|
||||
List<AppNavDestination> destinations = [];
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@ -22,6 +23,8 @@ class NotificationProvider extends ChangeNotifier {
|
||||
late final WebSocketProvider _ws;
|
||||
late final ConfigProvider _cfg;
|
||||
|
||||
final AudioPlayer _notifySoundPlayer = AudioPlayer(playerId: 'notify-sound');
|
||||
|
||||
NotificationProvider(BuildContext context) {
|
||||
_sn = context.read<SnNetworkProvider>();
|
||||
_ua = context.read<UserProvider>();
|
||||
@ -66,14 +69,19 @@ class NotificationProvider extends ChangeNotifier {
|
||||
}
|
||||
logging.info('[Push Notification] Device Push Token is $token');
|
||||
|
||||
await _sn.client.post(
|
||||
'/cgi/id/notifications/subscription',
|
||||
data: {
|
||||
'provider': provider,
|
||||
'device_token': token,
|
||||
'device_id': deviceUuid,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await _sn.client.post(
|
||||
'/cgi/id/notifications/subscription',
|
||||
data: {
|
||||
'provider': provider,
|
||||
'device_token': token,
|
||||
'device_id': deviceUuid
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
logging.error(
|
||||
'[Push Notification] Unable to register push notifications: $err');
|
||||
}
|
||||
}
|
||||
|
||||
int showingCount = 0;
|
||||
@ -91,6 +99,17 @@ class NotificationProvider extends ChangeNotifier {
|
||||
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
|
||||
if (doHaptic) HapticFeedback.mediumImpact();
|
||||
|
||||
// April fool notification sfx
|
||||
if (_cfg.prefs.getBool(kAppAprilFoolFeatures) ?? true) {
|
||||
final now = DateTime.now();
|
||||
if (now.day == 1 && now.month == 4) {
|
||||
_notifySoundPlayer.play(
|
||||
AssetSource('audio/notify/metal-pipe.mp3'),
|
||||
volume: 0.6,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.topic == 'messaging.message' &&
|
||||
skippableNotifyChannel != null) {
|
||||
if (notification.metadata['channel_id'] != null &&
|
||||
|
@ -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))),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -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 {
|
||||
class SnRealmProvider extends ChangeNotifier {
|
||||
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,17 +35,56 @@ class SnRealmProvider {
|
||||
_cache[realm.alias] = realm;
|
||||
_cache[realm.id.toString()] = realm;
|
||||
}
|
||||
_saveToLocal(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
void addAvailableRealm(SnRealm realm) {
|
||||
_availableRealms.add(realm);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<SnRealm> getRealm(dynamic aliasOrId) async {
|
||||
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))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,8 +4,7 @@ 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';
|
||||
const kTranslateApiBaseUrl = 'https://translate.solsynth.dev';
|
||||
|
||||
class SnTranslator {
|
||||
final Dio client = Dio(
|
||||
|
@ -64,6 +64,7 @@ class UserProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<SnAccount?> refreshUser() async {
|
||||
if (!isAuthorized) return null;
|
||||
final resp = await _sn.client.get('/cgi/id/users/me');
|
||||
final out = SnAccount.fromJson(resp.data);
|
||||
|
||||
|
304
lib/router.dart
304
lib/router.dart
@ -3,14 +3,18 @@ import 'package:flutter/material.dart';
|
||||
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/punishments.dart';
|
||||
import 'package:surface/screens/account/settings.dart';
|
||||
import 'package:surface/screens/account/action_events.dart';
|
||||
import 'package:surface/screens/account/badges.dart';
|
||||
import 'package:surface/screens/account/contact_methods.dart';
|
||||
import 'package:surface/screens/account/factor_settings.dart';
|
||||
import 'package:surface/screens/account/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/programs.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';
|
||||
@ -37,6 +41,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';
|
||||
@ -67,8 +72,8 @@ final _appRoutes = [
|
||||
),
|
||||
GoRoute(
|
||||
path: '/posts',
|
||||
name: 'explore',
|
||||
builder: (context, state) => const ExploreScreen(),
|
||||
name: 'posts',
|
||||
builder: (_, __) => const SizedBox.shrink(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/draft',
|
||||
@ -106,134 +111,194 @@ final _appRoutes = [
|
||||
state.uri.queryParameters['categories']?.split(','),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => ResponsiveScaffold(
|
||||
asideFlex: 2,
|
||||
contentFlex: 3,
|
||||
aside: const ExploreScreen(),
|
||||
child: child,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/explore',
|
||||
name: 'explore',
|
||||
builder: (context, state) => const ResponsiveScaffoldLanding(
|
||||
child: ExploreScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/posts/:slug',
|
||||
name: 'postDetail',
|
||||
builder: (context, state) => PostDetailScreen(
|
||||
key: ValueKey(state.pathParameters['slug']!),
|
||||
slug: state.pathParameters['slug']!,
|
||||
preload: state.extra as SnPost?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers/:name',
|
||||
name: 'postPublisher',
|
||||
builder: (context, state) =>
|
||||
PostPublisherScreen(name: state.pathParameters['name']!),
|
||||
),
|
||||
],
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => ResponsiveScaffold(
|
||||
aside: const AccountScreen(),
|
||||
child: child,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/:slug',
|
||||
name: 'postDetail',
|
||||
builder: (context, state) => PostDetailScreen(
|
||||
slug: state.pathParameters['slug']!,
|
||||
preload: state.extra as SnPost?,
|
||||
),
|
||||
path: '/account',
|
||||
name: 'account',
|
||||
builder: (context, state) =>
|
||||
const ResponsiveScaffoldLanding(child: AccountScreen()),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/punishments',
|
||||
name: 'accountPunishments',
|
||||
builder: (context, state) => const PunishmentsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/programs',
|
||||
name: 'accountProgram',
|
||||
builder: (context, state) => const AccountProgramScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/contacts',
|
||||
name: 'accountContactMethods',
|
||||
builder: (context, state) => const AccountContactMethod(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/events',
|
||||
name: 'accountActionEvents',
|
||||
builder: (context, state) => const ActionEventScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tickets',
|
||||
name: 'accountAuthTickets',
|
||||
builder: (context, state) => const AccountAuthTicket(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/badges',
|
||||
name: 'accountBadges',
|
||||
builder: (context, state) => const AccountBadgesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/wallet',
|
||||
name: 'accountWallet',
|
||||
builder: (context, state) => const WalletScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/keypairs',
|
||||
name: 'accountKeyPairs',
|
||||
builder: (context, state) => const KeyPairScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
name: 'accountSettings',
|
||||
builder: (context, state) => AccountSettingsScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/notify',
|
||||
name: 'accountSettingsNotify',
|
||||
builder: (context, state) => const AccountNotifyPrefsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/auth',
|
||||
name: 'accountSettingsSecurity',
|
||||
builder: (context, state) => const AccountSecurityPrefsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/factors',
|
||||
name: 'factorSettings',
|
||||
builder: (context, state) => FactorSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile/edit',
|
||||
name: 'accountProfileEdit',
|
||||
builder: (context, state) => ProfileEditScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers',
|
||||
name: 'accountPublishers',
|
||||
builder: (context, state) => PublisherScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers/new',
|
||||
name: 'accountPublisherNew',
|
||||
builder: (context, state) => AccountPublisherNewScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers/edit/:name',
|
||||
name: 'accountPublisherEdit',
|
||||
builder: (context, state) => AccountPublisherEditScreen(
|
||||
name: state.pathParameters['name']!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/account',
|
||||
name: 'account',
|
||||
builder: (context, state) => const AccountScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/contacts',
|
||||
name: 'accountContactMethods',
|
||||
builder: (context, state) => const AccountContactMethod(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/events',
|
||||
name: 'accountActionEvents',
|
||||
builder: (context, state) => const ActionEventScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tickets',
|
||||
name: 'accountAuthTickets',
|
||||
builder: (context, state) => const AccountAuthTicket(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/badges',
|
||||
name: 'accountBadges',
|
||||
builder: (context, state) => const AccountBadgesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/wallet',
|
||||
name: 'accountWallet',
|
||||
builder: (context, state) => const WalletScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/keypairs',
|
||||
name: 'accountKeyPairs',
|
||||
builder: (context, state) => const KeyPairScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
name: 'accountSettings',
|
||||
builder: (context, state) => AccountSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/factors',
|
||||
name: 'factorSettings',
|
||||
builder: (context, state) => FactorSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile/edit',
|
||||
name: 'accountProfileEdit',
|
||||
builder: (context, state) => ProfileEditScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers',
|
||||
name: 'accountPublishers',
|
||||
builder: (context, state) => PublisherScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers/new',
|
||||
name: 'accountPublisherNew',
|
||||
builder: (context, state) => AccountPublisherNewScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers/edit/:name',
|
||||
name: 'accountPublisherEdit',
|
||||
builder: (context, state) => AccountPublisherEditScreen(
|
||||
name: state.pathParameters['name']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile/:name',
|
||||
name: 'accountProfilePage',
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
child: UserScreen(name: state.pathParameters['name']!),
|
||||
),
|
||||
),
|
||||
],
|
||||
path: '/accounts/:name',
|
||||
name: 'accountProfilePage',
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
child: UserScreen(name: state.pathParameters['name']!),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
builder: (context, state) => const ChatScreen(),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) =>
|
||||
ResponsiveScaffold(aside: const ChatScreen(), child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/:scope/:alias',
|
||||
name: 'chatRoom',
|
||||
builder: (context, state) => ChatRoomScreen(
|
||||
scope: state.pathParameters['scope']!,
|
||||
alias: state.pathParameters['alias']!,
|
||||
extra: state.extra as ChatRoomScreenExtra?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/:scope/:alias/call',
|
||||
name: 'chatCallRoom',
|
||||
builder: (context, state) => CallRoomScreen(
|
||||
scope: state.pathParameters['scope']!,
|
||||
alias: state.pathParameters['alias']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/:scope/:alias/detail',
|
||||
name: 'channelDetail',
|
||||
builder: (context, state) => ChannelDetailScreen(
|
||||
scope: state.pathParameters['scope']!,
|
||||
alias: state.pathParameters['alias']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/manage',
|
||||
name: 'chatManage',
|
||||
builder: (context, state) => ChatManageScreen(
|
||||
editingChannelAlias: state.uri.queryParameters['editing'],
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
builder: (context, state) => const ResponsiveScaffoldLanding(
|
||||
child: ChatScreen(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/:scope/:alias',
|
||||
name: 'chatRoom',
|
||||
builder: (context, state) => ChatRoomScreen(
|
||||
key: ValueKey(
|
||||
'${state.pathParameters['scope']!}:${state.pathParameters['alias']!}',
|
||||
),
|
||||
scope: state.pathParameters['scope']!,
|
||||
alias: state.pathParameters['alias']!,
|
||||
extra: state.extra as ChatRoomScreenExtra?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/:scope/:alias/call',
|
||||
name: 'chatCallRoom',
|
||||
builder: (context, state) => CallRoomScreen(
|
||||
scope: state.pathParameters['scope']!,
|
||||
alias: state.pathParameters['alias']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/:scope/:alias/detail',
|
||||
name: 'channelDetail',
|
||||
builder: (context, state) => ChannelDetailScreen(
|
||||
scope: state.pathParameters['scope']!,
|
||||
alias: state.pathParameters['alias']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/manage',
|
||||
name: 'chatManage',
|
||||
builder: (context, state) => ChatManageScreen(
|
||||
editingChannelAlias: state.uri.queryParameters['editing'],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -245,6 +310,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',
|
||||
|
@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/database.dart';
|
||||
import 'package:surface/providers/navigation.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/providers/websocket.dart';
|
||||
@ -22,27 +23,97 @@ import 'package:surface/widgets/universal_image.dart';
|
||||
class AccountScreen extends StatelessWidget {
|
||||
const AccountScreen({super.key});
|
||||
|
||||
static const List<AppNavListItem> kNavList = [
|
||||
AppNavListItem(
|
||||
title: "accountPublishers",
|
||||
subtitle: "accountPublishersSubtitle",
|
||||
screen: "accountPublishers",
|
||||
icon: Symbols.face,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "accountProgram",
|
||||
subtitle: "accountProgramDescription",
|
||||
screen: "accountProgram",
|
||||
icon: Symbols.communities,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "friends",
|
||||
subtitle: "friendsDescription",
|
||||
screen: "friend",
|
||||
icon: Symbols.person,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "album",
|
||||
subtitle: "albumDescription",
|
||||
screen: "album",
|
||||
icon: Symbols.photo_library,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "stickers",
|
||||
subtitle: "stickersDescription",
|
||||
screen: "stickers",
|
||||
icon: Symbols.emoji_emotions,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "accountWallet",
|
||||
subtitle: "accountWalletSubtitle",
|
||||
screen: "accountWallet",
|
||||
icon: Symbols.wallet,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "accountBadges",
|
||||
subtitle: "accountBadgesDescription",
|
||||
screen: "accountBadges",
|
||||
icon: Symbols.award_star,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "accountKeyPairs",
|
||||
subtitle: "accountKeyPairsDescription",
|
||||
screen: "accountKeyPairs",
|
||||
icon: Symbols.key,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "accountPunishments",
|
||||
subtitle: "accountPunishmentsDescription",
|
||||
screen: "accountPunishments",
|
||||
icon: Symbols.credit_score,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "accountActionEvent",
|
||||
subtitle: "accountActionEventDescription",
|
||||
screen: "accountActionEvents",
|
||||
icon: Symbols.history,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "accountAuthTickets",
|
||||
subtitle: "accountAuthTicketsDescription",
|
||||
screen: "accountAuthTickets",
|
||||
icon: Symbols.confirmation_number,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "accountSettings",
|
||||
subtitle: "accountSettingsSubtitle",
|
||||
screen: "accountSettings",
|
||||
icon: Symbols.manage_accounts,
|
||||
),
|
||||
AppNavListItem(
|
||||
title: "abuseReport",
|
||||
subtitle: "abuseReportActionDescription",
|
||||
screen: "abuseReport",
|
||||
icon: Symbols.flag,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ua = context.watch<UserProvider>();
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
title: Text(
|
||||
"screenAccount",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(1, 1),
|
||||
blurRadius: 5.0,
|
||||
color: Color.fromARGB(255, 0, 0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
).tr(),
|
||||
title: Text("screenAccount").tr(),
|
||||
flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
@ -71,15 +142,6 @@ class AccountScreen extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
: null,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.settings, fill: 1),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pushNamed('settings');
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: ua.isAuthorized
|
||||
@ -118,7 +180,18 @@ class _AuthorizedAccountScreen extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountImage(content: ua.user!.avatar, radius: 28),
|
||||
GestureDetector(
|
||||
child: AccountImage(
|
||||
content: ua.user!.avatar,
|
||||
radius: 28,
|
||||
),
|
||||
onTap: () {
|
||||
GoRouter.of(context)
|
||||
.pushNamed('accountProfilePage', pathParameters: {
|
||||
'name': ua.user!.name,
|
||||
});
|
||||
},
|
||||
),
|
||||
_AccountStatusWidget(account: ua.user!),
|
||||
],
|
||||
),
|
||||
@ -147,115 +220,42 @@ class _AuthorizedAccountScreen extends StatelessWidget {
|
||||
);
|
||||
}).padding(all: 20),
|
||||
).padding(horizontal: 8, top: 16, bottom: 4),
|
||||
ListTile(
|
||||
title: Text('accountPublishers').tr(),
|
||||
subtitle: Text('accountPublishersSubtitle').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.face),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountPublishers');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('abuseReport').tr(),
|
||||
subtitle: Text('abuseReportActionDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.flag),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('abuseReport');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('factorSettings').tr(),
|
||||
subtitle: Text('factorSettingsSubtitle').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.lock),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('factorSettings');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountWallet').tr(),
|
||||
subtitle: Text('accountWalletSubtitle').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.wallet),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountWallet');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountBadges').tr(),
|
||||
subtitle: Text('accountBadgesDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.award_star),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountBadges');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountKeyPairs').tr(),
|
||||
subtitle: Text('accountKeyPairsDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.key),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountKeyPairs');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountActionEvent').tr(),
|
||||
subtitle: Text('accountActionEventDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.history),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountActionEvents');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountAuthTickets').tr(),
|
||||
subtitle: Text('accountAuthTicketsDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.confirmation_number),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountAuthTickets');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountSettings').tr(),
|
||||
subtitle: Text('accountSettingsSubtitle').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.manage_accounts),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountSettings');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountLogout').tr(),
|
||||
subtitle: Text('accountLogoutSubtitle').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.logout),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
final confirm = await context.showConfirmDialog(
|
||||
'accountLogoutConfirmTitle'.tr(),
|
||||
'accountLogoutConfirm'.tr(),
|
||||
);
|
||||
for (final item in AccountScreen.kNavList)
|
||||
Tooltip(
|
||||
message: item.subtitle.tr(),
|
||||
child: ListTile(
|
||||
minTileHeight: 48,
|
||||
title: Text(item.title).tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(item.icon),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(item.screen);
|
||||
},
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'accountLogoutSubtitle'.tr(),
|
||||
child: ListTile(
|
||||
title: Text('accountLogout').tr(),
|
||||
minTileHeight: 48,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.logout),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
final confirm = await context.showConfirmDialog(
|
||||
'accountLogoutConfirmTitle'.tr(),
|
||||
'accountLogoutConfirm'.tr(),
|
||||
);
|
||||
|
||||
if (!confirm) return;
|
||||
if (!context.mounted) return;
|
||||
ua.logoutUser();
|
||||
final ws = context.read<WebSocketProvider>();
|
||||
ws.disconnect();
|
||||
context.read<DatabaseProvider>().removeDatabase();
|
||||
},
|
||||
if (!confirm) return;
|
||||
if (!context.mounted) return;
|
||||
ua.logoutUser();
|
||||
final ws = context.read<WebSocketProvider>();
|
||||
ws.disconnect();
|
||||
context.read<DatabaseProvider>().removeDatabase();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -298,9 +298,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
|
||||
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})',
|
||||
]));
|
||||
ua.refreshUser();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
@ -59,6 +59,7 @@ class _ActionEventScreenState extends State<ActionEventScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('accountActionEvent').tr(),
|
||||
|
@ -91,6 +91,7 @@ class _AccountAuthTicketState extends State<AccountAuthTicket> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('accountAuthTickets').tr(),
|
||||
|
@ -70,6 +70,7 @@ class _AccountBadgesScreenState extends State<AccountBadgesScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
title: Text('screenAccountBadges').tr(),
|
||||
),
|
||||
|
@ -69,6 +69,7 @@ class _AccountContactMethodState extends State<AccountContactMethod> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('accountContactMethods').tr(),
|
||||
|
@ -16,7 +16,11 @@ final Map<int, (String, String, IconData)> kFactorTypes = {
|
||||
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
||||
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
|
||||
2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
|
||||
3: ('authFactorInAppNotify', 'authFactorInAppNotifyDescription', Symbols.notifications_active),
|
||||
3: (
|
||||
'authFactorInAppNotify',
|
||||
'authFactorInAppNotifyDescription',
|
||||
Symbols.notifications_active
|
||||
),
|
||||
};
|
||||
|
||||
class FactorSettingsScreen extends StatefulWidget {
|
||||
@ -36,7 +40,10 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/users/me/factors');
|
||||
_factors = List<SnAuthFactor>.from(
|
||||
resp.data?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>)).toList() ?? [],
|
||||
resp.data
|
||||
?.map((e) => SnAuthFactor.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
@ -55,6 +62,7 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: PageBackButton(),
|
||||
title: Text('screenFactorSettings').tr(),
|
||||
@ -96,7 +104,8 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
|
||||
return ListTile(
|
||||
title: Text(kFactorTypes[ele.type]!.$1).tr(),
|
||||
subtitle: Text(kFactorTypes[ele.type]!.$2).tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 12),
|
||||
contentPadding:
|
||||
const EdgeInsets.only(left: 24, right: 12),
|
||||
leading: Icon(kFactorTypes[ele.type]!.$3),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
@ -105,14 +114,17 @@ class _FactorSettingsScreenState extends State<FactorSettingsScreen> {
|
||||
context
|
||||
.showConfirmDialog(
|
||||
'authFactorDelete'.tr(),
|
||||
'authFactorDeleteDescription'.tr(args: [kFactorTypes[ele.type]!.$1.tr()]),
|
||||
'authFactorDeleteDescription'.tr(
|
||||
args: [kFactorTypes[ele.type]!.$1.tr()]),
|
||||
)
|
||||
.then((val) async {
|
||||
if (!val) return;
|
||||
try {
|
||||
if (!context.mounted) return;
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.delete('/cgi/id/users/me/factors/${ele.id}');
|
||||
final sn =
|
||||
context.read<SnNetworkProvider>();
|
||||
await sn.client.delete(
|
||||
'/cgi/id/users/me/factors/${ele.id}');
|
||||
_fetchFactors();
|
||||
} catch (err) {
|
||||
if (!context.mounted) return;
|
||||
@ -191,7 +203,9 @@ class _FactorNewDialogState extends State<_FactorNewDialog> {
|
||||
value: _factorType,
|
||||
items: kFactorTypes.entries.map(
|
||||
(ele) {
|
||||
final contains = widget.currentlyHave.map((ele) => ele.type).contains(ele.key);
|
||||
final contains = widget.currentlyHave
|
||||
.map((ele) => ele.type)
|
||||
.contains(ele.key);
|
||||
return DropdownMenuItem<int>(
|
||||
enabled: !contains,
|
||||
value: ele.key,
|
||||
|
@ -37,6 +37,7 @@ class _KeyPairScreenState extends State<KeyPairScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
title: Text('screenKeyPairs').tr(),
|
||||
),
|
||||
|
@ -1,11 +1,123 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
class AccountNotifyPrefsScreen extends StatelessWidget {
|
||||
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();
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('accountSettingsNotify').tr(),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
ListTile(
|
||||
tileColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Icons.save),
|
||||
title: Text('save').tr(),
|
||||
enabled: !_isBusy,
|
||||
onTap: () {
|
||||
_savePreferences();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: kNotifyTopicMap.length,
|
||||
itemBuilder: (context, index) {
|
||||
final element = kNotifyTopicMap.entries.elementAt(index);
|
||||
return CheckboxListTile(
|
||||
title: Text(element.value),
|
||||
subtitle: Text(
|
||||
element.key,
|
||||
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||
),
|
||||
value: _config[element.key] ?? true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_config[element.key] = value ?? false;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
148
lib/screens/account/prefs/security.dart
Normal file
148
lib/screens/account/prefs/security.dart
Normal file
@ -0,0 +1,148 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
class AccountSecurityPrefsScreen extends StatefulWidget {
|
||||
const AccountSecurityPrefsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AccountSecurityPrefsScreen> createState() =>
|
||||
_AccountSecurityPrefsScreenState();
|
||||
}
|
||||
|
||||
class _AccountSecurityPrefsScreenState
|
||||
extends State<AccountSecurityPrefsScreen> {
|
||||
bool _isBusy = true;
|
||||
|
||||
Map<String, dynamic> _config = {
|
||||
'maximum_auth_steps': 2,
|
||||
'always_risky': false,
|
||||
};
|
||||
|
||||
Future<void> _getPreferences() async {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
try {
|
||||
final resp = await sn.client.get('/cgi/id/preferences/auth');
|
||||
_config = resp.data['config']
|
||||
.map((k, v) => MapEntry(k, v as bool))
|
||||
.cast<String, bool>();
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _savePreferences() async {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
try {
|
||||
await sn.client.put(
|
||||
'/cgi/id/preferences/auth',
|
||||
data: {
|
||||
'config': _config,
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
context.showSnackbar('accountSettingsApplied'.tr());
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_getPreferences();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('accountSettingsSecurity').tr(),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
ListTile(
|
||||
tileColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Icons.save),
|
||||
title: Text('save').tr(),
|
||||
enabled: !_isBusy,
|
||||
onTap: () {
|
||||
_savePreferences();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text('authMaximumAuthSteps').tr(),
|
||||
subtitle: Text('authMaximumAuthStepsDescription')
|
||||
.plural(_config['maximum_auth_steps'] ?? 2),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
icon: const Icon(Symbols.remove),
|
||||
onPressed: () {
|
||||
if (_config['maximum_auth_steps'] > 1) {
|
||||
setState(() => _config['maximum_auth_steps']--);
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
icon: const Icon(Symbols.add),
|
||||
onPressed: () {
|
||||
if (_config['maximum_auth_steps'] < 99) {
|
||||
setState(() => _config['maximum_auth_steps']++);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text('authAlwaysRisky').tr(),
|
||||
subtitle: Text('authAlwaysRiskyDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
value: _config['always_risky'] ?? false,
|
||||
onChanged: (value) {
|
||||
setState(() => _config['always_risky'] = value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -66,37 +66,40 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
_locationController.text = prof.profile!.location;
|
||||
_avatar = prof.avatar;
|
||||
_banner = prof.banner;
|
||||
_links = prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList();
|
||||
_links =
|
||||
prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList();
|
||||
_birthday = prof.profile!.birthday?.toLocal();
|
||||
if (_birthday != null) {
|
||||
_birthdayController.text = DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal());
|
||||
_birthdayController.text =
|
||||
DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal());
|
||||
}
|
||||
}
|
||||
|
||||
void _selectBirthday() async {
|
||||
await showCupertinoModalPopup<DateTime?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext context) => Container(
|
||||
height: 216,
|
||||
padding: const EdgeInsets.only(top: 6.0),
|
||||
margin: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: CupertinoDatePicker(
|
||||
initialDateTime: _birthday?.toLocal(),
|
||||
mode: CupertinoDatePickerMode.date,
|
||||
use24hFormat: true,
|
||||
onDateTimeChanged: (DateTime newDate) {
|
||||
setState(() {
|
||||
_birthday = newDate;
|
||||
_birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
builder: (BuildContext context) => Container(
|
||||
height: 216,
|
||||
padding: const EdgeInsets.only(top: 6.0),
|
||||
margin:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: CupertinoDatePicker(
|
||||
initialDateTime: _birthday?.toLocal(),
|
||||
mode: CupertinoDatePickerMode.date,
|
||||
use24hFormat: true,
|
||||
onDateTimeChanged: (DateTime newDate) {
|
||||
setState(() {
|
||||
_birthday = newDate;
|
||||
_birthdayController.text =
|
||||
DateFormat(_kDateFormat).format(_birthday!);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -109,29 +112,32 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
|
||||
Uint8List? rawBytes;
|
||||
if (!skipCrop) {
|
||||
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios =
|
||||
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
|
||||
final result =
|
||||
(!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
)
|
||||
: await showMaterialImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
);
|
||||
final ImageProvider imageProvider =
|
||||
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios = place == 'banner'
|
||||
? [CropAspectRatio(width: 16, height: 7)]
|
||||
: [CropAspectRatio(width: 1, height: 1)];
|
||||
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
)
|
||||
: await showMaterialImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
);
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = true);
|
||||
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
|
||||
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!
|
||||
.buffer
|
||||
.asUint8List();
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = true);
|
||||
@ -152,7 +158,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
|
||||
if (!mounted) return;
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid});
|
||||
await sn.client
|
||||
.put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid});
|
||||
|
||||
if (!mounted) return;
|
||||
final ua = context.read<UserProvider>();
|
||||
@ -188,7 +195,9 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
'location': _locationController.value.text,
|
||||
'birthday': _birthday?.toUtc().toIso8601String(),
|
||||
'links': {
|
||||
for (final link in _links!.where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty)) link.$1: link.$2,
|
||||
for (final link in _links!
|
||||
.where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty))
|
||||
link.$1: link.$2,
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -235,7 +244,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(leading: const PageBackButton(), title: Text('screenAccountProfileEdit').tr()),
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('screenAccountProfileEdit').tr()),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -253,11 +265,14 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child:
|
||||
_banner != null
|
||||
? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover)
|
||||
: const SizedBox.shrink(),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
child: _banner != null
|
||||
? AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(_banner!),
|
||||
fit: BoxFit.cover)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -294,12 +309,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
labelText: 'fieldUsername'.tr(),
|
||||
helperText: 'fieldUsernameCannotEditHint'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
TextField(
|
||||
controller: _nicknameController,
|
||||
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldNickname'.tr()),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldNickname'.tr()),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
@ -311,7 +330,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldFirstName'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
@ -323,7 +343,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldLastName'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -338,7 +359,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldGender'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
@ -350,7 +372,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldPronouns'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -360,8 +383,11 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: null,
|
||||
minLines: 3,
|
||||
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldDescription'.tr()),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldDescription'.tr()),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@ -373,18 +399,21 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldTimeZone'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
StyledWidget(
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.calendar_month),
|
||||
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
||||
visualDensity:
|
||||
VisualDensity(horizontal: -4, vertical: -4),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () async {
|
||||
_timezoneController.text = await FlutterTimezone.getLocalTimezone();
|
||||
_timezoneController.text =
|
||||
await FlutterTimezone.getLocalTimezone();
|
||||
},
|
||||
),
|
||||
).padding(top: 6),
|
||||
@ -392,7 +421,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
StyledWidget(
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.clear),
|
||||
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
||||
visualDensity:
|
||||
VisualDensity(horizontal: -4, vertical: -4),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
@ -404,13 +434,18 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
),
|
||||
TextField(
|
||||
controller: _locationController,
|
||||
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldLocation'.tr()),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldLocation'.tr()),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
TextField(
|
||||
controller: _birthdayController,
|
||||
readOnly: true,
|
||||
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldBirthday'.tr()),
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldBirthday'.tr()),
|
||||
onTap: () => _selectBirthday(),
|
||||
),
|
||||
if (_links != null)
|
||||
@ -418,7 +453,8 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
margin: const EdgeInsets.only(top: 16, bottom: 4),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -427,13 +463,17 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
'fieldLinks'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 17),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium!
|
||||
.copyWith(fontSize: 17),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
||||
visualDensity:
|
||||
VisualDensity(horizontal: -4, vertical: -4),
|
||||
icon: const Icon(Symbols.add),
|
||||
onPressed: () {
|
||||
setState(() => _links!.add(('', '')));
|
||||
@ -457,7 +497,9 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
onChanged: (value) {
|
||||
_links![idx] = (value, _links![idx].$2);
|
||||
},
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) => FocusManager
|
||||
.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
@ -473,7 +515,9 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
onChanged: (value) {
|
||||
_links![idx] = (_links![idx].$1, value);
|
||||
},
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) => FocusManager
|
||||
.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@ -227,7 +228,7 @@ class _UserScreenState extends State<UserScreen>
|
||||
|
||||
late final _appBarWidth = MediaQuery.of(context).size.width;
|
||||
late final _appBarHeight =
|
||||
(_appBarWidth * kBannerAspectRatio).roundToDouble();
|
||||
math.min((_appBarWidth * kBannerAspectRatio), 360).roundToDouble();
|
||||
|
||||
void _updateAppBarBlur() {
|
||||
if (_scrollController.offset > _appBarHeight) return;
|
||||
@ -489,10 +490,10 @@ class _UserScreenState extends State<UserScreen>
|
||||
),
|
||||
const Gap(8),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: _account!.badges
|
||||
.map(
|
||||
(ele) => AccountBadge(badge: ele),
|
||||
)
|
||||
.map((ele) => AccountBadge(badge: ele))
|
||||
.toList(),
|
||||
).padding(horizontal: 8),
|
||||
const Gap(8),
|
||||
|
291
lib/screens/account/programs.dart
Normal file
291
lib/screens/account/programs.dart
Normal file
@ -0,0 +1,291 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/experience.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/markdown_content.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
class AccountProgramScreen extends StatefulWidget {
|
||||
const AccountProgramScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AccountProgramScreen> createState() => _AccountProgramScreenState();
|
||||
}
|
||||
|
||||
class _AccountProgramScreenState extends State<AccountProgramScreen> {
|
||||
bool _isBusy = false;
|
||||
final List<SnProgram> _programs = List.empty(growable: true);
|
||||
final List<SnProgramMember> _programMembers = List.empty(growable: true);
|
||||
|
||||
Future<void> _fetchPrograms() async {
|
||||
_programs.clear();
|
||||
setState(() => _isBusy = true);
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/programs');
|
||||
_programs.addAll(
|
||||
resp.data.map((ele) => SnProgram.fromJson(ele)).cast<SnProgram>(),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchProgramMembers() async {
|
||||
_programMembers.clear();
|
||||
setState(() => _isBusy = true);
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/programs/members');
|
||||
_programMembers.addAll(
|
||||
resp.data
|
||||
.map((ele) => SnProgramMember.fromJson(ele))
|
||||
.cast<SnProgramMember>(),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchPrograms();
|
||||
_fetchProgramMembers();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
title: Text('accountProgram').tr(),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _programs.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final ele = _programs[idx];
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => _ProgramJoinPopup(
|
||||
program: ele,
|
||||
isJoined:
|
||||
_programMembers.any((e) => e.programId == ele.id),
|
||||
),
|
||||
).then((value) {
|
||||
if (value == true) {
|
||||
_fetchProgramMembers();
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
if (ele.appearance['banner'] != null)
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 5,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceVariant,
|
||||
child: Image.network(
|
||||
ele.appearance['banner'],
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
ele.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium,
|
||||
).bold(),
|
||||
Text(
|
||||
ele.description,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_programMembers
|
||||
.any((e) => e.programId == ele.id))
|
||||
Text('accountProgramAlreadyJoined'.tr())
|
||||
.opacity(0.75),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).padding(horizontal: 8);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProgramJoinPopup extends StatefulWidget {
|
||||
final SnProgram program;
|
||||
final bool isJoined;
|
||||
const _ProgramJoinPopup({required this.program, required this.isJoined});
|
||||
|
||||
@override
|
||||
State<_ProgramJoinPopup> createState() => _ProgramJoinPopupState();
|
||||
}
|
||||
|
||||
class _ProgramJoinPopupState extends State<_ProgramJoinPopup> {
|
||||
bool _isBusy = false;
|
||||
|
||||
Future<void> _joinProgram() async {
|
||||
setState(() => _isBusy = true);
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.post('/cgi/id/programs/${widget.program.id}');
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
context.showSnackbar('accountProgramJoined'.tr());
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _leaveProgram() async {
|
||||
setState(() => _isBusy = true);
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.delete('/cgi/id/programs/${widget.program.id}');
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
context.showSnackbar('accountProgramLeft'.tr());
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.add, size: 24),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'accountProgramJoin',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
).tr(),
|
||||
],
|
||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.program.appearance['banner'] != null)
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 5,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: Image.network(
|
||||
widget.program.appearance['banner'],
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
).padding(bottom: 12),
|
||||
Text(
|
||||
widget.program.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
).bold(),
|
||||
MarkdownTextContent(content: widget.program.description),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'accountProgramJoinRequirements',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
).tr().bold(),
|
||||
Text('≥EXP ${widget.program.expRequirement}'),
|
||||
Text('≥Lv${getLevelFromExp(widget.program.expRequirement)}'),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'accountProgramJoinPricing',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
).tr().bold(),
|
||||
Text('walletCurrency${widget.program.price['currency'].toString().capitalize().replaceFirst('Normal', '')}')
|
||||
.plural(widget.program.price['amount'].toDouble()),
|
||||
Text('accountProgramJoinPricingHint').tr().opacity(0.75),
|
||||
const Gap(8),
|
||||
if (widget.isJoined)
|
||||
Text('accountProgramLeaveHint')
|
||||
.tr()
|
||||
.opacity(0.75)
|
||||
.padding(bottom: 8),
|
||||
if (!widget.isJoined)
|
||||
ElevatedButton(
|
||||
onPressed: _isBusy ? null : _joinProgram,
|
||||
child: Text('join').tr(),
|
||||
)
|
||||
else
|
||||
ElevatedButton(
|
||||
onPressed: _isBusy ? null : _leaveProgram,
|
||||
child: Text('leave').tr(),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24),
|
||||
Gap(MediaQuery.of(context).padding.bottom),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -27,10 +27,12 @@ class AccountPublisherEditScreen extends StatefulWidget {
|
||||
const AccountPublisherEditScreen({super.key, required this.name});
|
||||
|
||||
@override
|
||||
State<AccountPublisherEditScreen> createState() => _AccountPublisherEditScreenState();
|
||||
State<AccountPublisherEditScreen> createState() =>
|
||||
_AccountPublisherEditScreenState();
|
||||
}
|
||||
|
||||
class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> {
|
||||
class _AccountPublisherEditScreenState
|
||||
extends State<AccountPublisherEditScreen> {
|
||||
bool _isBusy = false;
|
||||
|
||||
SnPublisher? _publisher;
|
||||
@ -115,29 +117,32 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
|
||||
Uint8List? rawBytes;
|
||||
if (!skipCrop) {
|
||||
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios =
|
||||
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
|
||||
final result =
|
||||
(!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
)
|
||||
: await showMaterialImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
);
|
||||
final ImageProvider imageProvider =
|
||||
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios = place == 'banner'
|
||||
? [CropAspectRatio(width: 16, height: 7)]
|
||||
: [CropAspectRatio(width: 1, height: 1)];
|
||||
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
)
|
||||
: await showMaterialImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
);
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = true);
|
||||
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
|
||||
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!
|
||||
.buffer
|
||||
.asUint8List();
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = true);
|
||||
@ -191,7 +196,10 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(leading: PageBackButton(), title: Text('screenAccountPublisherEdit').tr()),
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: PageBackButton(),
|
||||
title: Text('screenAccountPublisherEdit').tr()),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
@ -208,11 +216,14 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child:
|
||||
_banner != null
|
||||
? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover)
|
||||
: const SizedBox.shrink(),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
child: _banner != null
|
||||
? AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(_banner!),
|
||||
fit: BoxFit.cover)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -245,13 +256,15 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
labelText: 'fieldUsername'.tr(),
|
||||
helperText: 'fieldUsernameCannotEditHint'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _nickController,
|
||||
decoration: InputDecoration(labelText: 'fieldNickname'.tr()),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
@ -259,7 +272,8 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
maxLines: null,
|
||||
minLines: 3,
|
||||
decoration: InputDecoration(labelText: 'fieldDescription'.tr()),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
Row(
|
||||
|
@ -25,7 +25,8 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('screenAccountPublisherNew').tr(),
|
||||
|
@ -33,7 +33,8 @@ class _PublisherScreenState extends State<PublisherScreen> {
|
||||
|
||||
try {
|
||||
final resp = await sn.client.get('/cgi/co/publishers/me');
|
||||
final List<SnPublisher> out = List<SnPublisher>.from(resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
|
||||
final List<SnPublisher> out = List<SnPublisher>.from(
|
||||
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@ -81,6 +82,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('screenAccountPublishers').tr(),
|
||||
@ -93,7 +95,9 @@ class _PublisherScreenState extends State<PublisherScreen> {
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.add_circle),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
|
||||
GoRouter.of(context)
|
||||
.pushNamed('accountPublisherNew')
|
||||
.then((value) {
|
||||
if (value == true) {
|
||||
_publishers.clear();
|
||||
_fetchPublishers();
|
||||
@ -119,7 +123,8 @@ class _PublisherScreenState extends State<PublisherScreen> {
|
||||
return ListTile(
|
||||
title: Text(publisher.nick),
|
||||
subtitle: Text('@${publisher.name}'),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: AccountImage(content: publisher.avatar),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder: (BuildContext context) => [
|
||||
|
187
lib/screens/account/punishments.dart
Normal file
187
lib/screens/account/punishments.dart
Normal file
@ -0,0 +1,187 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
const kPunishmentIcons = [
|
||||
Symbols.warning,
|
||||
Symbols.emergency_home,
|
||||
Symbols.dangerous,
|
||||
];
|
||||
|
||||
class PunishmentsScreen extends StatefulWidget {
|
||||
const PunishmentsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PunishmentsScreen> createState() => _PunishmentsScreenState();
|
||||
}
|
||||
|
||||
class _PunishmentsScreenState extends State<PunishmentsScreen> {
|
||||
bool _isBusy = false;
|
||||
List<SnPunishment>? _punishments;
|
||||
|
||||
Future<void> _fetchPunishments() async {
|
||||
setState(() => _isBusy = true);
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/punishments');
|
||||
if (!mounted) return;
|
||||
_punishments = List.from(
|
||||
resp.data.map((ele) => SnPunishment.fromJson(ele)),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchPunishments();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
title: Text('accountPunishments').tr(),
|
||||
leading: PageBackButton(),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
Card(
|
||||
margin: EdgeInsets.only(bottom: 8, left: 8, right: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.visibility, size: 20),
|
||||
const Gap(6),
|
||||
Expanded(
|
||||
child: Text('punishmentOverall').tr().fontSize(16).bold(),
|
||||
),
|
||||
],
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
if (_punishments == null) return Text('loading').tr();
|
||||
if (_punishments!.any((ele) => ele.type == 2)) {
|
||||
return Text('punishmentStatusBanned').tr();
|
||||
}
|
||||
if (_punishments!.any(
|
||||
(ele) => ele.type == 1 && ele.permNodes.isEmpty,
|
||||
)) {
|
||||
return Text('punishmentStatusLimitedFully').tr();
|
||||
} else if (_punishments!.any((ele) => ele.type == 1)) {
|
||||
return Text('punishmentStatusLimited').tr();
|
||||
}
|
||||
if (_punishments!.any((ele) => ele.type == 0)) {
|
||||
return Text('punishmentStatusWarned').tr();
|
||||
}
|
||||
return Text('punishmentStatusNormal').tr();
|
||||
},
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _fetchPunishments,
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _punishments?.length ?? 0,
|
||||
itemBuilder: (context, index) {
|
||||
final ele = _punishments![index];
|
||||
return Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(kPunishmentIcons[ele.type], size: 20),
|
||||
const Gap(6),
|
||||
Expanded(
|
||||
child: Text('punishmentType${ele.type}')
|
||||
.tr()
|
||||
.fontSize(16)
|
||||
.bold(),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(ele.reason),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'punishmentCreatedAt'.tr(args: [
|
||||
DateFormat().format(
|
||||
ele.createdAt.toLocal(),
|
||||
)
|
||||
]),
|
||||
).opacity(0.8),
|
||||
Text(
|
||||
ele.expiredAt == null
|
||||
? 'punishmentExpiredNever'.tr()
|
||||
: 'punishmentExpiredAt'.tr(args: [
|
||||
DateFormat().format(
|
||||
ele.expiredAt!.toLocal(),
|
||||
)
|
||||
]),
|
||||
).opacity(0.8),
|
||||
const Gap(8),
|
||||
if (ele.moderator != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('punishmentModerator').tr().opacity(0.75),
|
||||
InkWell(
|
||||
child: Row(
|
||||
children: [
|
||||
AccountImage(
|
||||
content: ele.moderator!.avatar,
|
||||
radius: 8,
|
||||
),
|
||||
const Gap(4),
|
||||
Text(ele.moderator?.nick ?? 'unknown'),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'accountProfilePage',
|
||||
pathParameters: {
|
||||
'name': ele.moderator!.name,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text('punishmentMadeBySystem').tr().opacity(0.75),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Gap(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -37,6 +37,7 @@ class AccountSettingsScreen extends StatelessWidget {
|
||||
final ua = context.watch<UserProvider>();
|
||||
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: PageBackButton(),
|
||||
title: Text('screenAccountSettings').tr(),
|
||||
@ -97,6 +98,36 @@ class AccountSettingsScreen extends StatelessWidget {
|
||||
GoRouter.of(context).pushNamed('accountContactMethods');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountSettingsNotify').tr(),
|
||||
subtitle: Text('accountSettingsNotifyDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.notifications),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountSettingsNotify');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountSettingsSecurity').tr(),
|
||||
subtitle: Text('accountSettingsSecurityDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.shield),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountSettingsSecurity');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('factorSettings').tr(),
|
||||
subtitle: Text('factorSettingsSubtitle').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.lock),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('factorSettings');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountProfileEdit').tr(),
|
||||
subtitle: Text('accountProfileEditSubtitle').tr(),
|
@ -10,7 +10,6 @@ import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/widgets/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_zoom.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_item.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
@ -106,7 +105,7 @@ class _AlbumScreenState extends State<AlbumScreen> {
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
leading: PageBackButton(),
|
||||
title: Text('screenAlbum').tr(),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
@ -119,7 +118,8 @@ class _AlbumScreenState extends State<AlbumScreen> {
|
||||
child: CircularProgressIndicator(
|
||||
value: _billing?.includedRatio ?? 0,
|
||||
strokeWidth: 8,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
).padding(all: 12),
|
||||
const Gap(24),
|
||||
@ -129,7 +129,8 @@ class _AlbumScreenState extends State<AlbumScreen> {
|
||||
children: [
|
||||
Text('attachmentBillingUploaded').tr().bold(),
|
||||
Text(
|
||||
(_billing?.currentBytes ?? 0).formatBytes(decimals: 4),
|
||||
(_billing?.currentBytes ?? 0)
|
||||
.formatBytes(decimals: 4),
|
||||
style: GoogleFonts.robotoMono(),
|
||||
),
|
||||
Text('attachmentBillingDiscount').tr().bold(),
|
||||
|
@ -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/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) => CaptchaScreen(),
|
||||
),
|
||||
);
|
||||
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,
|
||||
|
3
lib/screens/captcha/captcha.dart
Normal file
3
lib/screens/captcha/captcha.dart
Normal file
@ -0,0 +1,3 @@
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
export 'captcha_native.dart' if (kIsWeb) 'captcha_web.dart';
|
37
lib/screens/captcha/captcha_native.dart
Normal file
37
lib/screens/captcha/captcha_native.dart
Normal file
@ -0,0 +1,37 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
class CaptchaScreen extends StatefulWidget {
|
||||
const CaptchaScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CaptchaScreen> createState() => _CaptchaScreenState();
|
||||
}
|
||||
|
||||
class _CaptchaScreenState extends State<CaptchaScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cfg = context.read<ConfigProvider>();
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: Text("reCaptcha").tr()),
|
||||
body: InAppWebView(
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri('${cfg.serverUrl}/captcha?redirect_uri=solink://captcha'),
|
||||
),
|
||||
shouldOverrideUrlLoading: (controller, navigationAction) async {
|
||||
Uri? url = navigationAction.request.url;
|
||||
if (url != null && url.queryParameters.containsKey('captcha_tk')) {
|
||||
Navigator.pop(context, url.queryParameters['captcha_tk']!);
|
||||
return NavigationActionPolicy.CANCEL;
|
||||
}
|
||||
return NavigationActionPolicy.ALLOW;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
54
lib/screens/captcha/captcha_web.dart
Normal file
54
lib/screens/captcha/captcha_web.dart
Normal file
@ -0,0 +1,54 @@
|
||||
import 'dart:html' as html;
|
||||
import 'dart:ui_web' as ui;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
class CaptchaScreen extends StatefulWidget {
|
||||
const CaptchaScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CaptchaScreen> createState() => _CaptchaScreenState();
|
||||
}
|
||||
|
||||
class _CaptchaScreenState extends State<CaptchaScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupWebListener();
|
||||
}
|
||||
|
||||
void _setupWebListener() {
|
||||
html.window.onMessage.listen((event) {
|
||||
if (event.data != null && event.data is String) {
|
||||
final message = event.data as String;
|
||||
if (message.startsWith("captcha_tk=")) {
|
||||
String token = message.replaceFirst("captcha_tk=", "");
|
||||
Navigator.pop(context, token);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final iframe = html.IFrameElement()
|
||||
..src = '${context.read<ConfigProvider>().serverUrl}/captcha?redirect_uri=web'
|
||||
..style.border = 'none'
|
||||
..width = '100%'
|
||||
..height = '100%';
|
||||
|
||||
html.document.body!.append(iframe);
|
||||
ui.platformViewRegistry.registerViewFactory(
|
||||
'captcha-iframe',
|
||||
(int viewId) => iframe,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: Text("reCaptcha").tr()),
|
||||
body: HtmlElementView(viewType: 'captcha-iframe'),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
|
||||
@ -6,21 +8,22 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:responsive_framework/responsive_framework.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/channel.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/sn_realm.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/screens/chat/room.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/account/account_select.dart';
|
||||
import 'package:surface/widgets/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_background.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/unauthorized_hint.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
@ -38,6 +41,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
List<SnChannel>? _channels;
|
||||
Map<int, SnChatMessage>? _lastMessages;
|
||||
Map<int, int>? _unreadCounts;
|
||||
Map<int, int>? _unreadCountsGrouped;
|
||||
|
||||
Future<void> _fetchWhatsNew() async {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
@ -45,19 +49,48 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
if (resp.data == null) return;
|
||||
final List<dynamic> out = resp.data;
|
||||
setState(() {
|
||||
_unreadCounts = {for (var v in out) v['channel_id']: v['count']};
|
||||
_unreadCounts ??= {};
|
||||
_unreadCountsGrouped ??= {};
|
||||
for (var v in out) {
|
||||
_unreadCounts![v['channel_id']] = v['count'];
|
||||
final channel =
|
||||
_channels?.firstWhereOrNull((ele) => ele.id == v['channel_id']);
|
||||
if (channel != null) {
|
||||
if (channel.realmId != null) {
|
||||
_unreadCountsGrouped![channel.realmId!] ??= 0;
|
||||
_unreadCountsGrouped![channel.realmId!] =
|
||||
(_unreadCountsGrouped![channel.realmId!]! + v['count']).toInt();
|
||||
}
|
||||
if (channel.type == 1) {
|
||||
_unreadCountsGrouped![0] ??= 0;
|
||||
_unreadCountsGrouped![0] =
|
||||
(_unreadCountsGrouped![0]! + v['count']).toInt();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _refreshChannels({bool noRemote = false}) {
|
||||
void _refreshChannels({bool withBoost = false, bool noRemote = false}) {
|
||||
final ct = context.read<ChatChannelProvider>();
|
||||
final ua = context.read<UserProvider>();
|
||||
if (!ua.isAuthorized) {
|
||||
setState(() => _isBusy = false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!withBoost) {
|
||||
if (!noRemote) {
|
||||
ct.refreshAvailableChannels();
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_channels = ct.availableChannels;
|
||||
});
|
||||
}
|
||||
|
||||
final chan = context.read<ChatChannelProvider>();
|
||||
chan.fetchChannels(noRemote: noRemote).listen((channels) async {
|
||||
chan.fetchChannels(noRemote: true).listen((channels) async {
|
||||
final lastMessages = await chan.getLastMessages(channels);
|
||||
_lastMessages = {for (final val in lastMessages) val.channelId: val};
|
||||
channels.sort((a, b) {
|
||||
@ -99,6 +132,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
..onDone(() {
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = false);
|
||||
_fetchWhatsNew();
|
||||
});
|
||||
}
|
||||
|
||||
@ -130,40 +164,51 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
SnChannel? _focusChannel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refreshChannels();
|
||||
_fetchWhatsNew();
|
||||
_refreshChannels(withBoost: true);
|
||||
}
|
||||
|
||||
void _onTapChannel(SnChannel channel) {
|
||||
final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP);
|
||||
|
||||
if (doExpand) {
|
||||
setState(() => _focusChannel = channel);
|
||||
return;
|
||||
setState(() => _unreadCounts?[channel.id] = 0);
|
||||
if (ResponsiveScaffold.getIsExpand(context)) {
|
||||
GoRouter.of(context).pushReplacementNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {
|
||||
'scope': channel.realm?.alias ?? 'global',
|
||||
'alias': channel.alias,
|
||||
},
|
||||
).then((value) {
|
||||
if (mounted) {
|
||||
setState(() => _unreadCounts?[channel.id] = 0);
|
||||
_refreshChannels(noRemote: true);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {
|
||||
'scope': channel.realm?.alias ?? 'global',
|
||||
'alias': channel.alias,
|
||||
},
|
||||
).then((value) {
|
||||
if (mounted) {
|
||||
setState(() => _unreadCounts?[channel.id] = 0);
|
||||
_refreshChannels(noRemote: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
GoRouter.of(context).pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {
|
||||
'scope': channel.realm?.alias ?? 'global',
|
||||
'alias': channel.alias,
|
||||
},
|
||||
).then((value) {
|
||||
if (mounted) {
|
||||
_unreadCounts?[channel.id] = 0;
|
||||
setState(() => _unreadCounts?[channel.id] = 0);
|
||||
_refreshChannels(noRemote: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
SnRealm? _focusedRealm;
|
||||
bool _isDirect = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ua = context.read<UserProvider>();
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final rel = context.read<SnRealmProvider>();
|
||||
|
||||
if (!ua.isAuthorized) {
|
||||
return AppScaffold(
|
||||
@ -177,10 +222,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP);
|
||||
|
||||
final chatList = AppScaffold(
|
||||
noBackground: doExpand,
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
title: Text('screenChat').tr(),
|
||||
@ -248,64 +291,198 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
body: Column(
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
if (_channels != null && ResponsiveScaffold.getIsExpand(context))
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => Future.wait([
|
||||
Future.sync(() => _refreshChannels()),
|
||||
_fetchWhatsNew(),
|
||||
]),
|
||||
child: ListView.builder(
|
||||
itemCount: _channels?.length ?? 0,
|
||||
itemBuilder: (context, idx) {
|
||||
final channel = _channels![idx];
|
||||
final lastMessage = _lastMessages?[channel.id];
|
||||
onRefresh: () => Future.sync(() => _refreshChannels()),
|
||||
child: Builder(builder: (context) {
|
||||
final scopeList = ListView(
|
||||
key: const Key('realm-list-view'),
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading:
|
||||
const Icon(Symbols.inbox_text).padding(right: 4),
|
||||
contentPadding: EdgeInsets.only(left: 24, right: 24),
|
||||
title: Text('chatDirect').tr(),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (_unreadCountsGrouped?[0] != null &&
|
||||
(_unreadCountsGrouped?[0] ?? 0) > 0)
|
||||
Badge(
|
||||
label: Text(
|
||||
_unreadCountsGrouped![0].toString(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
setState(() => _isDirect = true);
|
||||
},
|
||||
),
|
||||
...rel.availableRealms.map((ele) {
|
||||
return ListTile(
|
||||
minTileHeight: 48,
|
||||
contentPadding: EdgeInsets.only(left: 20, right: 24),
|
||||
leading: AccountImage(
|
||||
content: ele.avatar,
|
||||
radius: 16,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (_unreadCountsGrouped?[ele.id] != null &&
|
||||
(_unreadCountsGrouped?[ele.id] ?? 0) > 0)
|
||||
Badge(
|
||||
label: Text(
|
||||
_unreadCountsGrouped![ele.id].toString(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(ele.name),
|
||||
onTap: () {
|
||||
setState(() => _focusedRealm = ele);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return _ChatChannelEntry(
|
||||
channel: channel,
|
||||
lastMessage: lastMessage,
|
||||
unreadCount: _unreadCounts?[channel.id],
|
||||
onTap: () {
|
||||
if (doExpand) {
|
||||
_unreadCounts?[channel.id] = 0;
|
||||
setState(() => _focusChannel = channel);
|
||||
return;
|
||||
}
|
||||
_onTapChannel(channel);
|
||||
},
|
||||
);
|
||||
},
|
||||
final directChatList = ListView(
|
||||
key: Key('direct-chat-list-view'),
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.arrow_left_alt),
|
||||
contentPadding: EdgeInsets.only(left: 24),
|
||||
title: Text('back').tr(),
|
||||
onTap: () {
|
||||
setState(() => _isDirect = false);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
..._channels!.where((ele) => ele.type == 1).map(
|
||||
(ele) {
|
||||
return _ChatChannelEntry(
|
||||
channel: ele,
|
||||
unreadCount: _unreadCounts?[ele.id],
|
||||
lastMessage: _lastMessages?[ele.id],
|
||||
isCompact: true,
|
||||
onTap: () => _onTapChannel(ele),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
final realmScopedChatList = _focusedRealm == null
|
||||
? const SizedBox.shrink()
|
||||
: ListView(
|
||||
key: ValueKey(_focusedRealm),
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
if (_focusedRealm!.banner != null)
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(
|
||||
_focusedRealm!.banner!,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
tileColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainer,
|
||||
leading: AccountImage(
|
||||
content: _focusedRealm!.avatar,
|
||||
radius: 16,
|
||||
),
|
||||
contentPadding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 16,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
setState(() => _focusedRealm = null);
|
||||
},
|
||||
),
|
||||
title: Text(_focusedRealm!.name),
|
||||
),
|
||||
...(_channels!
|
||||
.where(
|
||||
(ele) => ele.realmId == _focusedRealm?.id)
|
||||
.map(
|
||||
(ele) {
|
||||
return _ChatChannelEntry(
|
||||
channel: ele,
|
||||
unreadCount: _unreadCounts?[ele.id],
|
||||
lastMessage: _lastMessages?[ele.id],
|
||||
onTap: () => _onTapChannel(ele),
|
||||
isCompact: true,
|
||||
);
|
||||
},
|
||||
))
|
||||
],
|
||||
);
|
||||
|
||||
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: (_focusedRealm == null && !_isDirect)
|
||||
? scopeList
|
||||
: _isDirect
|
||||
? directChatList
|
||||
: realmScopedChatList,
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
else if (_channels != null)
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => Future.sync(() => _refreshChannels()),
|
||||
child: ListView(
|
||||
key: const Key('chat-list-view'),
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
...(_channels!.map((ele) {
|
||||
return _ChatChannelEntry(
|
||||
channel: ele,
|
||||
unreadCount: _unreadCounts?[ele.id],
|
||||
lastMessage: _lastMessages?[ele.id],
|
||||
onTap: () => _onTapChannel(ele),
|
||||
);
|
||||
}))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (doExpand) {
|
||||
return AppBackground(
|
||||
isRoot: true,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 340, child: chatList),
|
||||
const VerticalDivider(width: 1),
|
||||
if (_focusChannel != null)
|
||||
Expanded(
|
||||
child: ChatRoomScreen(
|
||||
key: ValueKey(_focusChannel!.id),
|
||||
scope: _focusChannel!.realm?.alias ?? 'global',
|
||||
alias: _focusChannel!.alias,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return chatList;
|
||||
}
|
||||
}
|
||||
|
||||
@ -314,11 +491,13 @@ class _ChatChannelEntry extends StatelessWidget {
|
||||
final int? unreadCount;
|
||||
final SnChatMessage? lastMessage;
|
||||
final Function? onTap;
|
||||
final bool isCompact;
|
||||
const _ChatChannelEntry({
|
||||
required this.channel,
|
||||
this.unreadCount,
|
||||
this.lastMessage,
|
||||
this.onTap,
|
||||
this.isCompact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -337,6 +516,34 @@ class _ChatChannelEntry extends StatelessWidget {
|
||||
? ud.getFromCache(otherMember.accountId)?.nick ?? channel.name
|
||||
: channel.name;
|
||||
|
||||
if (isCompact) {
|
||||
return ListTile(
|
||||
minTileHeight: 48,
|
||||
contentPadding:
|
||||
EdgeInsets.only(left: otherMember != null ? 20 : 24, right: 24),
|
||||
leading: otherMember != null
|
||||
? AccountImage(
|
||||
content: ud.getFromCache(otherMember.accountId)?.avatar,
|
||||
radius: 16,
|
||||
)
|
||||
: const Icon(Symbols.tag),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (unreadCount != null && (unreadCount ?? 0) > 0)
|
||||
Badge(
|
||||
label: Text(unreadCount.toString()),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(title),
|
||||
onTap: () {
|
||||
onTap?.call();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
@ -399,7 +606,7 @@ class _ChatChannelEntry extends StatelessWidget {
|
||||
content: otherMember != null
|
||||
? ud.getFromCache(otherMember.accountId)?.avatar
|
||||
: channel.realm?.avatar,
|
||||
fallbackWidget: const Icon(Symbols.chat, size: 20),
|
||||
fallbackWidget: const Icon(Symbols.tag, size: 20),
|
||||
),
|
||||
onTap: () => onTap?.call(),
|
||||
);
|
||||
|
@ -37,7 +37,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
|
||||
color:
|
||||
Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.75),
|
||||
child: call.focusTrack != null
|
||||
? InteractiveParticipantWidget(
|
||||
isFixedAvatar: false,
|
||||
@ -72,7 +73,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
||||
color: Theme.of(context).cardColor,
|
||||
participant: track,
|
||||
onTap: () {
|
||||
if (track.participant.sid != call.focusTrack?.participant.sid) {
|
||||
if (track.participant.sid !=
|
||||
call.focusTrack?.participant.sid) {
|
||||
call.setFocusTrack(track);
|
||||
}
|
||||
},
|
||||
@ -114,10 +116,14 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: InteractiveParticipantWidget(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh.withOpacity(0.75),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh
|
||||
.withOpacity(0.75),
|
||||
participant: track,
|
||||
onTap: () {
|
||||
if (track.participant.sid != call.focusTrack?.participant.sid) {
|
||||
if (track.participant.sid !=
|
||||
call.focusTrack?.participant.sid) {
|
||||
call.setFocusTrack(track);
|
||||
}
|
||||
},
|
||||
@ -149,6 +155,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
||||
listenable: call,
|
||||
builder: (context, _) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
title: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
@ -183,7 +190,8 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
||||
Builder(builder: (context) {
|
||||
final call = context.read<ChatCallProvider>();
|
||||
final connectionQuality =
|
||||
call.room.localParticipant?.connectionQuality ?? livekit.ConnectionQuality.unknown;
|
||||
call.room.localParticipant?.connectionQuality ??
|
||||
livekit.ConnectionQuality.unknown;
|
||||
return Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@ -205,24 +213,35 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
||||
children: [
|
||||
Text(
|
||||
{
|
||||
livekit.ConnectionState.disconnected: 'callStatusDisconnected'.tr(),
|
||||
livekit.ConnectionState.connected: 'callStatusConnected'.tr(),
|
||||
livekit.ConnectionState.connecting: 'callStatusConnecting'.tr(),
|
||||
livekit.ConnectionState.reconnecting: 'callStatusReconnecting'.tr(),
|
||||
livekit.ConnectionState.disconnected:
|
||||
'callStatusDisconnected'.tr(),
|
||||
livekit.ConnectionState.connected:
|
||||
'callStatusConnected'.tr(),
|
||||
livekit.ConnectionState.connecting:
|
||||
'callStatusConnecting'.tr(),
|
||||
livekit.ConnectionState.reconnecting:
|
||||
'callStatusReconnecting'.tr(),
|
||||
}[call.room.connectionState]!,
|
||||
),
|
||||
const Gap(6),
|
||||
if (connectionQuality != livekit.ConnectionQuality.unknown)
|
||||
if (connectionQuality !=
|
||||
livekit.ConnectionQuality.unknown)
|
||||
Icon(
|
||||
{
|
||||
livekit.ConnectionQuality.excellent: Icons.signal_cellular_alt,
|
||||
livekit.ConnectionQuality.good: Icons.signal_cellular_alt_2_bar,
|
||||
livekit.ConnectionQuality.poor: Icons.signal_cellular_alt_1_bar,
|
||||
livekit.ConnectionQuality.excellent:
|
||||
Icons.signal_cellular_alt,
|
||||
livekit.ConnectionQuality.good:
|
||||
Icons.signal_cellular_alt_2_bar,
|
||||
livekit.ConnectionQuality.poor:
|
||||
Icons.signal_cellular_alt_1_bar,
|
||||
}[connectionQuality],
|
||||
color: {
|
||||
livekit.ConnectionQuality.excellent: Colors.green,
|
||||
livekit.ConnectionQuality.good: Colors.orange,
|
||||
livekit.ConnectionQuality.poor: Colors.red,
|
||||
livekit.ConnectionQuality.excellent:
|
||||
Colors.green,
|
||||
livekit.ConnectionQuality.good:
|
||||
Colors.orange,
|
||||
livekit.ConnectionQuality.poor:
|
||||
Colors.red,
|
||||
}[connectionQuality],
|
||||
size: 16,
|
||||
)
|
||||
@ -244,7 +263,9 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: _layoutMode == 0 ? const Icon(Icons.view_list) : const Icon(Icons.grid_view),
|
||||
icon: _layoutMode == 0
|
||||
? const Icon(Icons.view_list)
|
||||
: const Icon(Icons.grid_view),
|
||||
onPressed: () {
|
||||
_switchLayout();
|
||||
},
|
||||
|
@ -220,6 +220,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
||||
final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id;
|
||||
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
|
||||
),
|
||||
|
@ -49,7 +49,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
|
||||
);
|
||||
if (_editingChannel != null) {
|
||||
_belongToRealm = _realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
|
||||
_belongToRealm =
|
||||
_realms?.firstWhereOrNull((e) => e.id == _editingChannel!.realmId);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) context.showErrorDialog(err);
|
||||
@ -97,7 +98,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
'is_community': _isCommunity,
|
||||
if (_editingChannel != null && _belongToRealm == null)
|
||||
'new_belongs_realm': 'global'
|
||||
else if (_editingChannel != null && _belongToRealm?.id != _editingChannel?.realm?.id)
|
||||
else if (_editingChannel != null &&
|
||||
_belongToRealm?.id != _editingChannel?.realm?.id)
|
||||
'new_belongs_realm': _belongToRealm!.alias,
|
||||
};
|
||||
|
||||
@ -139,8 +141,11 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
title: widget.editingChannelAlias != null ? Text('screenChatManage').tr() : Text('screenChatNew').tr(),
|
||||
title: widget.editingChannelAlias != null
|
||||
? Text('screenChatManage').tr()
|
||||
: Text('screenChatNew').tr(),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
@ -152,7 +157,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
|
||||
dividerColor: Colors.transparent,
|
||||
content: Text(
|
||||
'channelEditingNotice'.tr(args: ['#${_editingChannel!.alias}']),
|
||||
'channelEditingNotice'
|
||||
.tr(args: ['#${_editingChannel!.alias}']),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@ -192,12 +198,15 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.name).textStyle(Theme.of(context).textTheme.bodyMedium!),
|
||||
Text(item.name).textStyle(Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium!),
|
||||
Text(
|
||||
item.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).textStyle(Theme.of(context).textTheme.bodySmall!),
|
||||
).textStyle(
|
||||
Theme.of(context).textTheme.bodySmall!),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -213,7 +222,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
child: const Icon(Symbols.clear),
|
||||
),
|
||||
const Gap(12),
|
||||
@ -222,7 +232,9 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('fieldChatBelongToRealmUnset').tr().textStyle(
|
||||
Text('fieldChatBelongToRealmUnset')
|
||||
.tr()
|
||||
.textStyle(
|
||||
Theme.of(context).textTheme.bodyMedium!,
|
||||
),
|
||||
],
|
||||
@ -257,7 +269,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
helperText: 'fieldChatAliasHint'.tr(),
|
||||
helperMaxLines: 2,
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
@ -266,7 +279,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldChatName'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
@ -277,7 +291,8 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldChatDescription'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
CheckboxListTile(
|
||||
|
@ -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) {
|
||||
@ -274,6 +304,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_channel?.type == 1
|
||||
@ -281,25 +312,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 +381,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 +470,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!_messageController.isPending)
|
||||
if (!_messageController.isPending && _currentMember != null)
|
||||
Material(
|
||||
elevation: 2,
|
||||
child: Column(
|
||||
|
@ -157,6 +157,7 @@ class _ExploreScreenState extends State<ExploreScreen>
|
||||
Widget build(BuildContext context) {
|
||||
final cfg = context.watch<ConfigProvider>();
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
floatingActionButtonLocation: ExpandableFab.location,
|
||||
floatingActionButton: ExpandableFab(
|
||||
key: _fabKey,
|
||||
@ -243,6 +244,8 @@ class _ExploreScreenState extends State<ExploreScreen>
|
||||
GoRouter.of(context).pushNamed('postShuffle');
|
||||
},
|
||||
),
|
||||
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE))
|
||||
const Gap(48),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: IconButton(
|
||||
@ -449,7 +452,7 @@ class _PostListWidgetState extends State<_PostListWidget> {
|
||||
data: ele.toJson(),
|
||||
createdAt: ele.createdAt)),
|
||||
);
|
||||
_hasLoadedAll = postCount >= _feed.length;
|
||||
_hasLoadedAll = _feed.length >= postCount;
|
||||
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
}
|
||||
@ -534,6 +537,7 @@ class _PostListWidgetState extends State<_PostListWidget> {
|
||||
switch (ele.type) {
|
||||
case 'interactive.post':
|
||||
return OpenablePostItem(
|
||||
useReplace: true,
|
||||
data: SnPost.fromJson(ele.data),
|
||||
maxWidth: 640,
|
||||
onChanged: (data) {
|
||||
@ -551,9 +555,11 @@ class _PostListWidgetState extends State<_PostListWidget> {
|
||||
maxWidth: 640,
|
||||
);
|
||||
case 'reader.news':
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: NewsFeedEntry(data: ele),
|
||||
return Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: NewsFeedEntry(data: ele),
|
||||
),
|
||||
);
|
||||
default:
|
||||
return Container(
|
||||
|
@ -10,7 +10,6 @@ import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/account/account_select.dart';
|
||||
import 'package:surface/widgets/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
@ -47,8 +46,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/users/me/relations?status=1');
|
||||
_relations = List<SnRelationship>.from(
|
||||
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
|
||||
);
|
||||
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@ -67,8 +65,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3');
|
||||
_requests = List<SnRelationship>.from(
|
||||
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
|
||||
);
|
||||
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@ -87,8 +84,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/users/me/relations?status=2');
|
||||
_blocks = List<SnRelationship>.from(
|
||||
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
|
||||
);
|
||||
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@ -105,10 +101,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
try {
|
||||
final rel = context.read<SnRelationshipProvider>();
|
||||
await rel.updateRelationship(
|
||||
relation.relatedId,
|
||||
dstStatus,
|
||||
relation.permNodes,
|
||||
);
|
||||
relation.relatedId, dstStatus, relation.permNodes);
|
||||
if (!mounted) return;
|
||||
_fetchRelations();
|
||||
} catch (err) {
|
||||
@ -122,9 +115,8 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
Future<void> _deleteRelation(SnRelationship relation) async {
|
||||
final confirm = await context.showConfirmDialog(
|
||||
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
|
||||
'friendDeleteDescription'.tr(args: [
|
||||
relation.related?.nick ?? 'unknown'.tr(),
|
||||
]),
|
||||
'friendDeleteDescription'
|
||||
.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
|
||||
);
|
||||
if (!confirm) return;
|
||||
if (!mounted) return;
|
||||
@ -146,9 +138,11 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
|
||||
void _showRequests() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _FriendshipListWidget(relations: _requests),
|
||||
).then((value) {
|
||||
context: context,
|
||||
builder: (context) => _FriendshipListWidget(relations: _requests))
|
||||
.then((
|
||||
value,
|
||||
) {
|
||||
if (value != null) {
|
||||
_fetchRequests();
|
||||
_fetchRelations();
|
||||
@ -158,9 +152,10 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
|
||||
void _showBlocks() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _FriendshipListWidget(relations: _blocks),
|
||||
).then((value) {
|
||||
context: context,
|
||||
builder: (context) => _FriendshipListWidget(relations: _blocks)).then((
|
||||
value,
|
||||
) {
|
||||
if (value != null) {
|
||||
_fetchBlocks();
|
||||
_fetchRelations();
|
||||
@ -173,9 +168,8 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.post('/cgi/id/users/me/relations', data: {
|
||||
'related': user.name,
|
||||
});
|
||||
await sn.client
|
||||
.post('/cgi/id/users/me/relations', data: {'related': user.name});
|
||||
if (!mounted) return;
|
||||
context.showSnackbar('friendRequestSent'.tr());
|
||||
} catch (err) {
|
||||
@ -201,18 +195,16 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
if (!ua.isAuthorized) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
leading: PageBackButton(),
|
||||
title: Text('screenFriend').tr(),
|
||||
),
|
||||
body: Center(
|
||||
child: UnauthorizedHint(),
|
||||
),
|
||||
body: Center(child: UnauthorizedHint()),
|
||||
);
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
leading: PageBackButton(),
|
||||
title: Text('screenFriend').tr(),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
@ -220,9 +212,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
onPressed: () async {
|
||||
final user = await showModalBottomSheet<SnAccount?>(
|
||||
context: context,
|
||||
builder: (context) => AccountSelect(
|
||||
title: 'friendNew'.tr(),
|
||||
),
|
||||
builder: (context) => AccountSelect(title: 'friendNew'.tr()),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (user == null) return;
|
||||
@ -235,9 +225,8 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
if (_requests.isNotEmpty)
|
||||
ListTile(
|
||||
title: Text('friendRequests').tr(),
|
||||
subtitle: Text(
|
||||
'friendRequestsDescription',
|
||||
).plural(_requests.length),
|
||||
subtitle:
|
||||
Text('friendRequestsDescription').plural(_requests.length),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.group_add),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
@ -246,31 +235,30 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
if (_blocks.isNotEmpty)
|
||||
ListTile(
|
||||
title: Text('friendBlocklist').tr(),
|
||||
subtitle: Text(
|
||||
'friendBlocklistDescription',
|
||||
).plural(_blocks.length),
|
||||
subtitle:
|
||||
Text('friendBlocklistDescription').plural(_blocks.length),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.block),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: _showBlocks,
|
||||
),
|
||||
if (_requests.isNotEmpty || _blocks.isNotEmpty) const Divider(height: 1),
|
||||
if (_requests.isNotEmpty || _blocks.isNotEmpty)
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => Future.wait([
|
||||
_fetchRelations(),
|
||||
_fetchRequests(),
|
||||
]),
|
||||
onRefresh: () =>
|
||||
Future.wait([_fetchRelations(), _fetchRequests()]),
|
||||
child: ListView.builder(
|
||||
itemCount: _relations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final relation = _relations[index];
|
||||
final other = relation.related;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.only(right: 24, left: 16),
|
||||
contentPadding:
|
||||
const EdgeInsets.only(right: 24, left: 16),
|
||||
leading: AccountImage(content: other?.avatar),
|
||||
title: Text(other?.nick ?? 'unknown'),
|
||||
subtitle: Text(other?.nick ?? 'unknown'),
|
||||
@ -286,12 +274,16 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: _isUpdating ? null : () => _changeRelation(relation, 2),
|
||||
onTap: _isUpdating
|
||||
? null
|
||||
: () => _changeRelation(relation, 2),
|
||||
child: Text('friendBlock').tr(),
|
||||
),
|
||||
const Gap(8),
|
||||
InkWell(
|
||||
onTap: _isUpdating ? null : () => _deleteRelation(relation),
|
||||
onTap: _isUpdating
|
||||
? null
|
||||
: () => _deleteRelation(relation),
|
||||
child: Text('friendDeleteAction').tr(),
|
||||
),
|
||||
],
|
||||
@ -361,10 +353,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
|
||||
try {
|
||||
final rel = context.read<SnRelationshipProvider>();
|
||||
await rel.updateRelationship(
|
||||
relation.relatedId,
|
||||
dstStatus,
|
||||
relation.permNodes,
|
||||
);
|
||||
relation.relatedId, dstStatus, relation.permNodes);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
@ -378,9 +367,8 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
|
||||
Future<void> _deleteRelation(SnRelationship relation) async {
|
||||
final confirm = await context.showConfirmDialog(
|
||||
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
|
||||
'friendDeleteDescription'.tr(args: [
|
||||
relation.related?.nick ?? 'unknown'.tr(),
|
||||
]),
|
||||
'friendDeleteDescription'
|
||||
.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
|
||||
);
|
||||
if (!confirm) return;
|
||||
if (!mounted) return;
|
||||
@ -420,7 +408,9 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(kFriendStatus[relation.status] ?? 'unknown').tr().opacity(0.75),
|
||||
Text(kFriendStatus[relation.status] ?? 'unknown')
|
||||
.tr()
|
||||
.opacity(0.75),
|
||||
if (relation.status == 0)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
@ -441,7 +431,8 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: _isBusy ? null : () => _changeRelation(relation, 1),
|
||||
onTap:
|
||||
_isBusy ? null : () => _changeRelation(relation, 1),
|
||||
child: Text('friendUnblock').tr(),
|
||||
),
|
||||
const Gap(8),
|
||||
|
@ -18,6 +18,7 @@ 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/captcha.dart';
|
||||
import 'package:surface/types/check_in.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/app_bar_leading.dart';
|
||||
@ -389,41 +390,50 @@ class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
|
||||
size: 20,
|
||||
),
|
||||
const Gap(10),
|
||||
Text('serviceStatusOperational').tr(),
|
||||
Text('loading').tr(),
|
||||
],
|
||||
)
|
||||
: switch (_serviceStatus) {
|
||||
ServiceStatus.operational => Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Symbols.check,
|
||||
size: 20,
|
||||
color: Colors.green[900],
|
||||
),
|
||||
const Gap(10),
|
||||
Text('serviceStatusOperational').tr(),
|
||||
Text('serviceStatusOperational')
|
||||
.tr()
|
||||
.textColor(Colors.green[900]),
|
||||
],
|
||||
),
|
||||
ServiceStatus.failed => Tooltip(
|
||||
message: 'serviceStatusFailedDescription'.tr(),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Symbols.dangerous,
|
||||
size: 20,
|
||||
color: Colors.red[900],
|
||||
),
|
||||
const Gap(10),
|
||||
Text('serviceStatusFailed').tr(),
|
||||
Text('serviceStatusFailed')
|
||||
.tr()
|
||||
.textColor(Colors.red[900]),
|
||||
],
|
||||
),
|
||||
),
|
||||
_ => Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Symbols.error,
|
||||
size: 20,
|
||||
color: Colors.orange[900],
|
||||
),
|
||||
const Gap(10),
|
||||
Text('serviceStatusDowngraded').tr(),
|
||||
Text('serviceStatusDowngraded')
|
||||
.tr()
|
||||
.textColor(Colors.orange[900]),
|
||||
],
|
||||
),
|
||||
},
|
||||
@ -434,6 +444,7 @@ class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
|
||||
padding: EdgeInsets.only(top: 6),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final entry in _statuses!.entries)
|
||||
Tooltip(
|
||||
@ -441,6 +452,8 @@ class _HomeDashServiceStatusState extends State<_HomeDashServiceStatus> {
|
||||
? 'serviceName${kServicesName[entry.key]}'.tr()
|
||||
: 'unknown'.tr(),
|
||||
child: Chip(
|
||||
visualDensity:
|
||||
VisualDensity(horizontal: -4, vertical: -4),
|
||||
avatar: entry.value
|
||||
? const Icon(
|
||||
Symbols.circle,
|
||||
@ -505,11 +518,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
|
||||
}
|
||||
|
||||
Future<void> _doCheckIn() async {
|
||||
final captchaTk = await Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CaptchaScreen(),
|
||||
),
|
||||
);
|
||||
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) {
|
||||
@ -793,7 +815,7 @@ class _HomeDashNotificationWidgetState
|
||||
child: IconButton(
|
||||
icon: const Icon(Symbols.arrow_right_alt),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).goNamed('notification');
|
||||
GoRouter.of(context).pushNamed('notification');
|
||||
},
|
||||
),
|
||||
),
|
||||
@ -877,8 +899,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(
|
||||
@ -896,6 +920,7 @@ class _HomeDashRecommendationPostWidgetState
|
||||
child: PostItem(
|
||||
data: _posts![index],
|
||||
showMenu: false,
|
||||
showFullPost: true,
|
||||
).padding(bottom: 8),
|
||||
onTap: () {
|
||||
GoRouter.of(context)
|
||||
|
@ -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,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
|
||||
if (!ua.isAuthorized) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(leading: AutoAppBarLeading(), title: Text('screenNotification').tr()),
|
||||
appBar: AppBar(
|
||||
leading: PageBackButton(),
|
||||
title: Text('screenNotification').tr(),
|
||||
),
|
||||
body: Center(child: UnauthorizedHint()),
|
||||
);
|
||||
}
|
||||
@ -153,7 +161,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 +177,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 +200,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 +222,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 +268,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);
|
||||
|
@ -12,7 +12,6 @@ import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_background.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/post/post_comment_list.dart';
|
||||
import 'package:surface/widgets/post/post_item.dart';
|
||||
@ -22,7 +21,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();
|
||||
@ -65,108 +65,111 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
|
||||
|
||||
final double maxWidth = _data?.type == 'video' ? double.infinity : 640;
|
||||
|
||||
return AppBackground(
|
||||
isRoot: widget.onBack != null,
|
||||
child: AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
if (widget.onBack != null) {
|
||||
widget.onBack!.call();
|
||||
}
|
||||
if (GoRouter.of(context).canPop()) {
|
||||
GoRouter.of(context).pop(context);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).replaceNamed('explore');
|
||||
},
|
||||
),
|
||||
title: _data?.body['title'] != null
|
||||
? RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(children: [
|
||||
TextSpan(
|
||||
text: _data?.body['title'] ?? 'postNoun'.tr(),
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: 'postDetail'.tr(),
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
),
|
||||
),
|
||||
]),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text('postDetail').tr(),
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
if (widget.onBack != null) {
|
||||
widget.onBack!.call();
|
||||
}
|
||||
if (GoRouter.of(context).canPop()) {
|
||||
GoRouter.of(context).pop(context);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).replaceNamed('explore');
|
||||
},
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: LoadingIndicator(isActive: _isBusy),
|
||||
),
|
||||
if (_data != null)
|
||||
SliverToBoxAdapter(
|
||||
child: PostItem(
|
||||
data: _data!,
|
||||
maxWidth: maxWidth,
|
||||
showComments: false,
|
||||
showFullPost: true,
|
||||
onChanged: (data) {
|
||||
setState(() => _data = data);
|
||||
},
|
||||
onDeleted: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_data != null && _data!.type != 'video') const SliverToBoxAdapter(child: Divider(height: 1)),
|
||||
if (_data != null && _data!.type != 'video')
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.comment, size: 24),
|
||||
const Gap(16),
|
||||
Text('postCommentsDetailed')
|
||||
.plural(_data!.metric.replyCount)
|
||||
.textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 12).center(),
|
||||
),
|
||||
),
|
||||
if (_data != null && ua.isAuthorized && _data!.type != 'video')
|
||||
SliverToBoxAdapter(
|
||||
child: PostCommentQuickAction(
|
||||
parentPost: _data!,
|
||||
maxWidth: maxWidth,
|
||||
onPosted: () {
|
||||
setState(() {
|
||||
_data = _data!.copyWith(
|
||||
metric: _data!.metric.copyWith(
|
||||
replyCount: _data!.metric.replyCount + 1,
|
||||
title: _data?.body['title'] != null
|
||||
? RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(children: [
|
||||
TextSpan(
|
||||
text: _data?.body['title'] ?? 'postNoun'.tr(),
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
),
|
||||
);
|
||||
});
|
||||
_childListKey.currentState!.refresh();
|
||||
},
|
||||
),
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: 'postDetail'.tr(),
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
),
|
||||
),
|
||||
]),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text('postDetail').tr(),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: LoadingIndicator(isActive: _isBusy),
|
||||
),
|
||||
if (_data != null)
|
||||
SliverToBoxAdapter(
|
||||
child: PostItem(
|
||||
data: _data!,
|
||||
maxWidth: maxWidth,
|
||||
showComments: false,
|
||||
showFullPost: true,
|
||||
onChanged: (data) {
|
||||
setState(() => _data = data);
|
||||
},
|
||||
onDeleted: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
if (_data != null && _data!.type != 'video')
|
||||
PostCommentSliverList(
|
||||
key: _childListKey,
|
||||
),
|
||||
if (_data != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Divider(height: 1).padding(top: 8),
|
||||
),
|
||||
if (_data != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.comment, size: 24),
|
||||
const Gap(16),
|
||||
Text('postCommentsDetailed')
|
||||
.plural(_data!.metric.replyCount)
|
||||
.textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 12).center(),
|
||||
),
|
||||
),
|
||||
if (_data != null && ua.isAuthorized)
|
||||
SliverToBoxAdapter(
|
||||
child: PostCommentQuickAction(
|
||||
parentPost: _data!,
|
||||
maxWidth: maxWidth,
|
||||
onPosted: () {
|
||||
setState(() {
|
||||
_data = _data!.copyWith(
|
||||
metric: _data!.metric.copyWith(
|
||||
replyCount: _data!.metric.replyCount + 1,
|
||||
),
|
||||
);
|
||||
});
|
||||
_childListKey.currentState!.refresh();
|
||||
},
|
||||
),
|
||||
if (_data != null && _data!.type == 'video') SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_data != null) SliverGap(8),
|
||||
if (_data != null)
|
||||
PostCommentSliverList(
|
||||
key: _childListKey,
|
||||
parentPost: _data!,
|
||||
maxWidth: maxWidth,
|
||||
),
|
||||
if (_data != null)
|
||||
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
},
|
||||
|
@ -28,11 +28,8 @@ class _PostShuffleScreenState extends State<PostShuffleScreen> {
|
||||
setState(() => _isBusy = true);
|
||||
try {
|
||||
final pt = context.read<SnPostContentProvider>();
|
||||
final result = await pt.listPosts(
|
||||
take: 10,
|
||||
offset: _posts.length,
|
||||
isShuffle: true,
|
||||
);
|
||||
final result =
|
||||
await pt.listPosts(take: 10, offset: _posts.length, isShuffle: true);
|
||||
_posts.addAll(result.$1);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
@ -57,19 +54,14 @@ class _PostShuffleScreenState extends State<PostShuffleScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('postShuffle').tr(),
|
||||
),
|
||||
appBar: AppBar(title: Text('postShuffle').tr()),
|
||||
body: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
if (_isBusy || _posts.isEmpty)
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
child: Center(child: CircularProgressIndicator()))
|
||||
else
|
||||
Expanded(
|
||||
child: CardSwiper(
|
||||
@ -81,17 +73,20 @@ class _PostShuffleScreenState extends State<PostShuffleScreen> {
|
||||
final ele = _posts[idx];
|
||||
return SingleChildScrollView(
|
||||
child: Center(
|
||||
child: OpenablePostItem(
|
||||
key: ValueKey(ele),
|
||||
data: ele,
|
||||
maxWidth: 640,
|
||||
onChanged: (ele) {
|
||||
_posts[idx] = ele;
|
||||
setState(() {});
|
||||
},
|
||||
onDeleted: () {
|
||||
_fetchPosts();
|
||||
},
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: OpenablePostItem(
|
||||
key: ValueKey(ele),
|
||||
data: ele,
|
||||
maxWidth: 640,
|
||||
onChanged: (ele) {
|
||||
_posts[idx] = ele;
|
||||
setState(() {});
|
||||
},
|
||||
onDeleted: () {
|
||||
_fetchPosts();
|
||||
},
|
||||
).padding(all: 8),
|
||||
).padding(
|
||||
all: 24,
|
||||
bottom:
|
||||
|
@ -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: 5, 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 =
|
||||
math.min((_appBarWidth * kBannerAspectRatio), 360).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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -160,6 +165,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
||||
type: switch (_tabController.index) {
|
||||
1 => 'story',
|
||||
2 => 'article',
|
||||
3 => 'question',
|
||||
4 => 'video',
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
@ -193,7 +200,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 +217,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);
|
||||
@ -276,6 +286,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
body: NestedScrollView(
|
||||
controller: _scrollController,
|
||||
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
|
||||
@ -299,7 +310,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 +321,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 +347,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 +365,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 +393,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 +411,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 +422,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 +472,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 +481,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 +492,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 +503,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 +539,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
AccountImage(content: _account?.avatar, radius: 8),
|
||||
AccountImage(
|
||||
content: _account?.avatar, radius: 8),
|
||||
],
|
||||
),
|
||||
],
|
||||
@ -533,6 +571,18 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(
|
||||
Symbols.help,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(
|
||||
Symbols.video_call,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverToBoxAdapter(child: const Divider(height: 1)),
|
||||
@ -606,7 +656,7 @@ class _PublisherPostList extends StatelessWidget {
|
||||
onDeleted: onDeleted,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Gap(8),
|
||||
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
149
lib/screens/realm/community.dart
Normal file
149
lib/screens/realm/community.dart
Normal file
@ -0,0 +1,149 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/post.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/screens/post/post_editor.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/post/post_item.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
class RealmCommunityScreen extends StatefulWidget {
|
||||
final String alias;
|
||||
const RealmCommunityScreen({super.key, required this.alias});
|
||||
|
||||
@override
|
||||
State<RealmCommunityScreen> createState() => _RealmCommunityScreenState();
|
||||
}
|
||||
|
||||
class _RealmCommunityScreenState extends State<RealmCommunityScreen> {
|
||||
SnRealm? _realm;
|
||||
|
||||
Future<void> _fetchRealm() async {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/realms/${widget.alias}');
|
||||
_realm = SnRealm.fromJson(resp.data);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
rethrow;
|
||||
} finally {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
bool _isBusy = false;
|
||||
int? _totalCount;
|
||||
final List<SnPost> _posts = List.empty(growable: true);
|
||||
|
||||
Future<void> _fetchPosts() async {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final pt = context.read<SnPostContentProvider>();
|
||||
final out = await pt.listPosts(
|
||||
take: 10,
|
||||
offset: _posts.length,
|
||||
realm: _realm?.id.toString(),
|
||||
);
|
||||
_totalCount = out.$2;
|
||||
_posts.addAll(out.$1);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchRealm();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_realm?.name ?? 'loading'.tr()),
|
||||
),
|
||||
floatingActionButton: _realm != null
|
||||
? FloatingActionButton(
|
||||
child: const Icon(Symbols.edit),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'postEditor',
|
||||
extra: PostEditorExtra(realm: _realm!),
|
||||
);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_realm == null)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator().center(),
|
||||
),
|
||||
),
|
||||
if (_realm != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('realmCommunity'.tr(args: [_realm!.name]))
|
||||
.fontSize(17)
|
||||
.padding(horizontal: 20, bottom: 4),
|
||||
Text('postTotalCount'.plural(_totalCount ?? 0))
|
||||
.fontSize(13)
|
||||
.opacity(0.8)
|
||||
.padding(horizontal: 20, bottom: 4),
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 16),
|
||||
const Divider(height: 1),
|
||||
if (_realm != null)
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _fetchPosts,
|
||||
child: InfiniteList(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
itemCount: _posts.length,
|
||||
isLoading: _isBusy,
|
||||
hasReachedMax:
|
||||
_totalCount != null && _posts.length >= _totalCount!,
|
||||
onFetchData: _fetchPosts,
|
||||
itemBuilder: (context, idx) {
|
||||
final post = _posts[idx];
|
||||
return OpenablePostItem(
|
||||
data: post,
|
||||
maxWidth: 640,
|
||||
onChanged: (data) {
|
||||
setState(() => _posts[idx] = data);
|
||||
},
|
||||
onDeleted: () {
|
||||
setState(() => _posts.removeAt(idx));
|
||||
},
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider().padding(vertical: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -318,7 +318,7 @@ class _RealmPostListWidgetState extends State<_RealmPostListWidget> {
|
||||
},
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Gap(8),
|
||||
separatorBuilder: (_, __) => const Divider().padding(vertical: 2),
|
||||
),
|
||||
),
|
||||
).padding(top: 8);
|
||||
|
@ -4,8 +4,10 @@ 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/channel.dart';
|
||||
import 'package:surface/providers/config.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/chat.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
@ -57,7 +59,9 @@ class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
|
||||
title: Text('screenRealmDiscovery').tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: _isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
|
||||
icon: _isCompactView
|
||||
? const Icon(Symbols.view_list)
|
||||
: const Icon(Symbols.view_module),
|
||||
onPressed: () {
|
||||
setState(() => _isCompactView = !_isCompactView);
|
||||
context.read<ConfigProvider>().realmCompactView = _isCompactView;
|
||||
@ -117,7 +121,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
|
||||
try {
|
||||
setState(() => _isBusy = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public');
|
||||
final resp =
|
||||
await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public');
|
||||
final out = List<SnChannel>.from(
|
||||
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
|
||||
);
|
||||
@ -135,10 +140,13 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
|
||||
setState(() => _isJoining = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final ua = context.read<UserProvider>();
|
||||
await sn.client.post('/cgi/id/realms/${widget.realm.alias}/members', data: {
|
||||
final rel = context.read<SnRealmProvider>();
|
||||
await sn.client
|
||||
.post('/cgi/id/realms/${widget.realm.alias}/members', data: {
|
||||
'related': ua.user?.name,
|
||||
});
|
||||
await _joinSelectedChannels();
|
||||
rel.addAvailableRealm(widget.realm);
|
||||
if (!mounted) return;
|
||||
context.showSnackbar('realmJoined'.tr(args: [widget.realm.name]));
|
||||
Navigator.pop(context);
|
||||
@ -156,13 +164,20 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final ua = context.read<UserProvider>();
|
||||
await sn.client.post('/cgi/im/channels/${widget.realm.alias}/$channel/members', data: {
|
||||
'related': ua.user?.name,
|
||||
});
|
||||
await sn.client.post(
|
||||
'/cgi/im/channels/${widget.realm.alias}/$channel/members',
|
||||
data: {
|
||||
'related': ua.user?.name,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
final ct = context.read<ChatChannelProvider>();
|
||||
for (final channel
|
||||
in _channels!.where((ele) => _planJoinChannels.contains(ele.alias))) {
|
||||
ct.addAvailableChannel(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,7 +197,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
|
||||
children: [
|
||||
const Icon(Symbols.group_add, size: 24),
|
||||
const Gap(16),
|
||||
Text('realmJoin', style: Theme.of(context).textTheme.titleLarge).tr(),
|
||||
Text('realmJoin', style: Theme.of(context).textTheme.titleLarge)
|
||||
.tr(),
|
||||
],
|
||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||
Row(
|
||||
@ -216,7 +232,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium)
|
||||
child: Text('realmCommunityPublicChannelsHint'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium)
|
||||
.padding(horizontal: 24, vertical: 8),
|
||||
),
|
||||
Expanded(
|
||||
|
@ -80,6 +80,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final dt = context.read<DatabaseProvider>();
|
||||
final cfg = context.watch<ConfigProvider>();
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
@ -323,19 +326,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
secondary: const Icon(Symbols.left_panel_close),
|
||||
title: Text('settingsDrawerPreferCollapse').tr(),
|
||||
subtitle:
|
||||
Text('settingsDrawerPreferCollapseDescription').tr(),
|
||||
secondary: const Icon(Symbols.hide),
|
||||
title: Text('settingsHideBottomNav').tr(),
|
||||
subtitle: Text('settingsHideBottomNavDescription').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
value: _prefs.getBool(kAppDrawerPreferCollapse) ?? false,
|
||||
value: _prefs.getBool(kAppHideBottomNav) ?? false,
|
||||
onChanged: (value) {
|
||||
_prefs.setBool(kAppDrawerPreferCollapse, value ?? false);
|
||||
_prefs.setBool(kAppHideBottomNav, value ?? false);
|
||||
final cfg = context.read<ConfigProvider>();
|
||||
cfg.calcDrawerSize(context);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: cfg.soundEffects,
|
||||
onChanged: (value) {
|
||||
cfg.soundEffects = value ?? false;
|
||||
setState(() {});
|
||||
},
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
title: Text('settingsSoundEffects').tr(),
|
||||
subtitle: Text('settingsSoundEffectsDescription').tr(),
|
||||
secondary: const Icon(Symbols.sound_sampler),
|
||||
),
|
||||
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS))
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.window),
|
||||
title: Text('settingsResetMemorizedWindowSize').tr(),
|
||||
subtitle:
|
||||
Text('settingsResetMemorizedWindowSizeDescription')
|
||||
.tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 24),
|
||||
onTap: () {
|
||||
final prefs = context.read<ConfigProvider>().prefs;
|
||||
prefs.remove(kAppWindowSize);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.font_download),
|
||||
title: Text('settingsCustomFonts').tr(),
|
||||
@ -728,6 +755,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
GoRouter.of(context).pushNamed('about');
|
||||
},
|
||||
),
|
||||
if (now.day == 1 && now.month == 4)
|
||||
CheckboxListTile(
|
||||
title: Text('settingsAprilFoolFeatures').tr(),
|
||||
subtitle: Text('settingsAprilFoolFeaturesDescription').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
secondary: const Icon(Symbols.new_releases),
|
||||
value: cfg.aprilFoolFeatures,
|
||||
onChanged: (value) {
|
||||
cfg.aprilFoolFeatures = value ?? false;
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
|
@ -50,6 +50,7 @@ class _AppSharingListenerState extends State<AppSharingListener> {
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(width: double.infinity),
|
||||
ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 24),
|
||||
|
@ -9,7 +9,6 @@ import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/sn_sticker.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/widgets/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_item.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
@ -134,7 +133,7 @@ class _StickerScreenState extends State<StickerScreen>
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
leading: PageBackButton(),
|
||||
title: Text('screenStickers').tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
|
@ -45,10 +45,9 @@ class _WalletScreenState extends State<WalletScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(
|
||||
leading: PageBackButton(),
|
||||
title: Text('screenAccountWallet').tr(),
|
||||
),
|
||||
leading: PageBackButton(), title: Text('screenAccountWallet').tr()),
|
||||
body: Column(
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
@ -66,25 +65,36 @@ class _WalletScreenState extends State<WalletScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
child: Icon(Symbols.wallet, size: 28),
|
||||
),
|
||||
const Gap(12),
|
||||
SizedBox(width: double.infinity),
|
||||
Text(
|
||||
NumberFormat.compactCurrency(
|
||||
locale: EasyLocalization.of(context)!.currentLocale.toString(),
|
||||
locale: EasyLocalization.of(context)!
|
||||
.currentLocale
|
||||
.toString(),
|
||||
symbol: '${'walletCurrencyShort'.tr()} ',
|
||||
decimalDigits: 2,
|
||||
).format(double.parse(_wallet!.balance)),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Text('walletCurrency'.plural(double.parse(_wallet!.balance))),
|
||||
const Gap(16),
|
||||
Text(
|
||||
NumberFormat.compactCurrency(
|
||||
locale: EasyLocalization.of(context)!
|
||||
.currentLocale
|
||||
.toString(),
|
||||
symbol: '${'walletCurrencyGoldenShort'.tr()} ',
|
||||
decimalDigits: 2,
|
||||
).format(double.parse(_wallet!.goldenBalance)),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Text('walletCurrencyGolden'
|
||||
.plural(double.parse(_wallet!.goldenBalance))),
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 24),
|
||||
).padding(horizontal: 8, top: 16, bottom: 4),
|
||||
if (_wallet != null) Expanded(child: _WalletTransactionList(myself: _wallet!)),
|
||||
if (_wallet != null)
|
||||
Expanded(child: _WalletTransactionList(myself: _wallet!)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -109,14 +119,15 @@ class _WalletTransactionListState extends State<_WalletTransactionList> {
|
||||
try {
|
||||
setState(() => _isBusy = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/wa/transactions/me', queryParameters: {
|
||||
'take': 10,
|
||||
'offset': _transactions.length,
|
||||
});
|
||||
_totalCount = resp.data['count'];
|
||||
_transactions.addAll(
|
||||
resp.data['data']?.map((e) => SnTransaction.fromJson(e)).cast<SnTransaction>() ?? [],
|
||||
final resp = await sn.client.get(
|
||||
'/cgi/wa/transactions/me',
|
||||
queryParameters: {'take': 10, 'offset': _transactions.length},
|
||||
);
|
||||
_totalCount = resp.data['count'];
|
||||
_transactions.addAll(resp.data['data']
|
||||
?.map((e) => SnTransaction.fromJson(e))
|
||||
.cast<SnTransaction>() ??
|
||||
[]);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@ -141,7 +152,8 @@ class _WalletTransactionListState extends State<_WalletTransactionList> {
|
||||
child: InfiniteList(
|
||||
itemCount: _transactions.length,
|
||||
isLoading: _isBusy,
|
||||
hasReachedMax: _totalCount != null && _transactions.length >= _totalCount!,
|
||||
hasReachedMax:
|
||||
_totalCount != null && _transactions.length >= _totalCount!,
|
||||
onFetchData: () {
|
||||
_fetchTransactions();
|
||||
},
|
||||
@ -149,7 +161,9 @@ class _WalletTransactionListState extends State<_WalletTransactionList> {
|
||||
final ele = _transactions[idx];
|
||||
final isIncoming = ele.payeeId == widget.myself.id;
|
||||
return ListTile(
|
||||
leading: isIncoming ? const Icon(Symbols.call_received) : const Icon(Symbols.call_made),
|
||||
leading: isIncoming
|
||||
? const Icon(Symbols.call_received)
|
||||
: const Icon(Symbols.call_made),
|
||||
title: Text(
|
||||
'${isIncoming ? '+' : '-'}${ele.amount} ${'walletCurrencyShort'.tr()}',
|
||||
style: TextStyle(color: isIncoming ? Colors.green : Colors.red),
|
||||
@ -159,12 +173,26 @@ class _WalletTransactionListState extends State<_WalletTransactionList> {
|
||||
children: [
|
||||
Text(ele.remark),
|
||||
const Gap(2),
|
||||
Text(
|
||||
DateFormat(
|
||||
null,
|
||||
EasyLocalization.of(context)!.currentLocale.toString(),
|
||||
).format(ele.createdAt),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'walletTransactionType${ele.currency.capitalize()}'
|
||||
.tr(),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
Text(' · ')
|
||||
.textStyle(Theme.of(context).textTheme.labelSmall!)
|
||||
.padding(right: 4),
|
||||
Text(
|
||||
DateFormat(
|
||||
null,
|
||||
EasyLocalization.of(context)!
|
||||
.currentLocale
|
||||
.toString())
|
||||
.format(ele.createdAt),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -205,17 +233,14 @@ class _CreateWalletWidgetState extends State<_CreateWalletWidget> {
|
||||
autofocus: true,
|
||||
obscureText: true,
|
||||
controller: passwordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPassword'.tr(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: 'fieldPassword'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text('cancel').tr(),
|
||||
),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text('cancel').tr()),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop(passwordController.text);
|
||||
@ -234,9 +259,7 @@ class _CreateWalletWidgetState extends State<_CreateWalletWidget> {
|
||||
try {
|
||||
setState(() => _isBusy = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.post('/cgi/wa/wallets/me', data: {
|
||||
'password': password,
|
||||
});
|
||||
await sn.client.post('/cgi/wa/wallets/me', data: {'password': password});
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@ -255,20 +278,20 @@ class _CreateWalletWidgetState extends State<_CreateWalletWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
child: Icon(Symbols.add, size: 28),
|
||||
),
|
||||
CircleAvatar(radius: 28, child: Icon(Symbols.add, size: 28)),
|
||||
const Gap(12),
|
||||
Text('walletCreate', style: Theme.of(context).textTheme.titleLarge).tr(),
|
||||
Text('walletCreateSubtitle', style: Theme.of(context).textTheme.bodyMedium).tr(),
|
||||
Text('walletCreate',
|
||||
style: Theme.of(context).textTheme.titleLarge)
|
||||
.tr(),
|
||||
Text('walletCreateSubtitle',
|
||||
style: Theme.of(context).textTheme.bodyMedium)
|
||||
.tr(),
|
||||
const Gap(8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: _isBusy ? null : () => _createWallet(),
|
||||
child: Text('next').tr(),
|
||||
),
|
||||
onPressed: _isBusy ? null : () => _createWallet(),
|
||||
child: Text('next').tr()),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 24),
|
||||
|
@ -88,6 +88,8 @@ Future<ThemeData> createAppTheme(
|
||||
TargetPlatform.windows: ZoomPageTransitionsBuilder(),
|
||||
},
|
||||
),
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(year2023: false),
|
||||
sliderTheme: SliderThemeData(year2023: false),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -184,3 +184,63 @@ abstract class SnActionEvent with _$SnActionEvent {
|
||||
factory SnActionEvent.fromJson(Map<String, Object?> json) =>
|
||||
_$SnActionEventFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SnProgram with _$SnProgram {
|
||||
const factory SnProgram({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required String name,
|
||||
required String description,
|
||||
required String alias,
|
||||
required int expRequirement,
|
||||
required Map<String, dynamic> price,
|
||||
required Map<String, dynamic> badge,
|
||||
required Map<String, dynamic> group,
|
||||
required Map<String, dynamic> appearance,
|
||||
}) = _SnProgram;
|
||||
|
||||
factory SnProgram.fromJson(Map<String, Object?> json) =>
|
||||
_$SnProgramFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SnProgramMember with _$SnProgramMember {
|
||||
const factory SnProgramMember({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required DateTime lastPaid,
|
||||
required SnAccount account,
|
||||
required int accountId,
|
||||
required SnProgram program,
|
||||
required int programId,
|
||||
}) = _SnProgramMember;
|
||||
|
||||
factory SnProgramMember.fromJson(Map<String, Object?> json) =>
|
||||
_$SnProgramMemberFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SnPunishment with _$SnPunishment {
|
||||
const factory SnPunishment({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required String reason,
|
||||
required int type,
|
||||
@Default({}) Map<String, dynamic> permNodes,
|
||||
required DateTime? expiredAt,
|
||||
required SnAccount? account,
|
||||
required int? accountId,
|
||||
required SnAccount? moderator,
|
||||
required int? moderatorId,
|
||||
}) = _SnPunishment;
|
||||
|
||||
factory SnPunishment.fromJson(Map<String, Object?> json) =>
|
||||
_$SnPunishmentFromJson(json);
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -319,3 +319,104 @@ Map<String, dynamic> _$SnActionEventToJson(_SnActionEvent instance) =>
|
||||
'account': instance.account.toJson(),
|
||||
'account_id': instance.accountId,
|
||||
};
|
||||
|
||||
_SnProgram _$SnProgramFromJson(Map<String, dynamic> json) => _SnProgram(
|
||||
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),
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
alias: json['alias'] as String,
|
||||
expRequirement: (json['exp_requirement'] as num).toInt(),
|
||||
price: json['price'] as Map<String, dynamic>,
|
||||
badge: json['badge'] as Map<String, dynamic>,
|
||||
group: json['group'] as Map<String, dynamic>,
|
||||
appearance: json['appearance'] as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnProgramToJson(_SnProgram instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'alias': instance.alias,
|
||||
'exp_requirement': instance.expRequirement,
|
||||
'price': instance.price,
|
||||
'badge': instance.badge,
|
||||
'group': instance.group,
|
||||
'appearance': instance.appearance,
|
||||
};
|
||||
|
||||
_SnProgramMember _$SnProgramMemberFromJson(Map<String, dynamic> json) =>
|
||||
_SnProgramMember(
|
||||
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),
|
||||
lastPaid: DateTime.parse(json['last_paid'] as String),
|
||||
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
program: SnProgram.fromJson(json['program'] as Map<String, dynamic>),
|
||||
programId: (json['program_id'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnProgramMemberToJson(_SnProgramMember instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'last_paid': instance.lastPaid.toIso8601String(),
|
||||
'account': instance.account.toJson(),
|
||||
'account_id': instance.accountId,
|
||||
'program': instance.program.toJson(),
|
||||
'program_id': instance.programId,
|
||||
};
|
||||
|
||||
_SnPunishment _$SnPunishmentFromJson(Map<String, dynamic> json) =>
|
||||
_SnPunishment(
|
||||
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),
|
||||
reason: json['reason'] as String,
|
||||
type: (json['type'] as num).toInt(),
|
||||
permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {},
|
||||
expiredAt: json['expired_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['expired_at'] as String),
|
||||
account: json['account'] == null
|
||||
? null
|
||||
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
|
||||
accountId: (json['account_id'] as num?)?.toInt(),
|
||||
moderator: json['moderator'] == null
|
||||
? null
|
||||
: SnAccount.fromJson(json['moderator'] as Map<String, dynamic>),
|
||||
moderatorId: (json['moderator_id'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnPunishmentToJson(_SnPunishment instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'reason': instance.reason,
|
||||
'type': instance.type,
|
||||
'perm_nodes': instance.permNodes,
|
||||
'expired_at': instance.expiredAt?.toIso8601String(),
|
||||
'account': instance.account?.toJson(),
|
||||
'account_id': instance.accountId,
|
||||
'moderator': instance.moderator?.toJson(),
|
||||
'moderator_id': instance.moderatorId,
|
||||
};
|
||||
|
@ -11,6 +11,7 @@ abstract class SnWallet with _$SnWallet {
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required String balance,
|
||||
required String goldenBalance,
|
||||
required String password,
|
||||
required int accountId,
|
||||
}) = _SnWallet;
|
||||
@ -27,6 +28,7 @@ abstract class SnTransaction with _$SnTransaction {
|
||||
required DateTime? deletedAt,
|
||||
required String remark,
|
||||
required String amount,
|
||||
required String currency,
|
||||
required SnWallet? payer,
|
||||
required SnWallet? payee,
|
||||
required int? payerId,
|
||||
|
@ -20,6 +20,7 @@ mixin _$SnWallet {
|
||||
DateTime get updatedAt;
|
||||
DateTime? get deletedAt;
|
||||
String get balance;
|
||||
String get goldenBalance;
|
||||
String get password;
|
||||
int get accountId;
|
||||
|
||||
@ -46,6 +47,8 @@ mixin _$SnWallet {
|
||||
(identical(other.deletedAt, deletedAt) ||
|
||||
other.deletedAt == deletedAt) &&
|
||||
(identical(other.balance, balance) || other.balance == balance) &&
|
||||
(identical(other.goldenBalance, goldenBalance) ||
|
||||
other.goldenBalance == goldenBalance) &&
|
||||
(identical(other.password, password) ||
|
||||
other.password == password) &&
|
||||
(identical(other.accountId, accountId) ||
|
||||
@ -55,11 +58,11 @@ mixin _$SnWallet {
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
|
||||
deletedAt, balance, password, accountId);
|
||||
deletedAt, balance, goldenBalance, password, accountId);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnWallet(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, balance: $balance, password: $password, accountId: $accountId)';
|
||||
return 'SnWallet(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, balance: $balance, goldenBalance: $goldenBalance, password: $password, accountId: $accountId)';
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,6 +77,7 @@ abstract mixin class $SnWalletCopyWith<$Res> {
|
||||
DateTime updatedAt,
|
||||
DateTime? deletedAt,
|
||||
String balance,
|
||||
String goldenBalance,
|
||||
String password,
|
||||
int accountId});
|
||||
}
|
||||
@ -95,6 +99,7 @@ class _$SnWalletCopyWithImpl<$Res> implements $SnWalletCopyWith<$Res> {
|
||||
Object? updatedAt = null,
|
||||
Object? deletedAt = freezed,
|
||||
Object? balance = null,
|
||||
Object? goldenBalance = null,
|
||||
Object? password = null,
|
||||
Object? accountId = null,
|
||||
}) {
|
||||
@ -119,6 +124,10 @@ class _$SnWalletCopyWithImpl<$Res> implements $SnWalletCopyWith<$Res> {
|
||||
? _self.balance
|
||||
: balance // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
goldenBalance: null == goldenBalance
|
||||
? _self.goldenBalance
|
||||
: goldenBalance // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
password: null == password
|
||||
? _self.password
|
||||
: password // ignore: cast_nullable_to_non_nullable
|
||||
@ -140,6 +149,7 @@ class _SnWallet implements SnWallet {
|
||||
required this.updatedAt,
|
||||
required this.deletedAt,
|
||||
required this.balance,
|
||||
required this.goldenBalance,
|
||||
required this.password,
|
||||
required this.accountId});
|
||||
factory _SnWallet.fromJson(Map<String, dynamic> json) =>
|
||||
@ -156,6 +166,8 @@ class _SnWallet implements SnWallet {
|
||||
@override
|
||||
final String balance;
|
||||
@override
|
||||
final String goldenBalance;
|
||||
@override
|
||||
final String password;
|
||||
@override
|
||||
final int accountId;
|
||||
@ -188,6 +200,8 @@ class _SnWallet implements SnWallet {
|
||||
(identical(other.deletedAt, deletedAt) ||
|
||||
other.deletedAt == deletedAt) &&
|
||||
(identical(other.balance, balance) || other.balance == balance) &&
|
||||
(identical(other.goldenBalance, goldenBalance) ||
|
||||
other.goldenBalance == goldenBalance) &&
|
||||
(identical(other.password, password) ||
|
||||
other.password == password) &&
|
||||
(identical(other.accountId, accountId) ||
|
||||
@ -197,11 +211,11 @@ class _SnWallet implements SnWallet {
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
|
||||
deletedAt, balance, password, accountId);
|
||||
deletedAt, balance, goldenBalance, password, accountId);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnWallet(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, balance: $balance, password: $password, accountId: $accountId)';
|
||||
return 'SnWallet(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, balance: $balance, goldenBalance: $goldenBalance, password: $password, accountId: $accountId)';
|
||||
}
|
||||
}
|
||||
|
||||
@ -218,6 +232,7 @@ abstract mixin class _$SnWalletCopyWith<$Res>
|
||||
DateTime updatedAt,
|
||||
DateTime? deletedAt,
|
||||
String balance,
|
||||
String goldenBalance,
|
||||
String password,
|
||||
int accountId});
|
||||
}
|
||||
@ -239,6 +254,7 @@ class __$SnWalletCopyWithImpl<$Res> implements _$SnWalletCopyWith<$Res> {
|
||||
Object? updatedAt = null,
|
||||
Object? deletedAt = freezed,
|
||||
Object? balance = null,
|
||||
Object? goldenBalance = null,
|
||||
Object? password = null,
|
||||
Object? accountId = null,
|
||||
}) {
|
||||
@ -263,6 +279,10 @@ class __$SnWalletCopyWithImpl<$Res> implements _$SnWalletCopyWith<$Res> {
|
||||
? _self.balance
|
||||
: balance // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
goldenBalance: null == goldenBalance
|
||||
? _self.goldenBalance
|
||||
: goldenBalance // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
password: null == password
|
||||
? _self.password
|
||||
: password // ignore: cast_nullable_to_non_nullable
|
||||
@ -283,6 +303,7 @@ mixin _$SnTransaction {
|
||||
DateTime? get deletedAt;
|
||||
String get remark;
|
||||
String get amount;
|
||||
String get currency;
|
||||
SnWallet? get payer;
|
||||
SnWallet? get payee;
|
||||
int? get payerId;
|
||||
@ -313,6 +334,8 @@ mixin _$SnTransaction {
|
||||
other.deletedAt == deletedAt) &&
|
||||
(identical(other.remark, remark) || other.remark == remark) &&
|
||||
(identical(other.amount, amount) || other.amount == amount) &&
|
||||
(identical(other.currency, currency) ||
|
||||
other.currency == currency) &&
|
||||
(identical(other.payer, payer) || other.payer == payer) &&
|
||||
(identical(other.payee, payee) || other.payee == payee) &&
|
||||
(identical(other.payerId, payerId) || other.payerId == payerId) &&
|
||||
@ -322,11 +345,11 @@ mixin _$SnTransaction {
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
|
||||
deletedAt, remark, amount, payer, payee, payerId, payeeId);
|
||||
deletedAt, remark, amount, currency, payer, payee, payerId, payeeId);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnTransaction(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, remark: $remark, amount: $amount, payer: $payer, payee: $payee, payerId: $payerId, payeeId: $payeeId)';
|
||||
return 'SnTransaction(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, remark: $remark, amount: $amount, currency: $currency, payer: $payer, payee: $payee, payerId: $payerId, payeeId: $payeeId)';
|
||||
}
|
||||
}
|
||||
|
||||
@ -343,6 +366,7 @@ abstract mixin class $SnTransactionCopyWith<$Res> {
|
||||
DateTime? deletedAt,
|
||||
String remark,
|
||||
String amount,
|
||||
String currency,
|
||||
SnWallet? payer,
|
||||
SnWallet? payee,
|
||||
int? payerId,
|
||||
@ -371,6 +395,7 @@ class _$SnTransactionCopyWithImpl<$Res>
|
||||
Object? deletedAt = freezed,
|
||||
Object? remark = null,
|
||||
Object? amount = null,
|
||||
Object? currency = null,
|
||||
Object? payer = freezed,
|
||||
Object? payee = freezed,
|
||||
Object? payerId = freezed,
|
||||
@ -401,6 +426,10 @@ class _$SnTransactionCopyWithImpl<$Res>
|
||||
? _self.amount
|
||||
: amount // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
currency: null == currency
|
||||
? _self.currency
|
||||
: currency // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
payer: freezed == payer
|
||||
? _self.payer
|
||||
: payer // ignore: cast_nullable_to_non_nullable
|
||||
@ -459,6 +488,7 @@ class _SnTransaction implements SnTransaction {
|
||||
required this.deletedAt,
|
||||
required this.remark,
|
||||
required this.amount,
|
||||
required this.currency,
|
||||
required this.payer,
|
||||
required this.payee,
|
||||
required this.payerId,
|
||||
@ -479,6 +509,8 @@ class _SnTransaction implements SnTransaction {
|
||||
@override
|
||||
final String amount;
|
||||
@override
|
||||
final String currency;
|
||||
@override
|
||||
final SnWallet? payer;
|
||||
@override
|
||||
final SnWallet? payee;
|
||||
@ -516,6 +548,8 @@ class _SnTransaction implements SnTransaction {
|
||||
other.deletedAt == deletedAt) &&
|
||||
(identical(other.remark, remark) || other.remark == remark) &&
|
||||
(identical(other.amount, amount) || other.amount == amount) &&
|
||||
(identical(other.currency, currency) ||
|
||||
other.currency == currency) &&
|
||||
(identical(other.payer, payer) || other.payer == payer) &&
|
||||
(identical(other.payee, payee) || other.payee == payee) &&
|
||||
(identical(other.payerId, payerId) || other.payerId == payerId) &&
|
||||
@ -525,11 +559,11 @@ class _SnTransaction implements SnTransaction {
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
|
||||
deletedAt, remark, amount, payer, payee, payerId, payeeId);
|
||||
deletedAt, remark, amount, currency, payer, payee, payerId, payeeId);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnTransaction(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, remark: $remark, amount: $amount, payer: $payer, payee: $payee, payerId: $payerId, payeeId: $payeeId)';
|
||||
return 'SnTransaction(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, remark: $remark, amount: $amount, currency: $currency, payer: $payer, payee: $payee, payerId: $payerId, payeeId: $payeeId)';
|
||||
}
|
||||
}
|
||||
|
||||
@ -548,6 +582,7 @@ abstract mixin class _$SnTransactionCopyWith<$Res>
|
||||
DateTime? deletedAt,
|
||||
String remark,
|
||||
String amount,
|
||||
String currency,
|
||||
SnWallet? payer,
|
||||
SnWallet? payee,
|
||||
int? payerId,
|
||||
@ -578,6 +613,7 @@ class __$SnTransactionCopyWithImpl<$Res>
|
||||
Object? deletedAt = freezed,
|
||||
Object? remark = null,
|
||||
Object? amount = null,
|
||||
Object? currency = null,
|
||||
Object? payer = freezed,
|
||||
Object? payee = freezed,
|
||||
Object? payerId = freezed,
|
||||
@ -608,6 +644,10 @@ class __$SnTransactionCopyWithImpl<$Res>
|
||||
? _self.amount
|
||||
: amount // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
currency: null == currency
|
||||
? _self.currency
|
||||
: currency // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
payer: freezed == payer
|
||||
? _self.payer
|
||||
: payer // ignore: cast_nullable_to_non_nullable
|
||||
|
@ -14,6 +14,7 @@ _SnWallet _$SnWalletFromJson(Map<String, dynamic> json) => _SnWallet(
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
balance: json['balance'] as String,
|
||||
goldenBalance: json['golden_balance'] as String,
|
||||
password: json['password'] as String,
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
);
|
||||
@ -24,6 +25,7 @@ Map<String, dynamic> _$SnWalletToJson(_SnWallet instance) => <String, dynamic>{
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'balance': instance.balance,
|
||||
'golden_balance': instance.goldenBalance,
|
||||
'password': instance.password,
|
||||
'account_id': instance.accountId,
|
||||
};
|
||||
@ -38,6 +40,7 @@ _SnTransaction _$SnTransactionFromJson(Map<String, dynamic> json) =>
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
remark: json['remark'] as String,
|
||||
amount: json['amount'] as String,
|
||||
currency: json['currency'] as String,
|
||||
payer: json['payer'] == null
|
||||
? null
|
||||
: SnWallet.fromJson(json['payer'] as Map<String, dynamic>),
|
||||
@ -56,6 +59,7 @@ Map<String, dynamic> _$SnTransactionToJson(_SnTransaction instance) =>
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'remark': instance.remark,
|
||||
'amount': instance.amount,
|
||||
'currency': instance.currency,
|
||||
'payer': instance.payer?.toJson(),
|
||||
'payee': instance.payee?.toJson(),
|
||||
'payer_id': instance.payerId,
|
||||
|
@ -5,6 +5,7 @@ import 'dart:math' as math;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@ -12,6 +13,7 @@ import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/logger.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
@ -222,20 +224,71 @@ class _AttachmentItemContentVideoState
|
||||
: sn.getAttachmentUrl(widget.data.compressed!.rid);
|
||||
_videoPlayer = Player();
|
||||
_videoController = VideoController(_videoPlayer!);
|
||||
_videoPlayer!.open(Media(url), play: !widget.isAutoload);
|
||||
|
||||
String? uri;
|
||||
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
|
||||
if (inCacheInfo == null) {
|
||||
logging.info('[MediaPlayer] Miss cache: $url');
|
||||
final fileStream = DefaultCacheManager().getFileStream(
|
||||
url,
|
||||
withProgress: true,
|
||||
);
|
||||
await for (var fileInfo in fileStream) {
|
||||
if (fileInfo is FileInfo) {
|
||||
uri = fileInfo.file.path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uri = inCacheInfo.file.path;
|
||||
logging.info('[MediaPlayer] Hit cache: $url');
|
||||
}
|
||||
if (uri == null) {
|
||||
if (mounted) {
|
||||
context.showErrorDialog('attachmentFailedToLoadMedia'.tr());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_videoPlayer!.open(Media(uri), play: !widget.isAutoload);
|
||||
}
|
||||
|
||||
void _toggleOriginal() {
|
||||
void _toggleOriginal() async {
|
||||
if (!mounted) return;
|
||||
if (widget.data.compressedId == null) return;
|
||||
setState(() => _showOriginal = !_showOriginal);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final url = _showOriginal
|
||||
? sn.getAttachmentUrl(widget.data.rid)
|
||||
: sn.getAttachmentUrl(widget.data.compressed!.rid);
|
||||
|
||||
String? uri;
|
||||
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
|
||||
if (inCacheInfo == null) {
|
||||
logging.info('[MediaPlayer] Miss cache: $url');
|
||||
final fileStream = DefaultCacheManager().getFileStream(
|
||||
url,
|
||||
withProgress: true,
|
||||
);
|
||||
await for (var fileInfo in fileStream) {
|
||||
if (fileInfo is FileInfo) {
|
||||
uri = fileInfo.file.path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uri = inCacheInfo.file.path;
|
||||
logging.info('[MediaPlayer] Hit cache: $url');
|
||||
}
|
||||
if (uri == null) {
|
||||
if (mounted) {
|
||||
context.showErrorDialog('attachmentFailedToLoadMedia'.tr());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_videoPlayer?.open(
|
||||
Media(
|
||||
_showOriginal
|
||||
? sn.getAttachmentUrl(widget.data.rid)
|
||||
: sn.getAttachmentUrl(widget.data.compressed!.rid),
|
||||
),
|
||||
Media(uri),
|
||||
play: true,
|
||||
);
|
||||
}
|
||||
@ -439,7 +492,33 @@ class _AttachmentItemContentAudioState
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final url = sn.getAttachmentUrl(widget.data.rid);
|
||||
_audioPlayer = Player();
|
||||
await _audioPlayer!.open(Media(url), play: !widget.isAutoload);
|
||||
|
||||
String? uri;
|
||||
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
|
||||
if (inCacheInfo == null) {
|
||||
logging.info('[MediaPlayer] Miss cache: $url');
|
||||
final fileStream = DefaultCacheManager().getFileStream(
|
||||
url,
|
||||
withProgress: true,
|
||||
);
|
||||
await for (var fileInfo in fileStream) {
|
||||
if (fileInfo is FileInfo) {
|
||||
uri = fileInfo.file.path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uri = inCacheInfo.file.path;
|
||||
logging.info('[MediaPlayer] Hit cache: $url');
|
||||
}
|
||||
if (uri == null) {
|
||||
if (mounted) {
|
||||
context.showErrorDialog('attachmentFailedToLoadMedia'.tr());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await _audioPlayer!.open(Media(uri), play: !widget.isAutoload);
|
||||
_audioPlayer!.stream.playing.listen((v) => setState(() => _isPlaying = v));
|
||||
_audioPlayer!.stream.position.listen((v) => setState(() => _position = v));
|
||||
_audioPlayer!.stream.duration.listen((v) => setState(() => _duration = v));
|
||||
@ -567,6 +646,7 @@ class _AttachmentItemContentAudioState
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
@ -224,8 +224,10 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
(widget.data[idx]?.data['ratio'] ?? 1).toDouble(),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.data[idx]?.mediaType != SnMediaType.image)
|
||||
if (widget.data[idx]?.mediaType !=
|
||||
SnMediaType.image) {
|
||||
return;
|
||||
}
|
||||
context.pushTransparentRoute(
|
||||
AttachmentZoomView(
|
||||
data: widget.data
|
||||
@ -246,8 +248,10 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border:
|
||||
Border(top: borderSide, bottom: borderSide),
|
||||
border: Border.all(
|
||||
width: 1,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
),
|
||||
child: ClipRRect(
|
||||
@ -263,8 +267,8 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: Chip(
|
||||
label:
|
||||
Text('${idx + 1}/${widget.data.length}')),
|
||||
label: Text('${idx + 1}/${widget.data.length}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -65,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(
|
||||
|
@ -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(', '),
|
||||
]),
|
||||
),
|
||||
|
@ -16,7 +16,7 @@ class ConnectionIndicator extends StatelessWidget {
|
||||
final ws = context.watch<WebSocketProvider>();
|
||||
final cfg = context.watch<ConfigProvider>();
|
||||
|
||||
final marginLeft = cfg.drawerIsCollapsed ? 0.0 : cfg.drawerIsExpanded ? 304.0 : 80.0;
|
||||
final marginLeft = cfg.drawerIsCollapsed ? 0.0 : 80.0;
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: ws,
|
||||
@ -30,7 +30,8 @@ class ConnectionIndicator extends StatelessWidget {
|
||||
child: GestureDetector(
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16))),
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
child: ua.isAuthorized
|
||||
? Row(
|
||||
@ -39,16 +40,29 @@ class ConnectionIndicator extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (ws.isBusy)
|
||||
Text('serverConnecting').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer)
|
||||
Text(
|
||||
'serverConnecting',
|
||||
).tr().textColor(Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondaryContainer)
|
||||
else if (!ws.isConnected)
|
||||
Text('serverDisconnected')
|
||||
.tr()
|
||||
.textColor(Theme.of(context).colorScheme.onSecondaryContainer)
|
||||
Text(
|
||||
'serverDisconnected',
|
||||
).tr().textColor(Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondaryContainer)
|
||||
else
|
||||
Text('serverConnected').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer),
|
||||
Text(
|
||||
'serverConnected',
|
||||
).tr().textColor(Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondaryContainer),
|
||||
const Gap(8),
|
||||
if (ws.isBusy)
|
||||
const CircularProgressIndicator(strokeWidth: 2.5)
|
||||
const CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
padding: EdgeInsets.zero,
|
||||
)
|
||||
.width(12)
|
||||
.height(12)
|
||||
.padding(horizontal: 4, right: 4)
|
||||
@ -59,10 +73,9 @@ class ConnectionIndicator extends StatelessWidget {
|
||||
],
|
||||
).padding(horizontal: 8, vertical: 4)
|
||||
: const SizedBox.shrink(),
|
||||
).opacity(show ? 1 : 0, animate: true).animate(
|
||||
const Duration(milliseconds: 300),
|
||||
Curves.easeInOut,
|
||||
),
|
||||
)
|
||||
.opacity(show ? 1 : 0, animate: true)
|
||||
.animate(const Duration(milliseconds: 300), Curves.easeInOut),
|
||||
onTap: () {
|
||||
if (!ws.isConnected && !ws.isBusy) {
|
||||
ws.connect();
|
||||
|
@ -26,9 +26,7 @@ class ContextMenuArea extends StatelessWidget {
|
||||
final cfg = context.read<ConfigProvider>();
|
||||
if (!cfg.drawerIsCollapsed) {
|
||||
// Leave padding for side navigation
|
||||
mousePosition = cfg.drawerIsExpanded
|
||||
? mousePosition.copyWith(dx: mousePosition.dx - 304 * 2)
|
||||
: mousePosition.copyWith(dx: mousePosition.dx - 80 * 2);
|
||||
mousePosition = mousePosition.copyWith(dx: mousePosition.dx - 80 * 2);
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
@ -40,7 +38,8 @@ class ContextMenuArea extends StatelessWidget {
|
||||
}
|
||||
|
||||
void _showMenu(BuildContext context, Offset mousePosition) async {
|
||||
final menu = contextMenu.copyWith(position: contextMenu.position ?? mousePosition);
|
||||
final menu =
|
||||
contextMenu.copyWith(position: contextMenu.position ?? mousePosition);
|
||||
final value = await showContextMenu(context, contextMenu: menu);
|
||||
onItemSelected?.call(value);
|
||||
}
|
||||
|
@ -19,89 +19,87 @@ class NewsFeedEntry extends StatelessWidget {
|
||||
.cast<SnNewsArticle>()
|
||||
.toList();
|
||||
|
||||
return Card(
|
||||
child: 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)),
|
||||
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,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 0,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
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: 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},
|
||||
);
|
||||
},
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Gap(12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -12,16 +12,14 @@ class FeedUnknownEntry extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: 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),
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -176,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) {
|
||||
|
@ -1,5 +1,6 @@
|
||||
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';
|
||||
@ -83,6 +84,16 @@ class AppSystemMenuBar extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
PlatformMenuItem(
|
||||
shortcut: const SingleActivator(
|
||||
LogicalKeyboardKey.keyH,
|
||||
meta: true,
|
||||
),
|
||||
label: 'trayMenuHide'.tr(),
|
||||
onSelected: () {
|
||||
appWindow.hide();
|
||||
},
|
||||
),
|
||||
if (onQuit != null)
|
||||
PlatformMenuItem(
|
||||
shortcut: const SingleActivator(
|
||||
|
@ -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);
|
||||
},
|
||||
|
@ -1,14 +1,18 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:collection/collection.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/config.dart';
|
||||
import 'package:surface/providers/navigation.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/version_label.dart';
|
||||
|
||||
class AppNavigationDrawer extends StatefulWidget {
|
||||
@ -25,72 +29,118 @@ 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>();
|
||||
|
||||
final backgroundColor = cfg.drawerIsExpanded ? Colors.transparent : null;
|
||||
|
||||
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))
|
||||
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),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Solar Network').bold(),
|
||||
AppVersionLabel(),
|
||||
],
|
||||
).padding(
|
||||
horizontal: 32,
|
||||
vertical: 12,
|
||||
),
|
||||
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();
|
||||
},
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
...nav.destinations.mapIndexed((idx, ele) {
|
||||
return ListTile(
|
||||
leading: ele.icon,
|
||||
title: Text(ele.label).tr(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
selected: nav.currentIndex == idx,
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(ele.screen);
|
||||
nav.setIndex(idx);
|
||||
},
|
||||
);
|
||||
})
|
||||
],
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: AccountImage(
|
||||
content: ua.user?.avatar,
|
||||
fallbackWidget:
|
||||
ua.isAuthorized ? null : const Icon(Symbols.login),
|
||||
),
|
||||
title: ua.isAuthorized
|
||||
? Text(ua.user?.nick ?? 'unknown'.tr()).fontSize(15)
|
||||
: Text('screenAuthLogin').tr(),
|
||||
subtitle: ua.isAuthorized
|
||||
? Text('@${ua.user?.name ?? 'unknown'.tr()}').fontSize(13)
|
||||
: Text('navBottomUnauthorizedCaption').fontSize(13).tr(),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (ua.isAuthorized)
|
||||
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 + 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -1,10 +1,12 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/navigation.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
|
||||
class AppRailNavigation extends StatefulWidget {
|
||||
const AppRailNavigation({super.key});
|
||||
@ -18,43 +20,59 @@ class _AppRailNavigationState extends State<AppRailNavigation> {
|
||||
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.watch<UserProvider>();
|
||||
final nav = context.watch<NavigationProvider>();
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: nav,
|
||||
builder: (context, _) {
|
||||
final destinations = nav.destinations.where((ele) => ele.isPinned).toList();
|
||||
final destinations = nav.destinations.toList();
|
||||
|
||||
return SizedBox(
|
||||
width: 80,
|
||||
child: NavigationRail(
|
||||
selectedIndex:
|
||||
nav.currentIndex != null && nav.currentIndex! < nav.pinnedDestinationCount ? nav.currentIndex : null,
|
||||
labelType: NavigationRailLabelType.selected,
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerLow
|
||||
.withOpacity(0.5),
|
||||
selectedIndex: nav.currentIndex != null &&
|
||||
nav.currentIndex! < nav.destinations.length
|
||||
? nav.currentIndex
|
||||
: null,
|
||||
destinations: [
|
||||
...destinations.where((ele) => ele.isPinned).map((ele) {
|
||||
...destinations.map((ele) {
|
||||
return NavigationRailDestination(
|
||||
icon: ele.icon,
|
||||
label: Text(ele.label).tr(),
|
||||
);
|
||||
}),
|
||||
],
|
||||
leading: const Gap(4),
|
||||
trailing: Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: StyledWidget(
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.menu),
|
||||
onPressed: () {
|
||||
Scaffold.of(context).openDrawer();
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 24),
|
||||
child: GestureDetector(
|
||||
child: AccountImage(
|
||||
content: ua.user?.avatar,
|
||||
fallbackWidget:
|
||||
ua.isAuthorized ? null : const Icon(Symbols.login),
|
||||
),
|
||||
onTap: () {
|
||||
GoRouter.of(context).goNamed('account');
|
||||
},
|
||||
),
|
||||
).padding(bottom: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
onDestinationSelected: (idx) {
|
||||
|
@ -66,7 +66,9 @@ class AppScaffold extends StatelessWidget {
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
backgroundColor: noBackground
|
||||
? Colors.transparent
|
||||
: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: SizedBox.expand(
|
||||
child: noBackground
|
||||
? content
|
||||
@ -107,10 +109,10 @@ 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;
|
||||
final isExpandedDrawer = cfg.drawerIsExpanded;
|
||||
|
||||
final routeName = GoRouter.of(context)
|
||||
.routerDelegate
|
||||
@ -118,8 +120,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
|
||||
@ -130,19 +133,7 @@ class AppRootScaffold extends StatelessWidget {
|
||||
? body
|
||||
: Row(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1 / devicePixelRatio,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: isExpandedDrawer
|
||||
? AppNavigationDrawer(elevation: 0)
|
||||
: AppRailNavigation(),
|
||||
),
|
||||
AppRailNavigation(),
|
||||
Expanded(child: body),
|
||||
],
|
||||
);
|
||||
@ -230,10 +221,72 @@ class AppRootScaffold extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
drawer: !isExpandedDrawer ? AppNavigationDrawer() : null,
|
||||
drawerEdgeDragWidth: isPopable ? 0 : null,
|
||||
drawer: isCollapseDrawer ? const AppNavigationDrawer() : null,
|
||||
bottomNavigationBar:
|
||||
isShowBottomNavigation ? AppBottomNavigationBar() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ResponsiveScaffold extends StatelessWidget {
|
||||
final Widget aside;
|
||||
final Widget? child;
|
||||
final int asideFlex;
|
||||
final int contentFlex;
|
||||
const ResponsiveScaffold({
|
||||
super.key,
|
||||
required this.aside,
|
||||
required this.child,
|
||||
this.asideFlex = 1,
|
||||
this.contentFlex = 2,
|
||||
});
|
||||
|
||||
static bool getIsExpand(BuildContext context) {
|
||||
return ResponsiveBreakpoints.of(context).largerOrEqualTo(TABLET);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (getIsExpand(context)) {
|
||||
return AppBackground(
|
||||
isRoot: true,
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
flex: asideFlex,
|
||||
child: aside,
|
||||
),
|
||||
VerticalDivider(width: 1),
|
||||
if (child != null && child != aside)
|
||||
Flexible(flex: contentFlex, child: child!)
|
||||
else
|
||||
Flexible(
|
||||
flex: contentFlex,
|
||||
child: ResponsiveScaffoldLanding(child: null),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AppBackground(isRoot: true, child: child ?? aside);
|
||||
}
|
||||
}
|
||||
|
||||
class ResponsiveScaffoldLanding extends StatelessWidget {
|
||||
final Widget? child;
|
||||
const ResponsiveScaffoldLanding({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (ResponsiveScaffold.getIsExpand(context) || child == null) {
|
||||
return AppScaffold(
|
||||
noBackground: true,
|
||||
appBar: AppBar(),
|
||||
body: const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
return child!;
|
||||
}
|
||||
}
|
||||
|
@ -23,57 +23,54 @@ class FediversePostWidget extends StatelessWidget {
|
||||
return Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
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,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -4,7 +4,6 @@ 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/post.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
@ -30,24 +29,14 @@ 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,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
|
||||
? const BorderRadius.all(Radius.circular(8))
|
||||
: BorderRadius.zero,
|
||||
border: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
|
||||
? Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1 / devicePixelRatio,
|
||||
)
|
||||
: Border.symmetric(
|
||||
horizontal: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1 / devicePixelRatio,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.symmetric(
|
||||
horizontal: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1 / devicePixelRatio,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: PostMiniEditor(
|
||||
postReplyId: parentPost.id,
|
||||
@ -103,7 +92,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client
|
||||
.put('/cgi/co/questions/${widget.parentPost.id}/answer', data: {
|
||||
'publisher': answer.publisherId,
|
||||
'publisher': widget.parentPost.publisherId,
|
||||
'answer_id': answer.id,
|
||||
});
|
||||
if (!mounted) return;
|
||||
@ -151,6 +140,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'postDetail',
|
||||
pathParameters: {'slug': _posts[idx].id.toString()},
|
||||
@ -225,6 +215,9 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
|
||||
onPost: () {
|
||||
_childListKey.currentState!.refresh();
|
||||
},
|
||||
onExpand: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
@ -26,7 +25,6 @@ import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/translation.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/screens/post/post_detail.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/types/reaction.dart';
|
||||
@ -53,6 +51,7 @@ class OpenablePostItem extends StatelessWidget {
|
||||
final bool showMenu;
|
||||
final bool showFullPost;
|
||||
final bool showExpandableComments;
|
||||
final bool useReplace;
|
||||
final double? maxWidth;
|
||||
final Function(SnPost data)? onChanged;
|
||||
final Function()? onDeleted;
|
||||
@ -66,6 +65,7 @@ class OpenablePostItem extends StatelessWidget {
|
||||
this.showMenu = true,
|
||||
this.showFullPost = false,
|
||||
this.showExpandableComments = false,
|
||||
this.useReplace = false,
|
||||
this.maxWidth,
|
||||
this.onChanged,
|
||||
this.onDeleted,
|
||||
@ -74,40 +74,32 @@ class OpenablePostItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cfg = context.read<ConfigProvider>();
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
|
||||
child: Center(
|
||||
child: OpenContainer(
|
||||
closedBuilder: (_, __) => Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
|
||||
child: PostItem(
|
||||
data: data,
|
||||
maxWidth: maxWidth,
|
||||
showComments: showComments,
|
||||
showFullPost: showFullPost,
|
||||
showExpandableComments: showExpandableComments,
|
||||
onChanged: onChanged,
|
||||
onDeleted: onDeleted,
|
||||
onSelectAnswer: onSelectAnswer,
|
||||
),
|
||||
),
|
||||
openBuilder: (_, close) => PostDetailScreen(
|
||||
slug: data.id.toString(),
|
||||
preload: data,
|
||||
onBack: close,
|
||||
),
|
||||
openColor: Colors.transparent,
|
||||
openElevation: 0,
|
||||
transitionType: ContainerTransitionType.fade,
|
||||
closedElevation: 0,
|
||||
closedColor: Theme.of(context).colorScheme.surface.withOpacity(
|
||||
cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1,
|
||||
),
|
||||
closedShape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
child: GestureDetector(
|
||||
child: PostItem(
|
||||
data: data,
|
||||
maxWidth: maxWidth,
|
||||
showComments: showComments,
|
||||
showFullPost: showFullPost,
|
||||
showExpandableComments: showExpandableComments,
|
||||
onChanged: onChanged,
|
||||
onDeleted: onDeleted,
|
||||
onSelectAnswer: onSelectAnswer,
|
||||
),
|
||||
onTap: () {
|
||||
if (useReplace) {
|
||||
GoRouter.of(context)
|
||||
.pushReplacementNamed('postDetail', pathParameters: {
|
||||
'slug': data.id.toString(),
|
||||
});
|
||||
} else {
|
||||
GoRouter.of(context).pushNamed('postDetail', pathParameters: {
|
||||
'slug': data.id.toString(),
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -122,6 +114,7 @@ class PostItem extends StatefulWidget {
|
||||
final bool showMenu;
|
||||
final bool showFullPost;
|
||||
final bool showAvatar;
|
||||
final bool showCompactAvatar;
|
||||
final bool showExpandableComments;
|
||||
final double? maxWidth;
|
||||
final Function(SnPost data)? onChanged;
|
||||
@ -137,6 +130,7 @@ class PostItem extends StatefulWidget {
|
||||
this.showMenu = true,
|
||||
this.showFullPost = false,
|
||||
this.showAvatar = true,
|
||||
this.showCompactAvatar = false,
|
||||
this.showExpandableComments = false,
|
||||
this.maxWidth,
|
||||
this.onChanged,
|
||||
@ -166,6 +160,14 @@ class _PostItemState extends State<PostItem> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PostItem oldWidget) {
|
||||
_displayText = widget.data.body['content'] ?? '';
|
||||
_displayTitle = widget.data.body['title'] ?? '';
|
||||
_displayDescription = widget.data.body['description'] ?? '';
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
Future<void> _translateText() async {
|
||||
final ta = context.read<SnTranslator>();
|
||||
setState(() => _isTranslating = true);
|
||||
@ -269,6 +271,8 @@ class _PostItemState extends State<PostItem> {
|
||||
final ua = context.read<UserProvider>();
|
||||
final isAuthor =
|
||||
ua.isAuthorized && widget.data.publisher.accountId == ua.user?.id;
|
||||
final isParentAuthor = ua.isAuthorized &&
|
||||
widget.data.replyTo?.publisher.accountId == ua.user?.id;
|
||||
|
||||
final displayableAttachments = widget.data.preload?.attachments
|
||||
?.where((ele) =>
|
||||
@ -284,6 +288,244 @@ class _PostItemState extends State<PostItem> {
|
||||
attachmentSize -= 80;
|
||||
}
|
||||
|
||||
if (widget.showFullPost) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.maxWidth ?? double.infinity,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (widget.showAvatar)
|
||||
_PostAvatar(
|
||||
data: widget.data,
|
||||
isCompact: false,
|
||||
),
|
||||
if (widget.showAvatar) const Gap(12),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.showCompactAvatar)
|
||||
_PostAvatar(
|
||||
data: widget.data,
|
||||
isCompact: true,
|
||||
),
|
||||
if (widget.showAvatar) const Gap(8),
|
||||
_PostContentHeader(
|
||||
isRelativeDate: !widget.showFullPost,
|
||||
isCompact: false,
|
||||
data: widget.data,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_PostActionPopup(
|
||||
data: widget.data,
|
||||
isAuthor: isAuthor,
|
||||
isParentAuthor: isParentAuthor,
|
||||
onShare: () => _doShare(context),
|
||||
onShareImage: () => _doShareViaPicture(context),
|
||||
onSelectAnswer: widget.onSelectAnswer,
|
||||
onDeleted: () {
|
||||
widget.onDeleted?.call();
|
||||
},
|
||||
onTranslate: () {
|
||||
_translateText();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
if (widget.data.preload?.thumbnail != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
child: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(
|
||||
widget.data.preload!.thumbnail!.rid,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.data.preload?.video != null)
|
||||
_PostVideoPlayer(data: widget.data).padding(bottom: 8),
|
||||
if (widget.data.type == 'question')
|
||||
_PostQuestionHint(data: widget.data).padding(bottom: 8),
|
||||
if (_displayDescription.isNotEmpty || _displayTitle.isNotEmpty)
|
||||
_PostHeadline(
|
||||
title: _displayTitle,
|
||||
description: _displayDescription,
|
||||
data: widget.data,
|
||||
isEnlarge:
|
||||
widget.data.type == 'article' && widget.showFullPost,
|
||||
).padding(bottom: 8),
|
||||
if (widget.data.type == 'article' && !widget.showFullPost)
|
||||
Text('postArticle')
|
||||
.tr()
|
||||
.fontSize(13)
|
||||
.opacity(0.75)
|
||||
.padding(bottom: 8),
|
||||
if ((_displayText.isNotEmpty) &&
|
||||
(widget.showFullPost || widget.data.type != 'article'))
|
||||
_PostContentBody(
|
||||
text: _displayText,
|
||||
data: widget.data,
|
||||
isSelectable: widget.showFullPost,
|
||||
isEnlarge:
|
||||
widget.data.type == 'article' && widget.showFullPost,
|
||||
).padding(bottom: 6),
|
||||
if (widget.data.visibility > 0)
|
||||
_PostVisibilityHint(data: widget.data).padding(
|
||||
vertical: 4,
|
||||
),
|
||||
if (widget.data.body['content_truncated'] == true)
|
||||
_PostTruncatedHint(data: widget.data).padding(
|
||||
vertical: 4,
|
||||
),
|
||||
if (widget.data.tags.isNotEmpty)
|
||||
_PostTagsList(data: widget.data).padding(top: 4, bottom: 6),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
if (widget.showViews)
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.play_circle, size: 20),
|
||||
const Gap(4),
|
||||
Text('postViews').plural(widget.data.totalViews),
|
||||
],
|
||||
).opacity(0.75),
|
||||
if (_isTranslating)
|
||||
AnimateWidgetExtensions(Row(
|
||||
children: [
|
||||
Icon(Symbols.translate, size: 20),
|
||||
const Gap(4),
|
||||
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: Row(
|
||||
children: [
|
||||
Icon(Symbols.translate, size: 20),
|
||||
const Gap(4),
|
||||
Text('translated').tr(),
|
||||
],
|
||||
).opacity(0.75),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_displayText = widget.data.body['content'] ?? '';
|
||||
_displayTitle = widget.data.body['title'] ?? '';
|
||||
_displayDescription =
|
||||
widget.data.body['description'] ?? '';
|
||||
_isTranslated = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (widget.data.repostTo != null)
|
||||
_PostQuoteContent(child: widget.data.repostTo!).padding(
|
||||
top: 4,
|
||||
bottom: widget.data.preload?.attachments?.isNotEmpty ??
|
||||
false
|
||||
? 12
|
||||
: 0,
|
||||
),
|
||||
],
|
||||
).padding(
|
||||
bottom: widget.showViews || _isTranslated || _isTranslating
|
||||
? 8
|
||||
: 0,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12, top: 8),
|
||||
),
|
||||
if (displayableAttachments?.isNotEmpty ?? false)
|
||||
AttachmentList(
|
||||
data: displayableAttachments!,
|
||||
bordered: true,
|
||||
maxHeight: widget.showFullPost ? null : 480,
|
||||
minWidth: attachmentSize,
|
||||
maxWidth: attachmentSize,
|
||||
fit: widget.showFullPost ? BoxFit.cover : BoxFit.contain,
|
||||
padding: EdgeInsets.only(left: 12, right: 12),
|
||||
),
|
||||
if (widget.data.preload?.poll != null)
|
||||
StyledWidget(Container(
|
||||
constraints:
|
||||
BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity),
|
||||
child: PostPoll(poll: widget.data.preload!.poll!),
|
||||
))
|
||||
.padding(
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 12,
|
||||
bottom: 4,
|
||||
)
|
||||
.center(),
|
||||
if (widget.data.body['content'] != null &&
|
||||
(cfg.prefs.getBool(kAppExpandPostLink) ?? true))
|
||||
LinkPreviewWidget(
|
||||
text: widget.data.body['content'],
|
||||
).padding(left: 12, right: 4),
|
||||
if (widget.showExpandableComments)
|
||||
_PostCommentIntent(
|
||||
data: widget.data,
|
||||
showAvatar: widget.showAvatar,
|
||||
maxWidth: widget.maxWidth ?? double.infinity,
|
||||
).padding(left: 12, right: 12)
|
||||
else
|
||||
_PostFeaturedComment(data: widget.data, maxWidth: widget.maxWidth)
|
||||
.padding(left: 12, right: 12),
|
||||
if (widget.showReactions)
|
||||
Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: (widget.maxWidth ?? double.infinity) + 24,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: _PostReactionList(
|
||||
data: widget.data,
|
||||
padding: EdgeInsets.only(left: 12, right: 12),
|
||||
onChanged: _onChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@ -309,15 +551,28 @@ class _PostItemState extends State<PostItem> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _PostContentHeader(
|
||||
isRelativeDate: !widget.showFullPost,
|
||||
isCompact: true,
|
||||
data: widget.data,
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.showCompactAvatar)
|
||||
_PostAvatar(
|
||||
data: widget.data,
|
||||
isCompact: true,
|
||||
),
|
||||
if (widget.showCompactAvatar) const Gap(8),
|
||||
Expanded(
|
||||
child: _PostContentHeader(
|
||||
isRelativeDate: !widget.showFullPost,
|
||||
isCompact: true,
|
||||
data: widget.data,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_PostActionPopup(
|
||||
data: widget.data,
|
||||
isAuthor: isAuthor,
|
||||
isParentAuthor: isParentAuthor,
|
||||
onShare: () => _doShare(context),
|
||||
onShareImage: () => _doShareViaPicture(context),
|
||||
onSelectAnswer: widget.onSelectAnswer,
|
||||
@ -329,7 +584,7 @@ class _PostItemState extends State<PostItem> {
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
).padding(bottom: widget.showCompactAvatar ? 4 : 0),
|
||||
if (widget.data.preload?.thumbnail != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
@ -389,15 +644,6 @@ class _PostItemState extends State<PostItem> {
|
||||
isEnlarge: widget.data.type == 'article' &&
|
||||
widget.showFullPost,
|
||||
).padding(bottom: 6),
|
||||
if (widget.data.repostTo != null)
|
||||
_PostQuoteContent(child: widget.data.repostTo!)
|
||||
.padding(
|
||||
bottom:
|
||||
widget.data.preload?.attachments?.isNotEmpty ??
|
||||
false
|
||||
? 12
|
||||
: 0,
|
||||
),
|
||||
if (widget.data.visibility > 0)
|
||||
_PostVisibilityHint(data: widget.data).padding(
|
||||
vertical: 4,
|
||||
@ -462,12 +708,25 @@ class _PostItemState extends State<PostItem> {
|
||||
),
|
||||
],
|
||||
).padding(
|
||||
bottom: widget.showViews ||
|
||||
_isTranslated ||
|
||||
_isTranslating
|
||||
bottom: (widget.showViews ||
|
||||
_isTranslated ||
|
||||
_isTranslating) &&
|
||||
(widget.data.repostTo != null ||
|
||||
(widget.data.preload?.attachments
|
||||
?.isNotEmpty ??
|
||||
false))
|
||||
? 8
|
||||
: 0,
|
||||
),
|
||||
if (widget.data.repostTo != null)
|
||||
_PostQuoteContent(child: widget.data.repostTo!)
|
||||
.padding(
|
||||
bottom:
|
||||
(widget.data.preload?.attachments?.isNotEmpty ??
|
||||
false)
|
||||
? 8
|
||||
: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@ -502,19 +761,28 @@ class _PostItemState extends State<PostItem> {
|
||||
if (widget.showExpandableComments)
|
||||
_PostCommentIntent(
|
||||
data: widget.data,
|
||||
maxWidth: (widget.maxWidth ?? double.infinity) -
|
||||
(widget.showAvatar ? 72 : 24),
|
||||
showAvatar: widget.showAvatar,
|
||||
).padding(left: widget.showAvatar ? 60 : 12, right: 12)
|
||||
else
|
||||
else if (widget.showComments)
|
||||
_PostFeaturedComment(data: widget.data, maxWidth: widget.maxWidth)
|
||||
.padding(left: widget.showAvatar ? 60 : 12, right: 12),
|
||||
if (widget.showReactions)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: _PostReactionList(
|
||||
data: widget.data,
|
||||
padding:
|
||||
EdgeInsets.only(left: widget.showAvatar ? 60 : 12, right: 12),
|
||||
onChanged: _onChanged,
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.maxWidth ?? double.infinity,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: _PostReactionList(
|
||||
data: widget.data,
|
||||
padding: EdgeInsets.only(
|
||||
left: widget.showAvatar ? 60 : 12,
|
||||
right: 12,
|
||||
),
|
||||
onChanged: _onChanged,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -1045,6 +1313,7 @@ class _PostAvatar extends StatelessWidget {
|
||||
class _PostActionPopup extends StatelessWidget {
|
||||
final SnPost data;
|
||||
final bool isAuthor;
|
||||
final bool isParentAuthor;
|
||||
final Function onDeleted;
|
||||
final Function() onShare, onShareImage;
|
||||
final Function()? onSelectAnswer;
|
||||
@ -1052,6 +1321,7 @@ class _PostActionPopup extends StatelessWidget {
|
||||
const _PostActionPopup({
|
||||
required this.data,
|
||||
required this.isAuthor,
|
||||
required this.isParentAuthor,
|
||||
required this.onDeleted,
|
||||
required this.onShare,
|
||||
required this.onShareImage,
|
||||
@ -1125,7 +1395,7 @@ class _PostActionPopup extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
if (onTranslate != null) PopupMenuDivider(),
|
||||
if (isAuthor && onSelectAnswer != null)
|
||||
if (isParentAuthor && onSelectAnswer != null)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
@ -1138,7 +1408,7 @@ class _PostActionPopup extends StatelessWidget {
|
||||
onSelectAnswer?.call();
|
||||
},
|
||||
),
|
||||
if (isAuthor && onSelectAnswer != null) PopupMenuDivider(),
|
||||
if (isParentAuthor && onSelectAnswer != null) PopupMenuDivider(),
|
||||
if (isAuthor)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
@ -1299,19 +1569,24 @@ class _PostContentHeader extends StatelessWidget {
|
||||
if (isCompact) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(data.publisher.nick).bold(),
|
||||
Flexible(
|
||||
child: Text(
|
||||
data.publisher.nick,
|
||||
maxLines: 1,
|
||||
).bold(),
|
||||
),
|
||||
const Gap(4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
isRelativeDate
|
||||
? RelativeTime(context)
|
||||
.format((data.publishedAt ?? data.createdAt).toLocal())
|
||||
: DateFormat('y/M/d HH:mm')
|
||||
.format((data.publishedAt ?? data.createdAt).toLocal()),
|
||||
).fontSize(13),
|
||||
],
|
||||
).opacity(0.8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
isRelativeDate
|
||||
? RelativeTime(context)
|
||||
.format((data.publishedAt ?? data.createdAt).toLocal())
|
||||
: DateFormat('y/M/d HH:mm')
|
||||
.format((data.publishedAt ?? data.createdAt).toLocal()),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
).fontSize(13).opacity(0.8),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
@ -1330,7 +1605,10 @@ class _PostContentHeader extends StatelessWidget {
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text('@${data.publisher.name}').fontSize(13),
|
||||
Text(
|
||||
'@${data.publisher.name}',
|
||||
maxLines: 1,
|
||||
).fontSize(13),
|
||||
const Gap(4),
|
||||
Text(
|
||||
isRelativeDate
|
||||
@ -1338,6 +1616,8 @@ class _PostContentHeader extends StatelessWidget {
|
||||
.format((data.publishedAt ?? data.createdAt).toLocal())
|
||||
: DateFormat('y/M/d HH:mm')
|
||||
.format((data.publishedAt ?? data.createdAt).toLocal()),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
).fontSize(13),
|
||||
],
|
||||
).opacity(0.8),
|
||||
@ -1403,10 +1683,19 @@ class _PostQuoteContent extends StatelessWidget {
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
_PostContentHeader(
|
||||
data: child,
|
||||
isCompact: true,
|
||||
isRelativeDate: isRelativeDate,
|
||||
Row(
|
||||
children: [
|
||||
_PostAvatar(
|
||||
data: child,
|
||||
isCompact: true,
|
||||
),
|
||||
const Gap(8),
|
||||
_PostContentHeader(
|
||||
data: child,
|
||||
isCompact: true,
|
||||
isRelativeDate: isRelativeDate,
|
||||
),
|
||||
],
|
||||
).padding(bottom: 4),
|
||||
_PostContentBody(
|
||||
data: child,
|
||||
@ -1594,7 +1883,12 @@ class _PostTruncatedHint extends StatelessWidget {
|
||||
class _PostCommentIntent extends StatefulWidget {
|
||||
final SnPost data;
|
||||
final bool showAvatar;
|
||||
const _PostCommentIntent({required this.data, this.showAvatar = false});
|
||||
final double maxWidth;
|
||||
const _PostCommentIntent({
|
||||
required this.data,
|
||||
this.showAvatar = false,
|
||||
required this.maxWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PostCommentIntent> createState() => _PostCommentIntentState();
|
||||
@ -1633,53 +1927,69 @@ class _PostCommentIntentState extends State<_PostCommentIntent> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (_comments.isNotEmpty)
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final ele in _comments)
|
||||
PostItem(
|
||||
data: ele,
|
||||
showAvatar: false,
|
||||
showExpandableComments: true,
|
||||
showReactions: false,
|
||||
showViews: false,
|
||||
maxWidth: double.infinity,
|
||||
).padding(vertical: 8, left: 6),
|
||||
],
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
Row(
|
||||
children: [
|
||||
Transform.flip(
|
||||
flipX: true,
|
||||
child: const Icon(Symbols.comment, size: 20),
|
||||
),
|
||||
const Gap(4),
|
||||
Text('postCommentsDetailed'.plural(widget.data.metric.replyCount)),
|
||||
if (widget.data.metric.replyCount > 0 && !_isAllLoaded)
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: IconButton(
|
||||
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
||||
constraints: const BoxConstraints(),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(Symbols.expand_more, size: 18),
|
||||
onPressed: _isBusy
|
||||
? null
|
||||
: () {
|
||||
_fetchComments();
|
||||
},
|
||||
),
|
||||
).padding(left: 8),
|
||||
],
|
||||
).opacity(0.75).padding(horizontal: widget.showAvatar ? 4 : 0),
|
||||
],
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: widget.maxWidth),
|
||||
child: Column(
|
||||
children: [
|
||||
if (_comments.isNotEmpty)
|
||||
Card(
|
||||
elevation: 4,
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final ele in _comments)
|
||||
InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: PostItem(
|
||||
data: ele,
|
||||
showAvatar: false,
|
||||
showCompactAvatar: true,
|
||||
showExpandableComments: true,
|
||||
showReactions: false,
|
||||
showViews: false,
|
||||
maxWidth: double.infinity,
|
||||
).padding(vertical: 8, left: 6),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'postDetail',
|
||||
pathParameters: {'slug': ele.id.toString()},
|
||||
extra: ele,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
).padding(vertical: 8),
|
||||
Row(
|
||||
children: [
|
||||
Transform.flip(
|
||||
flipX: true,
|
||||
child: const Icon(Symbols.comment, size: 20),
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postCommentsDetailed'.plural(widget.data.metric.replyCount)),
|
||||
if (widget.data.metric.replyCount > 0 && !_isAllLoaded)
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: IconButton(
|
||||
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
||||
constraints: const BoxConstraints(),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(Symbols.expand_more, size: 18),
|
||||
onPressed: _isBusy
|
||||
? null
|
||||
: () {
|
||||
_fetchComments();
|
||||
},
|
||||
),
|
||||
).padding(left: 8),
|
||||
],
|
||||
).opacity(0.75).padding(horizontal: widget.showAvatar ? 4 : 0),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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: [
|
||||
|
@ -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(
|
||||
|
@ -6,6 +6,7 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <bitsdojo_window_linux/bitsdojo_window_plugin.h>
|
||||
#include <fast_rsa/fast_rsa_plugin.h>
|
||||
#include <file_saver/file_saver_plugin.h>
|
||||
@ -23,6 +24,9 @@
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
|
||||
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) bitsdojo_window_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "BitsdojoWindowPlugin");
|
||||
bitsdojo_window_plugin_register_with_registrar(bitsdojo_window_linux_registrar);
|
||||
|
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
bitsdojo_window_linux
|
||||
fast_rsa
|
||||
file_saver
|
||||
@ -22,7 +23,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
croppy
|
||||
media_kit_native_event_loop
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
@ -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));
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user