Compare commits

..

75 Commits

Author SHA1 Message Date
69d5e95565 🚀 Launch 2.4.2+86 March Update 2025-03-31 23:05:36 +08:00
3e3442fc89 💄 Enable new sfx on special days 2025-03-31 23:00:13 +08:00
8181010b0b 💄 New desktop loading animation 2025-03-31 22:50:08 +08:00
269caf7555 💄 Some improvements
🐛 Bug fixes
 The heart reaction
2025-03-31 01:27:45 +08:00
ae0809ad35 💄 Optimize background color 2025-03-31 00:51:37 +08:00
4005f03cf8 🐛 Fix notification 2025-03-30 23:25:38 +08:00
4bd8ec54f1 Optimize initialization 2025-03-30 20:43:47 +08:00
51a387851f 🐛 Fix infinite starting up 2025-03-30 20:37:04 +08:00
8ed847d870 ♻️ Use API Version 2 to load post 2025-03-30 15:31:02 +08:00
dfe13de220 Program Badges 2025-03-29 17:00:17 +08:00
b02a54c1e9 🐛 Fix sound mode 2025-03-29 16:41:23 +08:00
55a7e7d900 🐛 Fix app drawer did not close after selected 2025-03-29 01:04:37 +08:00
3585941ccb 🐛 Optimize noise cancellation 2025-03-29 01:03:11 +08:00
7c6f2cc4ab ♻️ Refactored call view 2025-03-29 00:58:13 +08:00
61dbf92909 🌐 Merge pull request #19 from Texas0295/master
Add AppImage build tools & Update workflow
2025-03-28 18:37:55 +08:00
b69e4002e0 Add AppImage build tools & Update workflow 2025-03-28 02:01:45 +08:00
49aa24b79d Merge branch 'Solsynth:master' into master 2025-03-28 01:59:19 +08:00
ceb5c53229 🚀 Launch 2.4.2+85 2025-03-28 01:01:34 +08:00
908f0cb59e 🐛 Fix some issues 2025-03-28 00:54:51 +08:00
7c2b8de931 Desktop chat list
🍱 Update launch sfx
2025-03-28 00:52:19 +08:00
ddd0a4c3d3 remove cache=true in build-linux 2025-03-28 00:41:58 +08:00
99e07de243 upload appimage file 2025-03-28 00:04:44 +08:00
6bb9c21759 Rollback drawer style on mobile
🗑️ Remove drawer prefer collapse & expand
2025-03-28 00:00:39 +08:00
8f2fc55608 User ear healthy 2025-03-27 23:52:37 +08:00
a1c4e5eca0 ♻️ Refactored large screen user experience 2025-03-27 23:18:40 +08:00
10bf0883e5 add appimage build 2025-03-27 23:11:15 +08:00
595050f89f ♻️ Explore two column 2025-03-27 22:58:06 +08:00
0722c99f21 ♻️ Openable Post Item now push pages 2025-03-27 22:46:36 +08:00
12d03836f9 ♻️ Updated nav & account page two column design 2025-03-27 22:42:44 +08:00
f78d3f4fd5 🔀 Merge pull request #18 from Texas0295/master
Fix workflow
2025-03-27 22:04:02 +08:00
e798a8ba76 fix workflow 2025-03-27 21:24:38 +08:00
c28a664373 Memorable window size 2025-03-27 00:37:45 +08:00
4589722c3b Weird boot sound effects 2025-03-27 00:22:41 +08:00
38e1c51b45 🐛 Fix linux compile issue 2025-03-26 23:32:23 +08:00
610ddec05c Sound effects on notify 2025-03-26 23:16:55 +08:00
d0276f9ac6 🐛 Fix some date issue 2025-03-26 22:51:09 +08:00
c1e89a2ee6 Punishments 2025-03-26 22:43:27 +08:00
ecc79368a1 🐛 Fix attachment border in list 2025-03-26 00:29:00 +08:00
e6d732c86a 💄 Optimize status text 2025-03-26 00:26:37 +08:00
dd055fb077 💄 Optimization and bug fixes 2025-03-26 00:24:07 +08:00
280840c6d8 ⬆️ Upgrade deps 2025-03-25 21:33:58 +08:00
bde62a7b2c Add cache for audio and video (experimental) 2025-03-25 00:33:39 +08:00
5445c570a2 Add deps for google_mobile_ads 2025-03-24 23:12:49 +08:00
b2302f5b3c 🐛 Make initialize for push notification no longer waited 2025-03-24 20:55:55 +08:00
d7359cfd0d 🐛 Fixes and optimization in programs 2025-03-24 00:09:36 +08:00
9cc577adbe Programs, members
🐛 Fix web assets redirecting issue
2025-03-23 22:34:58 +08:00
dd196b7754 Golden points 2025-03-23 18:23:18 +08:00
16c07c2133 🐛 Fix deps 2025-03-23 17:01:21 +08:00
6bcb658d44 🐛 Fix platform specific captcha solution cause build failed. 2025-03-23 16:47:06 +08:00
9311bfc3b5 ⬆️ Upgrade deps & replace to own translation api 2025-03-23 16:26:41 +08:00
8dd6435a30 🐛 Fix some issues on Android and Web 2025-03-23 16:24:53 +08:00
21a1d4a2ad 🐛 Fix unable select answer 2025-03-23 00:01:48 +08:00
603875b1af 🐛 Fix styling issue 2025-03-22 23:07:13 +08:00
4209a13c84 🐛 Fix no nav to use 2025-03-22 22:51:50 +08:00
55b79bfd8f 🐛 Finish bug fixes 2025-03-22 21:50:01 +08:00
6e6c3f42f6 🚀 Launch 2.4.2+84 2025-03-22 20:28:53 +08:00
dc38b46b2c Support captcha 2025-03-22 20:24:05 +08:00
b4990308e9 ♻️ Refactored nav completely 2025-03-22 18:39:01 +08:00
237abe564d ♻️ Refactored drawer nav 2025-03-22 18:14:36 +08:00
71b41d470a Splash screen loading 2025-03-22 16:36:10 +08:00
7052b5b635 Join channel hint
🗃️ Realm local db
2025-03-22 14:12:46 +08:00
f356e08f79 💄 New navigation draft (skip ci) 2025-03-22 12:48:55 +08:00
152872db65 💄 Show nick instead of name in typing indicator 2025-03-22 00:16:59 +08:00
dfe117d04f Auth preference screen 2025-03-22 00:14:55 +08:00
caf63f0cbe Notification preferences 2025-03-21 23:59:42 +08:00
b8f5cc82f9 Add attachments from file 2025-03-20 23:20:24 +08:00
360bc50f21 🐛 Fix linux G_APPLICATION_FLAGS_NONE api deprecated 2025-03-20 22:52:32 +08:00
2de93a0486 🐛 Close #15 with vide coding (not tested) 2025-03-20 22:45:53 +08:00
02227852f8 🚀 Launch 2.4.2+83 for some platforms 2025-03-19 00:48:36 +08:00
ad16de595b 🐛 Fix menubar missing hide 2025-03-19 00:30:58 +08:00
9f8c8923d9 🐛 Bug fixes in posts 2025-03-19 00:29:29 +08:00
060bfa4887 🐛 Fix explore unmixed feed pagination issue 2025-03-19 00:23:57 +08:00
e68ada2d04 💄 Optimize post comments 2025-03-19 00:21:54 +08:00
d6013078bd 🚀 Launch 2.4.2+81 2025-03-17 00:38:15 +08:00
5976d61997 💄 Bunch of optimization 2025-03-17 00:36:20 +08:00
130 changed files with 10168 additions and 2227 deletions

View File

@ -48,14 +48,15 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- 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
@ -63,3 +64,18 @@ jobs:
with:
name: build-output-linux
path: build/linux/x64/release/bundle
- name: Build AppImage
run: |
rm -r Solian.AppDir | true
mkdir Solian.AppDir
cp -r build/linux/x64/release/bundle/* Solian.AppDir
cp -r buildtools/appimage_config/* Solian.AppDir
cp assets/icon/icon-light-radius.png Solian.AppDir
sudo chmod +x buildtools/appimagetool-x86_64.AppImage
sudo chmod +x Solian.AppDir/AppRun
./buildtools/appimagetool-x86_64.AppImage Solian.AppDir
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: build-output-linux-appimage
path: './*.AppImage*'

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/icon/kanban-1st.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

View File

@ -130,7 +130,7 @@
"accountPublishersSubtitle": "Manage your publish identities.",
"accountSettings": "Account Settings",
"accountSettingsSubtitle": "Manage your account and make it yours.",
"accountProfileEdit": "Edit your profile",
"accountProfileEdit": "Edit Profile",
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
"accountWallet": "Wallet",
"accountWalletSubtitle": "View your balance and transactions.",
@ -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,105 @@
"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": "Back",
"badgeProgramDeveloper": "Developer Program Member",
"badgeProgramStellar": "A Stellar",
"badgeProgramModerator": "Community Moderator",
"postEditedHint": "edited",
"splashScreenServer": "Server",
"splashScreenServerName": "Potato",
"splashScreenCaption": "Trying to establishing connection with HyperNet™"
}

View File

@ -336,6 +336,7 @@
"fieldAttachmentRandomId": "访问 ID",
"fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "从相册中添加附件",
"addAttachmentFromFiles": "从文件中添加附件",
"addAttachmentFromClipboard": "粘贴附件",
"addAttachmentFromCameraPhoto": "拍摄照片",
"addAttachmentFromCameraVideo": "拍摄视频",
@ -844,5 +845,105 @@
"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": "返回",
"badgeProgramDeveloper": "开发者计划成员",
"badgeProgramStellar": "一颗恒星",
"badgeProgramModerator": "社区管理员",
"postEditedHint": "已编辑",
"splashScreenServer": "服务器",
"splashScreenServerName": "土豆",
"splashScreenCaption": "正在尝试与 HyperNet™ 取得太阳链连接"
}

View File

@ -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": "或者註冊一個賬號"
}

View File

@ -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": "或者註冊一個賬號"
}

View File

@ -0,0 +1,4 @@
#!/bin/sh
cd "$(dirname "$0")"
exec ./surface

View File

@ -0,0 +1,8 @@
[Desktop Entry]
Version=1.0
Type=Application
Terminal=false
Name=Solian
Exec=surface %u
Icon=icon-light-radius
Categories=Network;

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -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):
@ -187,9 +189,12 @@ PODS:
- Flutter
- flutter_webrtc
- WebRTC-SDK (= 125.6422.06)
- media_kit_libs_ios_video (1.0.4):
- livekit_noise_filter (0.0.1):
- Flutter
- media_kit_native_event_loop (1.0.0):
- flutter_webrtc
- LiveKitKrispNoiseFilter (= 0.0.7)
- LiveKitKrispNoiseFilter (0.0.7)
- media_kit_libs_ios_video (1.0.4):
- Flutter
- media_kit_video (0.0.1):
- Flutter
@ -212,8 +217,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 +235,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 +247,7 @@ PODS:
- sqlite3 (~> 3.49.1)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- SwiftyGif (5.4.5)
@ -259,6 +265,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`)
@ -281,15 +288,14 @@ DEPENDENCIES:
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- Kingfisher (~> 8.0)
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- livekit_noise_filter (from `.symlinks/plugins/livekit_noise_filter/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`)
@ -315,6 +321,7 @@ SPEC REPOS:
- GoogleDataTransport
- GoogleUtilities
- Kingfisher
- LiveKitKrispNoiseFilter
- nanopb
- OrderedSet
- PromisesObjC
@ -325,6 +332,8 @@ SPEC REPOS:
- WebRTC-SDK
EXTERNAL SOURCES:
audioplayers_darwin:
:path: ".symlinks/plugins/audioplayers_darwin/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
croppy:
@ -367,10 +376,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/in_app_review/ios"
livekit_client:
:path: ".symlinks/plugins/livekit_client/ios"
livekit_noise_filter:
:path: ".symlinks/plugins/livekit_noise_filter/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 +392,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 +413,66 @@ 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
livekit_noise_filter: a26aeb1c1eae6db0a023fd2f6ea3ff108c3ecbb0
LiveKitKrispNoiseFilter: efe418ceca28163ace0ff222bd2cc02384645d84
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

View File

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

View File

@ -241,7 +241,7 @@ class PostWriteController extends ChangeNotifier {
contentController.text = post.body['content'] ?? '';
aliasController.text = post.alias ?? '';
rewardController.text = post.body['reward']?.toString() ?? '';
videoAttachment = post.preload?.video;
videoAttachment = SnAttachment.fromJson(post.body['video']);
publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil;
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
@ -252,17 +252,21 @@ class PostWriteController extends ChangeNotifier {
categories =
List.from(post.categories.map((ele) => ele.alias), growable: true);
attachments.addAll(
post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
poll = post.preload?.poll;
post.body['attachments']
.where(SnAttachment.fromJson)
?.map(PostWriteMedia) ??
[],
);
poll = post.poll;
editingDraft = post.isDraft;
if (post.preload?.thumbnail != null &&
(post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
thumbnail = PostWriteMedia(post.preload!.thumbnail);
if (post.body['thumbnail'] != null) {
thumbnail =
PostWriteMedia(SnAttachment.fromJson(post.body['thumbnail']));
}
if (post.preload?.realm != null) {
realm = post.preload!.realm!;
if (post.realm != null) {
realm = post.realm!;
}
editingPost = post;

View File

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

View File

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

View File

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

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

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

View File

@ -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,13 +13,16 @@ 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:google_fonts/google_fonts.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:package_info_plus/package_info_plus.dart';
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 +50,8 @@ 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/navigation/app_scaffold.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';
@ -53,6 +59,7 @@ import 'package:in_app_review/in_app_review.dart';
import 'package:image_picker_android/image_picker_android.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
import 'package:local_notifier/local_notifier.dart';
import 'package:flutter_animate/flutter_animate.dart';
@pragma('vm:entry-point')
void appBackgroundDispatcher() {
@ -71,13 +78,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 +121,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 +164,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 +188,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 +259,10 @@ class _AppSplashScreen extends StatefulWidget {
}
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
bool _isBusy = false;
double _initPercentage = 0;
String _phaseText = 'appInitStarting';
void _tryRequestRating() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('first_boot_time')) {
@ -256,12 +291,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 +309,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 +318,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 +335,57 @@ 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
_initPercentage = 0.1;
_setPhaseText('network');
final sn = context.read<SnNetworkProvider>();
await sn.initializeUserAgent();
await sn.setConfigWithNative();
if (!mounted) return;
_initPercentage = 0.2;
_setPhaseText('userdata');
final ua = context.read<UserProvider>();
await ua.initialize();
if (!mounted) return;
_initPercentage = 0.3;
_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;
_initPercentage = 0.9;
_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();
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();
_initPercentage = 1;
_setPhaseText('done');
} catch (_) {}
_playIntro();
}
} catch (err) {
if (!mounted) return;
await context.showErrorDialog(err);
@ -339,28 +401,42 @@ 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 date = DateTime.now();
final player = AudioPlayer(playerId: 'launch-done-player');
await player.play(
(cfg.aprilFoolFeatures && date.month == 4 && date.day == 1)
? AssetSource('audio/sfx/launch-intro.mp3')
: AssetSource('audio/sfx/launch-done.mp3'),
volume: 0.8,
ctx: AudioContext(
android: AudioContextAndroid(
contentType: AndroidContentType.sonification,
usageType: AndroidUsageType.notificationEvent,
),
iOS: AudioContextIOS(category: AVAudioSessionCategory.ambient),
),
mode: PlayerMode.lowLatency,
);
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 +464,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,20 +473,28 @@ 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();
_hotkeyInitialization();
_notifyInitialization();
_initialize().then((_) {
_postInitialization();
_tryRequestRating();
_checkForUpdate();
});
try {
_trayInitialization();
_hotkeyInitialization();
_notifyInitialization();
_initialize().then((_) {
_postInitialization();
_tryRequestRating();
_checkForUpdate();
setState(() => _isBusy = false);
}).catchError((err) {
logging.error('[Bootstrap] Unable to initialize app', err);
setState(() => _isBusy = false);
});
} catch (err) {
logging.error('[Bootstrap] Unable to initialize (pre-stage) app', err);
}
}
Future<AppExitResponse> _onExitRequested() async {
@ -421,6 +503,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
}
void _quitApp() {
_saveWindowSize();
_appLifecycleListener?.dispose();
if (Platform.isWindows) {
appWindow.close();
@ -501,7 +584,13 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
}
});
return SizeChangedLayoutNotifier(
child: widget.child,
child: _isBusy
? _AppLoadingScreen(
isBusy: _isBusy,
initPercentage: _initPercentage,
phaseText: _phaseText,
)
: widget.child,
);
},
),
@ -509,3 +598,234 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
);
}
}
class _AppLoadingScreen extends StatelessWidget {
const _AppLoadingScreen({
required this.isBusy,
required this.initPercentage,
required this.phaseText,
});
final bool isBusy;
final double initPercentage;
final String phaseText;
@override
Widget build(BuildContext context) {
if (ResponsiveScaffold.getIsExpand(context)) {
return 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: Row(
children: [
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: initPercentage),
duration: Duration(milliseconds: 300),
builder: (context, value, _) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${(value * 100).toStringAsFixed(0)}%')
.padding(left: 32, bottom: 4),
LinearProgressIndicator(
value: value,
borderRadius: const BorderRadius.all(
Radius.circular(0),
),
stopIndicatorColor: Colors.transparent,
backgroundColor: Colors.transparent,
),
const Gap(24),
],
),
),
),
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: initPercentage),
duration: Duration(milliseconds: 300),
builder: (context, value, _) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${(value * 100).toStringAsFixed(0)}%')
.padding(right: 32, bottom: 4),
Transform.flip(
flipX: true,
child: LinearProgressIndicator(
value: value,
borderRadius: const BorderRadius.all(
Radius.circular(0),
),
stopIndicatorColor: Colors.transparent,
backgroundColor: Colors.transparent,
),
),
const Gap(24),
],
),
),
),
],
),
),
Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 240, minWidth: 160),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 24),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.surface.withOpacity(0.85),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 3,
),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'splashScreenServer',
style: GoogleFonts.notoSerifHk(height: 1, fontSize: 11),
textAlign: TextAlign.center,
).tr().opacity(0.85),
Text(
'splashScreenServerName',
style: GoogleFonts.notoSerifHk(
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
).tr().opacity(0.85),
Text.rich(
TextSpan(
text: '#',
style: GoogleFonts.notoSerifHk(),
children: [
TextSpan(
text: '0',
style: GoogleFonts.notoSerifHk(
fontSize: 80,
fontWeight: FontWeight.bold,
),
),
],
),
textAlign: TextAlign.center,
).padding(vertical: 16),
],
),
),
),
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.of(context).size.height * 0.2,
child: Column(
children: [
Text(
phaseText,
textAlign: TextAlign.center,
),
AnimateWidgetExtensions(Text(
'splashScreenCaption',
textAlign: TextAlign.center,
).tr())
.animate(onPlay: (e) => e.repeat())
.fadeIn(duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
],
),
),
Positioned(
bottom: 8,
left: 16,
right: 16,
child: Row(
children: [
Image.asset(
'assets/icon/icon.png',
width: 40,
height: 40,
color: Theme.of(context).colorScheme.onSurface,
).padding(all: 4),
const Gap(4),
Text('Solar Network').bold(),
Expanded(child: const SizedBox()),
AppVersionLabel(),
const Gap(12),
],
),
),
],
),
);
}
return 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),
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: initPercentage),
duration: Duration(milliseconds: 300),
builder: (context, value, _) =>
LinearProgressIndicator(value: value),
),
],
),
),
),
],
),
);
}
}

View File

@ -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(

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:livekit_noise_filter/livekit_noise_filter.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
@ -131,10 +132,14 @@ class ChatCallProvider extends ChangeNotifier {
void initRoom() {
initHardware();
final timeout = const Duration(seconds: 60);
_room = Room(
roomOptions: const RoomOptions(
roomOptions: RoomOptions(
dynacast: true,
adaptiveStream: true,
defaultAudioCaptureOptions: AudioCaptureOptions(
processor: LiveKitNoiseFilter(),
),
defaultAudioPublishOptions: AudioPublishOptions(
name: 'call_voice',
stream: 'call_stream',
@ -154,6 +159,16 @@ class ChatCallProvider extends ChangeNotifier {
params: VideoParametersPresets.h1080_169,
),
),
connectOptions: ConnectOptions(
autoSubscribe: true,
timeouts: Timeouts(
connection: timeout,
debounce: timeout,
publish: timeout,
peerConnection: timeout,
iceRestart: timeout,
),
),
);
_listener = _room.createListener();
WakelockPlus.enable();

View File

@ -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();

View File

@ -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 = [];

View File

@ -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,25 @@ 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,
ctx: AudioContext(
android: AudioContextAndroid(
contentType: AndroidContentType.sonification,
usageType: AndroidUsageType.notificationEvent,
),
iOS: AudioContextIOS(category: AVAudioSessionCategory.ambient),
),
mode: PlayerMode.lowLatency,
);
}
}
if (notification.topic == 'messaging.message' &&
skippableNotifyChannel != null) {
if (notification.metadata['channel_id'] != null &&

View File

@ -1,144 +1,31 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/poll.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart';
class SnPostContentProvider {
late final SnNetworkProvider _sn;
late final UserDirectoryProvider _ud;
late final SnAttachmentProvider _attach;
late final SnRealmProvider _realm;
SnPostContentProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ud = context.read<UserDirectoryProvider>();
_attach = context.read<SnAttachmentProvider>();
_realm = context.read<SnRealmProvider>();
}
Future<SnPoll> _fetchPoll(int id) async {
final resp = await _sn.client.get('/cgi/co/polls/$id');
return SnPoll.fromJson(resp.data);
}
Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
Set<String> rids = {};
Set<int> uids = {};
for (var i = 0; i < out.length; i++) {
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
if (out[i].body['thumbnail'] != null) {
rids.add(out[i].body['thumbnail']);
}
if (out[i].body['video'] != null) {
rids.add(out[i].body['video']);
}
if (out[i].repostTo != null) {
out[i] = out[i].copyWith(
repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
);
}
if (out[i].publisher.type == 0) {
uids.add(out[i].publisher.accountId);
}
}
final attachments = await _attach.getMultiple(rids.toList());
for (var i = 0; i < out.length; i++) {
SnPoll? poll;
SnRealm? realm;
if (out[i].pollId != null) {
poll = await _fetchPoll(out[i].pollId!);
}
if (out[i].realmId != null) {
realm = await _realm.getRealm(out[i].realmId!);
}
out[i] = out[i].copyWith(
preload: SnPostPreload(
thumbnail: attachments
.where((ele) => ele?.rid == out[i].body['thumbnail'])
.firstOrNull,
attachments: attachments
.where((ele) =>
out[i].body['attachments']?.contains(ele?.rid) ?? false)
.toList(),
video: attachments
.where((ele) => ele?.rid == out[i].body['video'])
.firstOrNull,
poll: poll,
realm: realm,
),
);
}
uids.addAll(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
await _ud.listAccount(uids);
return out;
}
Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
Set<String> rids = {};
Set<int> uids = {};
rids.addAll(out.body['attachments']?.cast<String>() ?? []);
if (out.body['thumbnail'] != null) {
rids.add(out.body['thumbnail']);
}
if (out.body['video'] != null) {
rids.add(out.body['video']);
}
if (out.repostTo != null) {
out = out.copyWith(
repostTo: await _preloadRelatedDataSingle(out.repostTo!),
);
}
if (out.publisher.type == 0) {
uids.add(out.publisher.accountId);
}
final attachments = await _attach.getMultiple(rids.toList());
SnPoll? poll;
SnRealm? realm;
if (out.pollId != null) {
poll = await _fetchPoll(out.pollId!);
}
if (out.realmId != null) {
realm = await _realm.getRealm(out.realmId!);
}
out = out.copyWith(
preload: SnPostPreload(
thumbnail: attachments
.where((ele) => ele?.rid == out.body['thumbnail'])
.firstOrNull,
attachments: attachments
.where(
(ele) => out.body['attachments']?.contains(ele?.rid) ?? false)
.toList(),
video: attachments
.where((ele) => ele?.rid == out.body['video'])
.firstOrNull,
poll: poll,
realm: realm,
),
);
uids.addAll(
attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
await _ud.listAccount(uids);
return out;
}
Future<List<SnPost>> listRecommendations() async {
final resp = await _sn.client.get('/cgi/co/recommendations');
final resp = await _sn.client.get(
'/cgi/co/recommendations',
options: Options(headers: {
'X-API-Version': '2',
}),
);
final out = _preloadRelatedDataInBatch(
List.from(resp.data.map((ele) => SnPost.fromJson(ele))),
);
@ -202,6 +89,9 @@ class SnPostContentProvider {
if (realm != null) 'realm': realm,
if (channel != null) 'channel': channel,
},
options: Options(headers: {
'X-API-Version': '2',
}),
);
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
@ -215,11 +105,16 @@ class SnPostContentProvider {
int take = 10,
int offset = 0,
}) async {
final resp = await _sn.client
.get('/cgi/co/posts/$parentId/replies', queryParameters: {
'take': take,
'offset': offset,
});
final resp = await _sn.client.get(
'/cgi/co/posts/$parentId/replies',
queryParameters: {
'take': take,
'offset': offset,
},
options: Options(headers: {
'X-API-Version': '2',
}),
);
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
@ -234,13 +129,20 @@ class SnPostContentProvider {
Iterable<String>? tags,
Iterable<String>? categories,
}) async {
final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: {
'take': take,
'offset': offset,
'probe': searchTerm,
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
});
final resp = await _sn.client.get(
'/cgi/co/posts/search',
queryParameters: {
'take': take,
'offset': offset,
'probe': searchTerm,
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
if (categories?.isNotEmpty ?? false)
'categories': categories!.join(','),
},
options: Options(headers: {
'X-API-Version': '2',
}),
);
final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
);
@ -249,7 +151,12 @@ class SnPostContentProvider {
}
Future<SnPost> getPost(dynamic id) async {
final resp = await _sn.client.get('/cgi/co/posts/$id');
final resp = await _sn.client.get(
'/cgi/co/posts/$id',
options: Options(headers: {
'X-API-Version': '2',
}),
);
final out = _preloadRelatedDataSingle(
SnPost.fromJson(resp.data),
);

View File

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

View File

@ -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))),
),
),
);
}
}
}

View File

@ -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(

View File

@ -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);

View File

@ -1,16 +1,19 @@
import 'package:animations/animations.dart';
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 +40,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';
@ -49,16 +53,6 @@ import 'package:surface/types/post.dart';
import 'package:surface/widgets/about.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
Widget _fadeThroughTransition(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent,
child: child,
);
}
final _appRoutes = [
GoRoute(
path: '/',
@ -67,8 +61,8 @@ final _appRoutes = [
),
GoRoute(
path: '/posts',
name: 'explore',
builder: (context, state) => const ExploreScreen(),
name: 'posts',
builder: (_, __) => const SizedBox.shrink(),
routes: [
GoRoute(
path: '/draft',
@ -106,145 +100,209 @@ 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'],
),
),
],
),
],
),
GoRoute(
path: '/realm',
name: 'realm',
pageBuilder: (context, state) => CustomTransitionPage(
transitionsBuilder: _fadeThroughTransition,
child: const RealmScreen(),
),
builder: (context, state) => const RealmScreen(),
routes: [
GoRoute(
path: '/:alias/community',
name: 'realmCommunity',
builder: (context, state) => RealmCommunityScreen(
alias: state.pathParameters['alias']!,
),
),
GoRoute(
path: '/manage',
name: 'realmManage',

View File

@ -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: ResponsiveScaffold.getIsExpand(context),
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();
}
});
},

View File

@ -59,6 +59,7 @@ class _ActionEventScreenState extends State<ActionEventScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountActionEvent').tr(),

View File

@ -91,6 +91,7 @@ class _AccountAuthTicketState extends State<AccountAuthTicket> {
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountAuthTickets').tr(),

View File

@ -70,6 +70,7 @@ class _AccountBadgesScreenState extends State<AccountBadgesScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
title: Text('screenAccountBadges').tr(),
),

View File

@ -69,6 +69,7 @@ class _AccountContactMethodState extends State<AccountContactMethod> {
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountContactMethods').tr(),

View File

@ -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: ResponsiveScaffold.getIsExpand(context),
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,

View File

@ -37,6 +37,7 @@ class _KeyPairScreenState extends State<KeyPairScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
title: Text('screenKeyPairs').tr(),
),

View File

@ -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: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountSettingsNotify').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.save),
title: Text('save').tr(),
enabled: !_isBusy,
onTap: () {
_savePreferences();
},
),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: kNotifyTopicMap.length,
itemBuilder: (context, index) {
final element = kNotifyTopicMap.entries.elementAt(index);
return CheckboxListTile(
title: Text(element.value),
subtitle: Text(
element.key,
style: GoogleFonts.robotoMono(fontSize: 12),
),
value: _config[element.key] ?? true,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onChanged: (value) {
setState(() {
_config[element.key] = value ?? false;
});
},
);
},
),
),
],
),
);
}
}

View File

@ -0,0 +1,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: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: const PageBackButton(),
title: Text('accountSettingsSecurity').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.save),
title: Text('save').tr(),
enabled: !_isBusy,
onTap: () {
_savePreferences();
},
),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
ListTile(
title: Text('authMaximumAuthSteps').tr(),
subtitle: Text('authMaximumAuthStepsDescription')
.plural(_config['maximum_auth_steps'] ?? 2),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Symbols.remove),
onPressed: () {
if (_config['maximum_auth_steps'] > 1) {
setState(() => _config['maximum_auth_steps']--);
}
},
),
IconButton(
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Symbols.add),
onPressed: () {
if (_config['maximum_auth_steps'] < 99) {
setState(() => _config['maximum_auth_steps']++);
}
},
),
],
),
),
CheckboxListTile(
title: Text('authAlwaysRisky').tr(),
subtitle: Text('authAlwaysRiskyDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
value: _config['always_risky'] ?? false,
onChanged: (value) {
setState(() => _config['always_risky'] = value);
},
),
],
),
),
],
),
);
}
}

View File

@ -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: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: const PageBackButton(),
title: Text('screenAccountProfileEdit').tr()),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -251,13 +263,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 9,
aspectRatio: 16 / 7,
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(),
),
),
],

View File

@ -1,3 +1,4 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
@ -60,6 +61,21 @@ final Map<String, (String, IconData, Color)> kBadgesMeta = {
Symbols.thumb_up,
Colors.lightGreen,
),
'programs.developers': (
'badgeProgramDeveloper',
Symbols.code,
Colors.blue,
),
'programs.stellar': (
'badgeProgramStellar',
Symbols.family_star,
Colors.orange,
),
'programs.moderator': (
'badgeProgramModerator',
Symbols.sword_rose,
Colors.blue,
),
};
class UserScreen extends StatefulWidget {
@ -227,7 +243,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 +505,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),

View 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: ResponsiveScaffold.getIsExpand(context),
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),
],
),
),
);
}
}

View File

@ -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: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenAccountPublisherEdit').tr()),
body: SingleChildScrollView(
child: Column(
children: [
@ -206,13 +214,16 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 9,
aspectRatio: 16 / 7,
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(

View File

@ -25,7 +25,8 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
leading: const PageBackButton(),
title: Text('screenAccountPublisherNew').tr(),

View File

@ -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: ResponsiveScaffold.getIsExpand(context),
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) => [

View 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: ResponsiveScaffold.getIsExpand(context),
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),
),
),
),
],
),
);
}
}

View File

@ -37,6 +37,7 @@ class AccountSettingsScreen extends StatelessWidget {
final ua = context.watch<UserProvider>();
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
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(),

View File

@ -8,9 +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/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';
@ -54,7 +52,6 @@ class _AlbumScreenState extends State<AlbumScreen> {
try {
final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>();
final resp = await sn.client.get('/cgi/uc/attachments', queryParameters: {
'take': 10,
'offset': _attachments.length,
@ -65,8 +62,6 @@ class _AlbumScreenState extends State<AlbumScreen> {
_attachments.addAll(attachments);
_heroTags.addAll(_attachments.map((_) => uuid.v4()));
await ud.listAccount(attachments.map((e) => e.accountId).toSet());
_totalCount = resp.data['count'] as int?;
} catch (err) {
if (!mounted) return;
@ -106,7 +101,7 @@ class _AlbumScreenState extends State<AlbumScreen> {
controller: _scrollController,
slivers: [
SliverAppBar(
leading: AutoAppBarLeading(),
leading: PageBackButton(),
title: Text('screenAlbum').tr(),
),
SliverToBoxAdapter(
@ -119,7 +114,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 +125,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(),

View File

@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/captcha/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,

View File

@ -0,0 +1,3 @@
import 'package:flutter/foundation.dart' show kIsWeb;
export 'captcha_native.dart' if (kIsWeb) 'captcha_web.dart';

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

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

View File

@ -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: ResponsiveScaffold.getIsExpand(context),
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 / 7,
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(),
);

View File

@ -32,17 +32,16 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
}
}
Widget _buildListLayout() {
Widget _buildMeetLayout() {
final call = context.read<ChatCallProvider>();
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,
participant: call.focusTrack!,
onTap: () {},
)
: const SizedBox.shrink(),
),
@ -61,22 +60,18 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8, left: 8),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
isFixedAvatar: true,
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid != call.focusTrack?.participant.sid) {
call.setFocusTrack(track);
}
},
),
return SizedBox(
height: 128,
width: 128,
child: InteractiveParticipantWidget(
participant: track,
avatarSize: 32,
onTap: () {
if (track.participant.sid !=
call.focusTrack?.participant.sid) {
call.setFocusTrack(track);
}
},
),
);
},
@ -87,46 +82,26 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
);
}
Widget _buildGridLayout() {
Widget _buildListLayout() {
final call = context.read<ChatCallProvider>();
return LayoutBuilder(builder: (context, constraints) {
double screenWidth = constraints.maxWidth;
double screenHeight = constraints.maxHeight;
int columns = (math.sqrt(call.participantTracks.length)).ceil();
int rows = (call.participantTracks.length / columns).ceil();
double tileWidth = screenWidth / columns;
double tileHeight = screenHeight / rows;
return StyledWidget(GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
childAspectRatio: tileWidth / tileHeight,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: math.max(0, call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = call.participantTracks[index];
return Card(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
color: Theme.of(context).colorScheme.surfaceContainerHigh.withOpacity(0.75),
participant: track,
onTap: () {
if (track.participant.sid != call.focusTrack?.participant.sid) {
call.setFocusTrack(track);
}
},
),
),
);
},
)).padding(all: 8);
});
return LayoutBuilder(
builder: (context, constraints) {
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: math.max(0, call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = call.participantTracks[index];
return InteractiveParticipantWidget(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
isList: true,
avatarSize: 24,
participant: track,
);
},
);
},
);
}
@override
@ -149,6 +124,7 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
listenable: call,
builder: (context, _) {
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
title: RichText(
textAlign: TextAlign.center,
@ -169,117 +145,129 @@ class _CallRoomScreenState extends State<CallRoomScreen> {
]),
),
),
body: GestureDetector(
behavior: HitTestBehavior.translucent,
child: Column(
children: [
body: Column(
children: [
SizedBox(
width: MediaQuery.of(context).size.width,
height: 64,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Builder(builder: (context) {
final call = context.read<ChatCallProvider>();
final connectionQuality =
call.room.localParticipant?.connectionQuality ??
livekit.ConnectionQuality.unknown;
return Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
call.channel?.name ?? 'unknown'.tr(),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const Gap(6),
Text(call.lastDuration.toString())
],
),
Row(
children: [
Text(
{
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)
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,
}[connectionQuality],
color: {
livekit.ConnectionQuality.excellent:
Colors.green,
livekit.ConnectionQuality.good:
Colors.orange,
livekit.ConnectionQuality.poor:
Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
padding: EdgeInsets.zero,
),
).padding(all: 3),
],
),
],
),
);
}),
Row(
children: [
IconButton(
icon: _layoutMode == 0
? const Icon(Icons.view_list)
: const Icon(Icons.grid_view),
onPressed: () {
_switchLayout();
},
),
],
),
],
).padding(left: 20, right: 16),
),
Expanded(
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: Builder(
builder: (context) {
switch (_layoutMode) {
case 1:
return _buildListLayout();
default:
return _buildMeetLayout();
}
},
),
),
),
if (call.room.localParticipant != null)
SizedBox(
width: MediaQuery.of(context).size.width,
height: 64,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Builder(builder: (context) {
final call = context.read<ChatCallProvider>();
final connectionQuality =
call.room.localParticipant?.connectionQuality ?? livekit.ConnectionQuality.unknown;
return Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
call.channel?.name ?? 'unknown'.tr(),
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const Gap(6),
Text(call.lastDuration.toString())
],
),
Row(
children: [
Text(
{
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)
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,
}[connectionQuality],
color: {
livekit.ConnectionQuality.excellent: Colors.green,
livekit.ConnectionQuality.good: Colors.orange,
livekit.ConnectionQuality.poor: Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
).padding(all: 3),
],
),
],
),
);
}),
Row(
children: [
IconButton(
icon: _layoutMode == 0 ? const Icon(Icons.view_list) : const Icon(Icons.grid_view),
onPressed: () {
_switchLayout();
},
),
],
),
],
).padding(left: 20, right: 16),
),
Expanded(
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: Builder(
builder: (context) {
switch (_layoutMode) {
case 1:
return _buildGridLayout();
default:
return _buildListLayout();
}
},
),
child: ControlsWidget(
call.room,
call.room.localParticipant!,
),
),
if (call.room.localParticipant != null)
SizedBox(
width: MediaQuery.of(context).size.width,
child: ControlsWidget(
call.room,
call.room.localParticipant!,
),
),
],
),
onTap: () {},
Gap(MediaQuery.of(context).padding.bottom),
],
),
);
});

View File

@ -220,6 +220,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
final isOwned = ua.isAuthorized && _channel?.accountId == ua.user?.id;
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
appBar: AppBar(
title: _channel != null ? Text(_channel!.name) : Text('loading').tr(),
),

View File

@ -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: ResponsiveScaffold.getIsExpand(context),
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(

View File

@ -52,8 +52,10 @@ class ChatRoomScreen extends StatefulWidget {
class _ChatRoomScreenState extends State<ChatRoomScreen> {
bool _isBusy = false;
bool _isCalling = false;
bool _isJoining = false;
SnChannel? _channel;
SnChannelMember? _currentMember;
SnChannelMember? _otherMember;
SnChatCall? _ongoingCall;
@ -67,7 +69,24 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
StreamSubscription? _wsSubscription;
// TODO fetch user identity and ask them to join the channel or not
Future<void> _joinChannel() async {
try {
setState(() => _isJoining = true);
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client
.post('/cgi/im/channels/${_channel!.keyPath}/members', data: {
'related': ua.user?.name,
});
_initializeChat();
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isJoining = true);
}
}
Future<void> _fetchChannel() async {
setState(() => _isBusy = true);
@ -76,6 +95,12 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
_channel = await chan.getChannel('${widget.scope}:${widget.alias}');
if (!mounted || _channel == null) return;
final ct = context.read<ChatChannelProvider>();
try {
_currentMember = await ct.getChannelProfile(_channel!);
} catch (_) {}
if (!mounted || _currentMember == null) return;
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
if (_channel!.type == 1) {
@ -204,11 +229,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
}
@override
void initState() {
super.initState();
_messageController = ChatMessageController(context);
Future<void> _initializeChat() async {
_fetchChannel().then((_) async {
if (_currentMember == null) return;
await _messageController.initialize(_channel!);
if (widget.extra != null) {
@ -230,6 +253,13 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
_fetchOngoingCall(),
]);
});
}
@override
void initState() {
super.initState();
_messageController = ChatMessageController(context);
_initializeChat();
_wsSubscription = _ws.pk.stream.listen((event) {
switch (event.method) {
@ -274,6 +304,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
final ud = context.read<UserDirectoryProvider>();
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
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(

View File

@ -157,6 +157,7 @@ class _ExploreScreenState extends State<ExploreScreen>
Widget build(BuildContext context) {
final cfg = context.watch<ConfigProvider>();
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
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(

View File

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

View File

@ -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)

View File

@ -11,13 +11,11 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/notification.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/notification.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/markdown_content.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';
import '../providers/userinfo.dart';
@ -63,7 +61,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 +99,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 +124,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 +146,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 +159,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 +175,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,46 +198,55 @@ 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',
'interactive.subscription',
].contains(nty.topic) &&
nty.metadata['related_post'] != null)
GestureDetector(
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: Theme.of(context).dividerColor, width: 1),
),
child: PostItem(
data: SnPost.fromJson(nty.metadata['related_post']!),
showComments: false,
showReactions: false,
showMenu: false,
TextButton(
style: ButtonStyle(
padding: WidgetStatePropertyAll(
EdgeInsets.zero,
),
visualDensity: VisualDensity.compact,
),
onTap: () {
child: Text('postReadMore').tr(),
onPressed: () {
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 +256,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
IconButton(
icon: const Icon(Symbols.check),
padding: EdgeInsets.all(0),
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
onPressed: _isSubmitting ? null : () => _markOneAsRead(nty),
visualDensity:
const VisualDensity(horizontal: -4, vertical: -4),
onPressed:
_isSubmitting ? null : () => _markOneAsRead(nty),
),
],
).padding(horizontal: 16);

View File

@ -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: ResponsiveScaffold.getIsExpand(context),
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)),
],
),
);
}

View File

@ -1,7 +1,7 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart';
@ -77,7 +77,8 @@ class _PostDraftBoxState extends State<PostDraftBox> {
},
);
},
separatorBuilder: (_, __) => const Gap(8),
separatorBuilder: (_, __) =>
const Divider().padding(vertical: 2),
),
),
),

View File

@ -45,12 +45,14 @@ class PostEditorExtra {
final String? title;
final String? description;
final List<PostWriteMedia>? attachments;
final SnRealm? realm;
const PostEditorExtra({
this.text,
this.title,
this.description,
this.attachments,
this.realm,
});
}
@ -263,6 +265,7 @@ class _PostEditorScreenState extends State<PostEditorScreen>
_writeController.descriptionController.text =
widget.extraProps!.description ?? '';
_writeController.addAttachments(widget.extraProps!.attachments ?? []);
_writeController.setRealm(widget.extraProps!.realm);
}
}

View File

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

View File

@ -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,21 @@ 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,
useReplace: true,
onChanged: (ele) {
_posts[idx] = ele;
setState(() {});
},
onDeleted: () {
_fetchPosts();
},
).padding(all: 8),
).padding(
all: 24,
bottom:

View File

@ -34,9 +34,11 @@ class PostPublisherScreen extends StatefulWidget {
State<PostPublisherScreen> createState() => _PostPublisherScreenState();
}
class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTickerProviderStateMixin {
class _PostPublisherScreenState extends State<PostPublisherScreen>
with SingleTickerProviderStateMixin {
late final ScrollController _scrollController = ScrollController();
late final TabController _tabController = TabController(length: 3, vsync: this);
late final TabController _tabController =
TabController(length: 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: ResponsiveScaffold.getIsExpand(context),
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),
);
}
}

View File

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

View File

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

View File

@ -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(

View File

@ -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(() {});
},
)
],
),
],

View File

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

View File

@ -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(

View File

@ -45,10 +45,9 @@ class _WalletScreenState extends State<WalletScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
noBackground: ResponsiveScaffold.getIsExpand(context),
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),

View File

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

View File

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

View File

@ -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,
};

View File

@ -1,4 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:surface/types/account.dart';
part 'attachment.freezed.dart';
@ -39,6 +40,7 @@ abstract class SnAttachment with _$SnAttachment {
required int? refId,
required SnAttachmentPool? pool,
required int? poolId,
required SnAccount? account,
required int accountId,
int? thumbnailId,
SnAttachment? thumbnail,
@ -49,7 +51,8 @@ abstract class SnAttachment with _$SnAttachment {
@Default({}) Map<String, dynamic> metadata,
}) = _SnAttachment;
factory SnAttachment.fromJson(Map<String, Object?> json) => _$SnAttachmentFromJson(json);
factory SnAttachment.fromJson(Map<String, Object?> json) =>
_$SnAttachmentFromJson(json);
Map<String, dynamic> get data => {
...metadata,
@ -85,7 +88,8 @@ abstract class SnAttachmentFragment with _$SnAttachmentFragment {
@Default([]) List<String> fileChunksMissing,
}) = _SnAttachmentFragment;
factory SnAttachmentFragment.fromJson(Map<String, Object?> json) => _$SnAttachmentFragmentFromJson(json);
factory SnAttachmentFragment.fromJson(Map<String, Object?> json) =>
_$SnAttachmentFragmentFromJson(json);
SnMediaType get mediaType => switch (mimetype.split('/').firstOrNull) {
'image' => SnMediaType.image,
@ -109,7 +113,8 @@ abstract class SnAttachmentPool with _$SnAttachmentPool {
required int? accountId,
}) = _SnAttachmentPool;
factory SnAttachmentPool.fromJson(Map<String, Object?> json) => _$SnAttachmentPoolFromJson(json);
factory SnAttachmentPool.fromJson(Map<String, Object?> json) =>
_$SnAttachmentPoolFromJson(json);
}
@freezed
@ -122,7 +127,8 @@ abstract class SnAttachmentDestination with _$SnAttachmentDestination {
required bool isBoost,
}) = _SnAttachmentDestination;
factory SnAttachmentDestination.fromJson(Map<String, Object?> json) => _$SnAttachmentDestinationFromJson(json);
factory SnAttachmentDestination.fromJson(Map<String, Object?> json) =>
_$SnAttachmentDestinationFromJson(json);
}
@freezed
@ -139,7 +145,8 @@ abstract class SnAttachmentBoost with _$SnAttachmentBoost {
required int account,
}) = _SnAttachmentBoost;
factory SnAttachmentBoost.fromJson(Map<String, Object?> json) => _$SnAttachmentBoostFromJson(json);
factory SnAttachmentBoost.fromJson(Map<String, Object?> json) =>
_$SnAttachmentBoostFromJson(json);
}
@freezed
@ -158,7 +165,8 @@ abstract class SnSticker with _$SnSticker {
required int accountId,
}) = _SnSticker;
factory SnSticker.fromJson(Map<String, Object?> json) => _$SnStickerFromJson(json);
factory SnSticker.fromJson(Map<String, Object?> json) =>
_$SnStickerFromJson(json);
}
@freezed
@ -175,7 +183,8 @@ abstract class SnStickerPack with _$SnStickerPack {
required int accountId,
}) = _SnStickerPack;
factory SnStickerPack.fromJson(Map<String, Object?> json) => _$SnStickerPackFromJson(json);
factory SnStickerPack.fromJson(Map<String, Object?> json) =>
_$SnStickerPackFromJson(json);
}
@freezed
@ -186,5 +195,6 @@ abstract class SnAttachmentBilling with _$SnAttachmentBilling {
required double includedRatio,
}) = _SnAttachmentBilling;
factory SnAttachmentBilling.fromJson(Map<String, Object?> json) => _$SnAttachmentBillingFromJson(json);
factory SnAttachmentBilling.fromJson(Map<String, Object?> json) =>
_$SnAttachmentBillingFromJson(json);
}

View File

@ -38,6 +38,7 @@ mixin _$SnAttachment {
int? get refId;
SnAttachmentPool? get pool;
int? get poolId;
SnAccount? get account;
int get accountId;
int? get thumbnailId;
SnAttachment? get thumbnail;
@ -98,6 +99,7 @@ mixin _$SnAttachment {
(identical(other.refId, refId) || other.refId == refId) &&
(identical(other.pool, pool) || other.pool == pool) &&
(identical(other.poolId, poolId) || other.poolId == poolId) &&
(identical(other.account, account) || other.account == account) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.thumbnailId, thumbnailId) ||
@ -140,6 +142,7 @@ mixin _$SnAttachment {
refId,
pool,
poolId,
account,
accountId,
thumbnailId,
thumbnail,
@ -152,7 +155,7 @@ mixin _$SnAttachment {
@override
String toString() {
return 'SnAttachment(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, rid: $rid, uuid: $uuid, size: $size, name: $name, alt: $alt, mimetype: $mimetype, hash: $hash, destination: $destination, refCount: $refCount, contentRating: $contentRating, qualityRating: $qualityRating, cleanedAt: $cleanedAt, isAnalyzed: $isAnalyzed, isSelfRef: $isSelfRef, isIndexable: $isIndexable, ref: $ref, refId: $refId, pool: $pool, poolId: $poolId, accountId: $accountId, thumbnailId: $thumbnailId, thumbnail: $thumbnail, compressedId: $compressedId, compressed: $compressed, boosts: $boosts, usermeta: $usermeta, metadata: $metadata)';
return 'SnAttachment(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, rid: $rid, uuid: $uuid, size: $size, name: $name, alt: $alt, mimetype: $mimetype, hash: $hash, destination: $destination, refCount: $refCount, contentRating: $contentRating, qualityRating: $qualityRating, cleanedAt: $cleanedAt, isAnalyzed: $isAnalyzed, isSelfRef: $isSelfRef, isIndexable: $isIndexable, ref: $ref, refId: $refId, pool: $pool, poolId: $poolId, account: $account, accountId: $accountId, thumbnailId: $thumbnailId, thumbnail: $thumbnail, compressedId: $compressedId, compressed: $compressed, boosts: $boosts, usermeta: $usermeta, metadata: $metadata)';
}
}
@ -186,6 +189,7 @@ abstract mixin class $SnAttachmentCopyWith<$Res> {
int? refId,
SnAttachmentPool? pool,
int? poolId,
SnAccount? account,
int accountId,
int? thumbnailId,
SnAttachment? thumbnail,
@ -197,6 +201,7 @@ abstract mixin class $SnAttachmentCopyWith<$Res> {
$SnAttachmentCopyWith<$Res>? get ref;
$SnAttachmentPoolCopyWith<$Res>? get pool;
$SnAccountCopyWith<$Res>? get account;
$SnAttachmentCopyWith<$Res>? get thumbnail;
$SnAttachmentCopyWith<$Res>? get compressed;
}
@ -236,6 +241,7 @@ class _$SnAttachmentCopyWithImpl<$Res> implements $SnAttachmentCopyWith<$Res> {
Object? refId = freezed,
Object? pool = freezed,
Object? poolId = freezed,
Object? account = freezed,
Object? accountId = null,
Object? thumbnailId = freezed,
Object? thumbnail = freezed,
@ -338,6 +344,10 @@ class _$SnAttachmentCopyWithImpl<$Res> implements $SnAttachmentCopyWith<$Res> {
? _self.poolId
: poolId // ignore: cast_nullable_to_non_nullable
as int?,
account: freezed == account
? _self.account
: account // ignore: cast_nullable_to_non_nullable
as SnAccount?,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
@ -401,6 +411,20 @@ class _$SnAttachmentCopyWithImpl<$Res> implements $SnAttachmentCopyWith<$Res> {
});
}
/// Create a copy of SnAttachment
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
}
/// Create a copy of SnAttachment
/// with the given fields replaced by the non-null parameter values.
@override
@ -457,6 +481,7 @@ class _SnAttachment extends SnAttachment {
required this.refId,
required this.pool,
required this.poolId,
required this.account,
required this.accountId,
this.thumbnailId,
this.thumbnail,
@ -521,6 +546,8 @@ class _SnAttachment extends SnAttachment {
@override
final int? poolId;
@override
final SnAccount? account;
@override
final int accountId;
@override
final int? thumbnailId;
@ -612,6 +639,7 @@ class _SnAttachment extends SnAttachment {
(identical(other.refId, refId) || other.refId == refId) &&
(identical(other.pool, pool) || other.pool == pool) &&
(identical(other.poolId, poolId) || other.poolId == poolId) &&
(identical(other.account, account) || other.account == account) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.thumbnailId, thumbnailId) ||
@ -654,6 +682,7 @@ class _SnAttachment extends SnAttachment {
refId,
pool,
poolId,
account,
accountId,
thumbnailId,
thumbnail,
@ -666,7 +695,7 @@ class _SnAttachment extends SnAttachment {
@override
String toString() {
return 'SnAttachment(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, rid: $rid, uuid: $uuid, size: $size, name: $name, alt: $alt, mimetype: $mimetype, hash: $hash, destination: $destination, refCount: $refCount, contentRating: $contentRating, qualityRating: $qualityRating, cleanedAt: $cleanedAt, isAnalyzed: $isAnalyzed, isSelfRef: $isSelfRef, isIndexable: $isIndexable, ref: $ref, refId: $refId, pool: $pool, poolId: $poolId, accountId: $accountId, thumbnailId: $thumbnailId, thumbnail: $thumbnail, compressedId: $compressedId, compressed: $compressed, boosts: $boosts, usermeta: $usermeta, metadata: $metadata)';
return 'SnAttachment(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, rid: $rid, uuid: $uuid, size: $size, name: $name, alt: $alt, mimetype: $mimetype, hash: $hash, destination: $destination, refCount: $refCount, contentRating: $contentRating, qualityRating: $qualityRating, cleanedAt: $cleanedAt, isAnalyzed: $isAnalyzed, isSelfRef: $isSelfRef, isIndexable: $isIndexable, ref: $ref, refId: $refId, pool: $pool, poolId: $poolId, account: $account, accountId: $accountId, thumbnailId: $thumbnailId, thumbnail: $thumbnail, compressedId: $compressedId, compressed: $compressed, boosts: $boosts, usermeta: $usermeta, metadata: $metadata)';
}
}
@ -702,6 +731,7 @@ abstract mixin class _$SnAttachmentCopyWith<$Res>
int? refId,
SnAttachmentPool? pool,
int? poolId,
SnAccount? account,
int accountId,
int? thumbnailId,
SnAttachment? thumbnail,
@ -716,6 +746,8 @@ abstract mixin class _$SnAttachmentCopyWith<$Res>
@override
$SnAttachmentPoolCopyWith<$Res>? get pool;
@override
$SnAccountCopyWith<$Res>? get account;
@override
$SnAttachmentCopyWith<$Res>? get thumbnail;
@override
$SnAttachmentCopyWith<$Res>? get compressed;
@ -757,6 +789,7 @@ class __$SnAttachmentCopyWithImpl<$Res>
Object? refId = freezed,
Object? pool = freezed,
Object? poolId = freezed,
Object? account = freezed,
Object? accountId = null,
Object? thumbnailId = freezed,
Object? thumbnail = freezed,
@ -859,6 +892,10 @@ class __$SnAttachmentCopyWithImpl<$Res>
? _self.poolId
: poolId // ignore: cast_nullable_to_non_nullable
as int?,
account: freezed == account
? _self.account
: account // ignore: cast_nullable_to_non_nullable
as SnAccount?,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
@ -922,6 +959,20 @@ class __$SnAttachmentCopyWithImpl<$Res>
});
}
/// Create a copy of SnAttachment
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
}
/// Create a copy of SnAttachment
/// with the given fields replaced by the non-null parameter values.
@override

View File

@ -39,6 +39,9 @@ _SnAttachment _$SnAttachmentFromJson(Map<String, dynamic> json) =>
? null
: SnAttachmentPool.fromJson(json['pool'] as Map<String, dynamic>),
poolId: (json['pool_id'] as num?)?.toInt(),
account: json['account'] == null
? null
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
thumbnailId: (json['thumbnail_id'] as num?)?.toInt(),
thumbnail: json['thumbnail'] == null
@ -82,6 +85,7 @@ Map<String, dynamic> _$SnAttachmentToJson(_SnAttachment instance) =>
'ref_id': instance.refId,
'pool': instance.pool?.toJson(),
'pool_id': instance.poolId,
'account': instance.account?.toJson(),
'account_id': instance.accountId,
'thumbnail_id': instance.thumbnailId,
'thumbnail': instance.thumbnail?.toJson(),

View File

@ -1,4 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/poll.dart';
import 'package:surface/types/realm.dart';
@ -26,6 +27,7 @@ abstract class SnPost with _$SnPost {
required int? replyId,
required int? repostId,
required int? realmId,
required SnRealm? realm,
required SnPost? replyTo,
required SnPost? repostTo,
required List<int>? visibleUsersList,
@ -43,9 +45,9 @@ abstract class SnPost with _$SnPost {
@Default(0) int totalAggregatedViews,
required int publisherId,
required int? pollId,
required SnPoll? poll,
required SnPublisher publisher,
required SnMetric metric,
SnPostPreload? preload,
}) = _SnPost;
factory SnPost.fromJson(Map<String, Object?> json) => _$SnPostFromJson(json);
@ -146,6 +148,7 @@ abstract class SnPublisher with _$SnPublisher {
required int totalDownvote,
required int? realmId,
required int accountId,
required SnAccount? account,
}) = _SnPublisher;
factory SnPublisher.fromJson(Map<String, Object?> json) =>

View File

@ -30,6 +30,7 @@ mixin _$SnPost {
int? get replyId;
int? get repostId;
int? get realmId;
SnRealm? get realm;
SnPost? get replyTo;
SnPost? get repostTo;
List<int>? get visibleUsersList;
@ -47,9 +48,9 @@ mixin _$SnPost {
int get totalAggregatedViews;
int get publisherId;
int? get pollId;
SnPoll? get poll;
SnPublisher get publisher;
SnMetric get metric;
SnPostPreload? get preload;
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@ -88,6 +89,7 @@ mixin _$SnPost {
(identical(other.repostId, repostId) ||
other.repostId == repostId) &&
(identical(other.realmId, realmId) || other.realmId == realmId) &&
(identical(other.realm, realm) || other.realm == realm) &&
(identical(other.replyTo, replyTo) || other.replyTo == replyTo) &&
(identical(other.repostTo, repostTo) ||
other.repostTo == repostTo) &&
@ -119,10 +121,10 @@ mixin _$SnPost {
(identical(other.publisherId, publisherId) ||
other.publisherId == publisherId) &&
(identical(other.pollId, pollId) || other.pollId == pollId) &&
(identical(other.poll, poll) || other.poll == poll) &&
(identical(other.publisher, publisher) ||
other.publisher == publisher) &&
(identical(other.metric, metric) || other.metric == metric) &&
(identical(other.preload, preload) || other.preload == preload));
(identical(other.metric, metric) || other.metric == metric));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@ -144,6 +146,7 @@ mixin _$SnPost {
replyId,
repostId,
realmId,
realm,
replyTo,
repostTo,
const DeepCollectionEquality().hash(visibleUsersList),
@ -161,14 +164,14 @@ mixin _$SnPost {
totalAggregatedViews,
publisherId,
pollId,
poll,
publisher,
metric,
preload
metric
]);
@override
String toString() {
return 'SnPost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, body: $body, language: $language, alias: $alias, aliasPrefix: $aliasPrefix, tags: $tags, categories: $categories, replies: $replies, replyId: $replyId, repostId: $repostId, realmId: $realmId, replyTo: $replyTo, repostTo: $repostTo, visibleUsersList: $visibleUsersList, invisibleUsersList: $invisibleUsersList, visibility: $visibility, editedAt: $editedAt, pinnedAt: $pinnedAt, lockedAt: $lockedAt, isDraft: $isDraft, publishedAt: $publishedAt, publishedUntil: $publishedUntil, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, totalViews: $totalViews, totalAggregatedViews: $totalAggregatedViews, publisherId: $publisherId, pollId: $pollId, publisher: $publisher, metric: $metric, preload: $preload)';
return 'SnPost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, body: $body, language: $language, alias: $alias, aliasPrefix: $aliasPrefix, tags: $tags, categories: $categories, replies: $replies, replyId: $replyId, repostId: $repostId, realmId: $realmId, realm: $realm, replyTo: $replyTo, repostTo: $repostTo, visibleUsersList: $visibleUsersList, invisibleUsersList: $invisibleUsersList, visibility: $visibility, editedAt: $editedAt, pinnedAt: $pinnedAt, lockedAt: $lockedAt, isDraft: $isDraft, publishedAt: $publishedAt, publishedUntil: $publishedUntil, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, totalViews: $totalViews, totalAggregatedViews: $totalAggregatedViews, publisherId: $publisherId, pollId: $pollId, poll: $poll, publisher: $publisher, metric: $metric)';
}
}
@ -193,6 +196,7 @@ abstract mixin class $SnPostCopyWith<$Res> {
int? replyId,
int? repostId,
int? realmId,
SnRealm? realm,
SnPost? replyTo,
SnPost? repostTo,
List<int>? visibleUsersList,
@ -210,15 +214,16 @@ abstract mixin class $SnPostCopyWith<$Res> {
int totalAggregatedViews,
int publisherId,
int? pollId,
SnPoll? poll,
SnPublisher publisher,
SnMetric metric,
SnPostPreload? preload});
SnMetric metric});
$SnRealmCopyWith<$Res>? get realm;
$SnPostCopyWith<$Res>? get replyTo;
$SnPostCopyWith<$Res>? get repostTo;
$SnPollCopyWith<$Res>? get poll;
$SnPublisherCopyWith<$Res> get publisher;
$SnMetricCopyWith<$Res> get metric;
$SnPostPreloadCopyWith<$Res>? get preload;
}
/// @nodoc
@ -248,6 +253,7 @@ class _$SnPostCopyWithImpl<$Res> implements $SnPostCopyWith<$Res> {
Object? replyId = freezed,
Object? repostId = freezed,
Object? realmId = freezed,
Object? realm = freezed,
Object? replyTo = freezed,
Object? repostTo = freezed,
Object? visibleUsersList = freezed,
@ -265,9 +271,9 @@ class _$SnPostCopyWithImpl<$Res> implements $SnPostCopyWith<$Res> {
Object? totalAggregatedViews = null,
Object? publisherId = null,
Object? pollId = freezed,
Object? poll = freezed,
Object? publisher = null,
Object? metric = null,
Object? preload = freezed,
}) {
return _then(_self.copyWith(
id: null == id
@ -330,6 +336,10 @@ class _$SnPostCopyWithImpl<$Res> implements $SnPostCopyWith<$Res> {
? _self.realmId
: realmId // ignore: cast_nullable_to_non_nullable
as int?,
realm: freezed == realm
? _self.realm
: realm // ignore: cast_nullable_to_non_nullable
as SnRealm?,
replyTo: freezed == replyTo
? _self.replyTo
: replyTo // ignore: cast_nullable_to_non_nullable
@ -398,6 +408,10 @@ class _$SnPostCopyWithImpl<$Res> implements $SnPostCopyWith<$Res> {
? _self.pollId
: pollId // ignore: cast_nullable_to_non_nullable
as int?,
poll: freezed == poll
? _self.poll
: poll // ignore: cast_nullable_to_non_nullable
as SnPoll?,
publisher: null == publisher
? _self.publisher
: publisher // ignore: cast_nullable_to_non_nullable
@ -406,13 +420,23 @@ class _$SnPostCopyWithImpl<$Res> implements $SnPostCopyWith<$Res> {
? _self.metric
: metric // ignore: cast_nullable_to_non_nullable
as SnMetric,
preload: freezed == preload
? _self.preload
: preload // ignore: cast_nullable_to_non_nullable
as SnPostPreload?,
));
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnRealmCopyWith<$Res>? get realm {
if (_self.realm == null) {
return null;
}
return $SnRealmCopyWith<$Res>(_self.realm!, (value) {
return _then(_self.copyWith(realm: value));
});
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@ -441,6 +465,20 @@ class _$SnPostCopyWithImpl<$Res> implements $SnPostCopyWith<$Res> {
});
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPollCopyWith<$Res>? get poll {
if (_self.poll == null) {
return null;
}
return $SnPollCopyWith<$Res>(_self.poll!, (value) {
return _then(_self.copyWith(poll: value));
});
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@ -460,20 +498,6 @@ class _$SnPostCopyWithImpl<$Res> implements $SnPostCopyWith<$Res> {
return _then(_self.copyWith(metric: value));
});
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPostPreloadCopyWith<$Res>? get preload {
if (_self.preload == null) {
return null;
}
return $SnPostPreloadCopyWith<$Res>(_self.preload!, (value) {
return _then(_self.copyWith(preload: value));
});
}
}
/// @nodoc
@ -495,6 +519,7 @@ class _SnPost extends SnPost {
required this.replyId,
required this.repostId,
required this.realmId,
required this.realm,
required this.replyTo,
required this.repostTo,
required final List<int>? visibleUsersList,
@ -512,9 +537,9 @@ class _SnPost extends SnPost {
this.totalAggregatedViews = 0,
required this.publisherId,
required this.pollId,
required this.poll,
required this.publisher,
required this.metric,
this.preload})
required this.metric})
: _body = body,
_tags = tags,
_categories = categories,
@ -583,6 +608,8 @@ class _SnPost extends SnPost {
@override
final int? realmId;
@override
final SnRealm? realm;
@override
final SnPost? replyTo;
@override
final SnPost? repostTo;
@ -637,11 +664,11 @@ class _SnPost extends SnPost {
@override
final int? pollId;
@override
final SnPoll? poll;
@override
final SnPublisher publisher;
@override
final SnMetric metric;
@override
final SnPostPreload? preload;
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@ -685,6 +712,7 @@ class _SnPost extends SnPost {
(identical(other.repostId, repostId) ||
other.repostId == repostId) &&
(identical(other.realmId, realmId) || other.realmId == realmId) &&
(identical(other.realm, realm) || other.realm == realm) &&
(identical(other.replyTo, replyTo) || other.replyTo == replyTo) &&
(identical(other.repostTo, repostTo) ||
other.repostTo == repostTo) &&
@ -716,10 +744,10 @@ class _SnPost extends SnPost {
(identical(other.publisherId, publisherId) ||
other.publisherId == publisherId) &&
(identical(other.pollId, pollId) || other.pollId == pollId) &&
(identical(other.poll, poll) || other.poll == poll) &&
(identical(other.publisher, publisher) ||
other.publisher == publisher) &&
(identical(other.metric, metric) || other.metric == metric) &&
(identical(other.preload, preload) || other.preload == preload));
(identical(other.metric, metric) || other.metric == metric));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@ -741,6 +769,7 @@ class _SnPost extends SnPost {
replyId,
repostId,
realmId,
realm,
replyTo,
repostTo,
const DeepCollectionEquality().hash(_visibleUsersList),
@ -758,14 +787,14 @@ class _SnPost extends SnPost {
totalAggregatedViews,
publisherId,
pollId,
poll,
publisher,
metric,
preload
metric
]);
@override
String toString() {
return 'SnPost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, body: $body, language: $language, alias: $alias, aliasPrefix: $aliasPrefix, tags: $tags, categories: $categories, replies: $replies, replyId: $replyId, repostId: $repostId, realmId: $realmId, replyTo: $replyTo, repostTo: $repostTo, visibleUsersList: $visibleUsersList, invisibleUsersList: $invisibleUsersList, visibility: $visibility, editedAt: $editedAt, pinnedAt: $pinnedAt, lockedAt: $lockedAt, isDraft: $isDraft, publishedAt: $publishedAt, publishedUntil: $publishedUntil, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, totalViews: $totalViews, totalAggregatedViews: $totalAggregatedViews, publisherId: $publisherId, pollId: $pollId, publisher: $publisher, metric: $metric, preload: $preload)';
return 'SnPost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, body: $body, language: $language, alias: $alias, aliasPrefix: $aliasPrefix, tags: $tags, categories: $categories, replies: $replies, replyId: $replyId, repostId: $repostId, realmId: $realmId, realm: $realm, replyTo: $replyTo, repostTo: $repostTo, visibleUsersList: $visibleUsersList, invisibleUsersList: $invisibleUsersList, visibility: $visibility, editedAt: $editedAt, pinnedAt: $pinnedAt, lockedAt: $lockedAt, isDraft: $isDraft, publishedAt: $publishedAt, publishedUntil: $publishedUntil, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, totalViews: $totalViews, totalAggregatedViews: $totalAggregatedViews, publisherId: $publisherId, pollId: $pollId, poll: $poll, publisher: $publisher, metric: $metric)';
}
}
@ -791,6 +820,7 @@ abstract mixin class _$SnPostCopyWith<$Res> implements $SnPostCopyWith<$Res> {
int? replyId,
int? repostId,
int? realmId,
SnRealm? realm,
SnPost? replyTo,
SnPost? repostTo,
List<int>? visibleUsersList,
@ -808,20 +838,22 @@ abstract mixin class _$SnPostCopyWith<$Res> implements $SnPostCopyWith<$Res> {
int totalAggregatedViews,
int publisherId,
int? pollId,
SnPoll? poll,
SnPublisher publisher,
SnMetric metric,
SnPostPreload? preload});
SnMetric metric});
@override
$SnRealmCopyWith<$Res>? get realm;
@override
$SnPostCopyWith<$Res>? get replyTo;
@override
$SnPostCopyWith<$Res>? get repostTo;
@override
$SnPollCopyWith<$Res>? get poll;
@override
$SnPublisherCopyWith<$Res> get publisher;
@override
$SnMetricCopyWith<$Res> get metric;
@override
$SnPostPreloadCopyWith<$Res>? get preload;
}
/// @nodoc
@ -851,6 +883,7 @@ class __$SnPostCopyWithImpl<$Res> implements _$SnPostCopyWith<$Res> {
Object? replyId = freezed,
Object? repostId = freezed,
Object? realmId = freezed,
Object? realm = freezed,
Object? replyTo = freezed,
Object? repostTo = freezed,
Object? visibleUsersList = freezed,
@ -868,9 +901,9 @@ class __$SnPostCopyWithImpl<$Res> implements _$SnPostCopyWith<$Res> {
Object? totalAggregatedViews = null,
Object? publisherId = null,
Object? pollId = freezed,
Object? poll = freezed,
Object? publisher = null,
Object? metric = null,
Object? preload = freezed,
}) {
return _then(_SnPost(
id: null == id
@ -933,6 +966,10 @@ class __$SnPostCopyWithImpl<$Res> implements _$SnPostCopyWith<$Res> {
? _self.realmId
: realmId // ignore: cast_nullable_to_non_nullable
as int?,
realm: freezed == realm
? _self.realm
: realm // ignore: cast_nullable_to_non_nullable
as SnRealm?,
replyTo: freezed == replyTo
? _self.replyTo
: replyTo // ignore: cast_nullable_to_non_nullable
@ -1001,6 +1038,10 @@ class __$SnPostCopyWithImpl<$Res> implements _$SnPostCopyWith<$Res> {
? _self.pollId
: pollId // ignore: cast_nullable_to_non_nullable
as int?,
poll: freezed == poll
? _self.poll
: poll // ignore: cast_nullable_to_non_nullable
as SnPoll?,
publisher: null == publisher
? _self.publisher
: publisher // ignore: cast_nullable_to_non_nullable
@ -1009,13 +1050,23 @@ class __$SnPostCopyWithImpl<$Res> implements _$SnPostCopyWith<$Res> {
? _self.metric
: metric // ignore: cast_nullable_to_non_nullable
as SnMetric,
preload: freezed == preload
? _self.preload
: preload // ignore: cast_nullable_to_non_nullable
as SnPostPreload?,
));
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnRealmCopyWith<$Res>? get realm {
if (_self.realm == null) {
return null;
}
return $SnRealmCopyWith<$Res>(_self.realm!, (value) {
return _then(_self.copyWith(realm: value));
});
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@ -1044,6 +1095,20 @@ class __$SnPostCopyWithImpl<$Res> implements _$SnPostCopyWith<$Res> {
});
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPollCopyWith<$Res>? get poll {
if (_self.poll == null) {
return null;
}
return $SnPollCopyWith<$Res>(_self.poll!, (value) {
return _then(_self.copyWith(poll: value));
});
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@ -1063,20 +1128,6 @@ class __$SnPostCopyWithImpl<$Res> implements _$SnPostCopyWith<$Res> {
return _then(_self.copyWith(metric: value));
});
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPostPreloadCopyWith<$Res>? get preload {
if (_self.preload == null) {
return null;
}
return $SnPostPreloadCopyWith<$Res>(_self.preload!, (value) {
return _then(_self.copyWith(preload: value));
});
}
}
/// @nodoc
@ -2465,6 +2516,7 @@ mixin _$SnPublisher {
int get totalDownvote;
int? get realmId;
int get accountId;
SnAccount? get account;
/// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values.
@ -2501,7 +2553,8 @@ mixin _$SnPublisher {
other.totalDownvote == totalDownvote) &&
(identical(other.realmId, realmId) || other.realmId == realmId) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
other.accountId == accountId) &&
(identical(other.account, account) || other.account == account));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@ -2521,11 +2574,12 @@ mixin _$SnPublisher {
totalUpvote,
totalDownvote,
realmId,
accountId);
accountId,
account);
@override
String toString() {
return 'SnPublisher(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, name: $name, nick: $nick, description: $description, avatar: $avatar, banner: $banner, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, realmId: $realmId, accountId: $accountId)';
return 'SnPublisher(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, name: $name, nick: $nick, description: $description, avatar: $avatar, banner: $banner, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, realmId: $realmId, accountId: $accountId, account: $account)';
}
}
@ -2549,7 +2603,10 @@ abstract mixin class $SnPublisherCopyWith<$Res> {
int totalUpvote,
int totalDownvote,
int? realmId,
int accountId});
int accountId,
SnAccount? account});
$SnAccountCopyWith<$Res>? get account;
}
/// @nodoc
@ -2578,6 +2635,7 @@ class _$SnPublisherCopyWithImpl<$Res> implements $SnPublisherCopyWith<$Res> {
Object? totalDownvote = null,
Object? realmId = freezed,
Object? accountId = null,
Object? account = freezed,
}) {
return _then(_self.copyWith(
id: null == id
@ -2636,8 +2694,26 @@ class _$SnPublisherCopyWithImpl<$Res> implements $SnPublisherCopyWith<$Res> {
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
account: freezed == account
? _self.account
: account // ignore: cast_nullable_to_non_nullable
as SnAccount?,
));
}
/// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
}
}
/// @nodoc
@ -2657,7 +2733,8 @@ class _SnPublisher implements SnPublisher {
required this.totalUpvote,
required this.totalDownvote,
required this.realmId,
required this.accountId});
required this.accountId,
required this.account});
factory _SnPublisher.fromJson(Map<String, dynamic> json) =>
_$SnPublisherFromJson(json);
@ -2689,6 +2766,8 @@ class _SnPublisher implements SnPublisher {
final int? realmId;
@override
final int accountId;
@override
final SnAccount? account;
/// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values.
@ -2730,7 +2809,8 @@ class _SnPublisher implements SnPublisher {
other.totalDownvote == totalDownvote) &&
(identical(other.realmId, realmId) || other.realmId == realmId) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
other.accountId == accountId) &&
(identical(other.account, account) || other.account == account));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@ -2750,11 +2830,12 @@ class _SnPublisher implements SnPublisher {
totalUpvote,
totalDownvote,
realmId,
accountId);
accountId,
account);
@override
String toString() {
return 'SnPublisher(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, name: $name, nick: $nick, description: $description, avatar: $avatar, banner: $banner, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, realmId: $realmId, accountId: $accountId)';
return 'SnPublisher(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, name: $name, nick: $nick, description: $description, avatar: $avatar, banner: $banner, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, realmId: $realmId, accountId: $accountId, account: $account)';
}
}
@ -2780,7 +2861,11 @@ abstract mixin class _$SnPublisherCopyWith<$Res>
int totalUpvote,
int totalDownvote,
int? realmId,
int accountId});
int accountId,
SnAccount? account});
@override
$SnAccountCopyWith<$Res>? get account;
}
/// @nodoc
@ -2809,6 +2894,7 @@ class __$SnPublisherCopyWithImpl<$Res> implements _$SnPublisherCopyWith<$Res> {
Object? totalDownvote = null,
Object? realmId = freezed,
Object? accountId = null,
Object? account = freezed,
}) {
return _then(_SnPublisher(
id: null == id
@ -2867,8 +2953,26 @@ class __$SnPublisherCopyWithImpl<$Res> implements _$SnPublisherCopyWith<$Res> {
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
account: freezed == account
? _self.account
: account // ignore: cast_nullable_to_non_nullable
as SnAccount?,
));
}
/// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
}
}
/// @nodoc

View File

@ -32,6 +32,9 @@ _SnPost _$SnPostFromJson(Map<String, dynamic> json) => _SnPost(
replyId: (json['reply_id'] as num?)?.toInt(),
repostId: (json['repost_id'] as num?)?.toInt(),
realmId: (json['realm_id'] as num?)?.toInt(),
realm: json['realm'] == null
? null
: SnRealm.fromJson(json['realm'] as Map<String, dynamic>),
replyTo: json['reply_to'] == null
? null
: SnPost.fromJson(json['reply_to'] as Map<String, dynamic>),
@ -68,12 +71,12 @@ _SnPost _$SnPostFromJson(Map<String, dynamic> json) => _SnPost(
(json['total_aggregated_views'] as num?)?.toInt() ?? 0,
publisherId: (json['publisher_id'] as num).toInt(),
pollId: (json['poll_id'] as num?)?.toInt(),
poll: json['poll'] == null
? null
: SnPoll.fromJson(json['poll'] as Map<String, dynamic>),
publisher:
SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
metric: SnMetric.fromJson(json['metric'] as Map<String, dynamic>),
preload: json['preload'] == null
? null
: SnPostPreload.fromJson(json['preload'] as Map<String, dynamic>),
);
Map<String, dynamic> _$SnPostToJson(_SnPost instance) => <String, dynamic>{
@ -92,6 +95,7 @@ Map<String, dynamic> _$SnPostToJson(_SnPost instance) => <String, dynamic>{
'reply_id': instance.replyId,
'repost_id': instance.repostId,
'realm_id': instance.realmId,
'realm': instance.realm?.toJson(),
'reply_to': instance.replyTo?.toJson(),
'repost_to': instance.repostTo?.toJson(),
'visible_users_list': instance.visibleUsersList,
@ -109,9 +113,9 @@ Map<String, dynamic> _$SnPostToJson(_SnPost instance) => <String, dynamic>{
'total_aggregated_views': instance.totalAggregatedViews,
'publisher_id': instance.publisherId,
'poll_id': instance.pollId,
'poll': instance.poll?.toJson(),
'publisher': instance.publisher.toJson(),
'metric': instance.metric.toJson(),
'preload': instance.preload?.toJson(),
};
_SnPostTag _$SnPostTagFromJson(Map<String, dynamic> json) => _SnPostTag(
@ -241,6 +245,9 @@ _SnPublisher _$SnPublisherFromJson(Map<String, dynamic> json) => _SnPublisher(
totalDownvote: (json['total_downvote'] as num).toInt(),
realmId: (json['realm_id'] as num?)?.toInt(),
accountId: (json['account_id'] as num).toInt(),
account: json['account'] == null
? null
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
);
Map<String, dynamic> _$SnPublisherToJson(_SnPublisher instance) =>
@ -259,6 +266,7 @@ Map<String, dynamic> _$SnPublisherToJson(_SnPublisher instance) =>
'total_downvote': instance.totalDownvote,
'realm_id': instance.realmId,
'account_id': instance.accountId,
'account': instance.account?.toJson(),
};
_SnSubscription _$SnSubscriptionFromJson(Map<String, dynamic> json) =>

View File

@ -17,4 +17,5 @@ const Map<String, ReactInfo> kTemplateReactions = {
'party': ReactInfo(icon: '🎉', attitude: 1),
'joy': ReactInfo(icon: '🤣', attitude: 1),
'pray': ReactInfo(icon: '🙏', attitude: 1),
'heart': ReactInfo(icon: '❤️', attitude: 1),
};

View File

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

View File

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

View File

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

View File

@ -54,11 +54,15 @@ class AccountImage extends StatelessWidget {
))
.center(),
)
: AutoResizeUniversalImage(
: UniversalImage(
sn.getAttachmentUrl(url),
filterQuality: filterQuality,
key: Key('attachment-${content.hashCode}'),
fit: BoxFit.cover,
width: (radius != null ? radius! : 20) * 2,
height: (radius != null ? radius! : 20) * 2,
cacheWidth: (radius != null ? radius! : 20) * 2,
cacheHeight: (radius != null ? radius! : 20) * 2,
),
),
),

View File

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

View File

@ -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}'),
),
),
],
),

View File

@ -16,7 +16,6 @@ import 'package:photo_view/photo_view_gallery.dart';
import 'package:provider/provider.dart';
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/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
@ -418,8 +417,7 @@ class _AttachmentZoomDetailPopup extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
final account = ud.getFromCache(data.accountId);
final account = data.account!;
const tableGap = TableRow(
children: [
@ -461,12 +459,12 @@ class _AttachmentZoomDetailPopup extends StatelessWidget {
children: [
if (data.accountId > 0)
AccountImage(
content: account?.avatar,
content: account.avatar,
radius: 8,
),
const Gap(8),
Text(data.accountId > 0
? account?.nick ?? 'unknown'.tr()
? account.nick
: 'unknown'.tr()),
const Gap(8),
Text('#${data.accountId}',

View File

@ -8,12 +8,12 @@ import 'package:surface/widgets/account/account_image.dart';
class NoContentWidget extends StatefulWidget {
final SnAccount? userinfo;
final bool isSpeaking;
final bool isFixed;
final double? avatarSize;
const NoContentWidget({
super.key,
this.userinfo,
this.isFixed = false,
this.avatarSize,
required this.isSpeaking,
});
@ -45,41 +45,35 @@ class _NoContentWidgetState extends State<NoContentWidget>
@override
Widget build(BuildContext context) {
final double radius = widget.isFixed
? 32
: math.min(
MediaQuery.of(context).size.width * 0.1,
MediaQuery.of(context).size.height * 0.1,
);
final double radius = widget.avatarSize ??
math.min(
MediaQuery.of(context).size.width * 0.1,
MediaQuery.of(context).size.height * 0.1,
);
return Container(
alignment: Alignment.center,
child: Center(
child: Animate(
autoPlay: false,
controller: _animationController,
effects: [
CustomEffect(
begin: widget.isSpeaking ? 2 : 0,
end: 8,
curve: Curves.easeInOut,
duration: 1250.ms,
builder: (context, value, child) => Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(radius + 8)),
border: value > 0
? Border.all(color: Colors.green, width: value)
: null,
),
child: child,
),
)
],
child: AccountImage(
content: widget.userinfo?.avatar,
radius: radius,
return Animate(
autoPlay: false,
controller: _animationController,
effects: [
CustomEffect(
begin: widget.isSpeaking ? 2 : 0,
end: 8,
curve: Curves.easeInOut,
duration: 1250.ms,
builder: (context, value, child) => Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(radius + 8)),
border: value > 0
? Border.all(color: Colors.green, width: value)
: null,
),
child: child,
),
),
)
],
child: AccountImage(
content: widget.userinfo?.avatar,
radius: radius,
),
);
}

View File

@ -2,7 +2,9 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:gap/gap.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/chat/call/call_no_content.dart';
@ -11,23 +13,32 @@ import 'package:surface/widgets/chat/call/call_participant_menu.dart';
import 'package:surface/widgets/chat/call/call_participant_stats.dart';
abstract class ParticipantWidget extends StatefulWidget {
static ParticipantWidget widgetFor(ParticipantTrack participantTrack,
{bool isFixed = false, bool showStatsLayer = false}) {
static ParticipantWidget widgetFor(
ParticipantTrack participantTrack, {
double? avatarSize,
EdgeInsets? padding,
bool showStatsLayer = false,
bool isList = false,
}) {
if (participantTrack.participant is LocalParticipant) {
return LocalParticipantWidget(
participantTrack.participant as LocalParticipant,
participantTrack.videoTrack,
isFixed,
avatarSize,
participantTrack.isScreenShare,
showStatsLayer,
isList,
padding,
);
} else if (participantTrack.participant is RemoteParticipant) {
return RemoteParticipantWidget(
participantTrack.participant as RemoteParticipant,
participantTrack.videoTrack,
isFixed,
avatarSize,
participantTrack.isScreenShare,
showStatsLayer,
isList,
padding,
);
}
throw UnimplementedError('Unknown participant type');
@ -36,8 +47,10 @@ abstract class ParticipantWidget extends StatefulWidget {
abstract final Participant participant;
abstract final VideoTrack? videoTrack;
abstract final bool isScreenShare;
abstract final bool isFixed;
abstract final double? avatarSize;
abstract final bool showStatsLayer;
abstract final bool isList;
abstract final EdgeInsets? padding;
final VideoQuality quality;
const ParticipantWidget({
@ -52,18 +65,24 @@ class LocalParticipantWidget extends ParticipantWidget {
@override
final VideoTrack? videoTrack;
@override
final bool isFixed;
final double? avatarSize;
@override
final bool isScreenShare;
@override
final bool showStatsLayer;
@override
final bool isList;
@override
final EdgeInsets? padding;
const LocalParticipantWidget(
this.participant,
this.videoTrack,
this.isFixed,
this.avatarSize,
this.isScreenShare,
this.showStatsLayer, {
this.showStatsLayer,
this.isList,
this.padding, {
super.key,
});
@ -77,18 +96,24 @@ class RemoteParticipantWidget extends ParticipantWidget {
@override
final VideoTrack? videoTrack;
@override
final bool isFixed;
final double? avatarSize;
@override
final bool isScreenShare;
@override
final bool showStatsLayer;
@override
final bool isList;
@override
final EdgeInsets? padding;
const RemoteParticipantWidget(
this.participant,
this.videoTrack,
this.isFixed,
this.avatarSize,
this.isScreenShare,
this.showStatsLayer, {
this.showStatsLayer,
this.isList,
this.padding, {
super.key,
});
@ -136,19 +161,82 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget>
}
@override
Widget build(BuildContext ctx) {
Widget build(BuildContext context) {
if (widget.isList) {
return Padding(
padding: widget.padding ?? EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SizedBox(
width: (widget.avatarSize ?? 32) * 2,
height: (widget.avatarSize ?? 32) * 2,
child: Center(
child: NoContentWidget(
userinfo: _userinfoMetadata,
avatarSize: widget.avatarSize,
isSpeaking: widget.participant.isSpeaking,
),
),
),
const Gap(8),
Expanded(
child: SizedBox(
height: (widget.avatarSize ?? 32) * 2,
child: ParticipantInfoWidget(
isList: true,
title: widget.participant.name.isNotEmpty
? widget.participant.name
: widget.participant.identity,
audioAvailable: _firstAudioPublication?.muted == false &&
_firstAudioPublication?.subscribed == true,
connectionQuality: widget.participant.connectionQuality,
isScreenShare: widget.isScreenShare,
),
),
),
],
),
if (_activeVideoTrack != null && !_activeVideoTrack!.muted)
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Material(
borderRadius: const BorderRadius.all(Radius.circular(8)),
color: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.75),
child: VideoTrackRenderer(
_activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
),
),
).padding(top: 8),
),
],
),
);
}
return Stack(
children: [
_activeVideoTrack != null && !_activeVideoTrack!.muted
? VideoTrackRenderer(
_activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
)
: NoContentWidget(
userinfo: _userinfoMetadata,
isFixed: widget.isFixed,
isSpeaking: widget.participant.isSpeaking,
),
if (_activeVideoTrack != null && !_activeVideoTrack!.muted)
VideoTrackRenderer(
_activeVideoTrack!,
fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
)
else
Center(
child: NoContentWidget(
userinfo: _userinfoMetadata,
avatarSize: widget.avatarSize,
isSpeaking: widget.participant.isSpeaking,
),
),
if (widget.showStatsLayer)
Positioned(
top: 30,
@ -199,44 +287,51 @@ class _RemoteParticipantWidgetState
}
class InteractiveParticipantWidget extends StatelessWidget {
final double? width;
final double? height;
final Color? color;
final bool isFixedAvatar;
final double? avatarSize;
final bool isList;
final ParticipantTrack participant;
final Function() onTap;
final Function? onTap;
final EdgeInsets? padding;
const InteractiveParticipantWidget({
super.key,
this.width,
this.height,
this.color,
this.isFixedAvatar = false,
this.avatarSize,
this.isList = false,
this.padding,
required this.participant,
required this.onTap,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
child: Container(
width: width,
height: height,
color: color,
child: ParticipantWidget.widgetFor(participant, isFixed: isFixedAvatar),
),
onTap: () => onTap(),
onLongPress: () {
if (participant.participant is LocalParticipant) return;
showModalBottomSheet(
context: context,
builder: (context) => ParticipantMenu(
participant: participant.participant as RemoteParticipant,
videoTrack: participant.videoTrack,
isScreenShare: participant.isScreenShare,
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap != null
? () {
onTap?.call();
}
: null,
onLongPress: () {
if (participant.participant is LocalParticipant) return;
showModalBottomSheet(
context: context,
builder: (context) => ParticipantMenu(
participant: participant.participant as RemoteParticipant,
videoTrack: participant.videoTrack,
isScreenShare: participant.isScreenShare,
),
);
},
child: Container(
child: ParticipantWidget.widgetFor(
participant,
avatarSize: avatarSize,
isList: isList,
padding: padding,
),
);
},
),
),
);
}
}

View File

@ -9,6 +9,7 @@ class ParticipantInfoWidget extends StatelessWidget {
final bool audioAvailable;
final ConnectionQuality connectionQuality;
final bool isScreenShare;
final bool isList;
const ParticipantInfoWidget({
super.key,
@ -16,64 +17,124 @@ class ParticipantInfoWidget extends StatelessWidget {
this.audioAvailable = true,
this.connectionQuality = ConnectionQuality.unknown,
this.isScreenShare = false,
this.isList = false,
});
@override
Widget build(BuildContext context) => Container(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
padding: const EdgeInsets.symmetric(
vertical: 7,
horizontal: 10,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (title != null)
Flexible(
child: Text(
title!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white),
),
Widget build(BuildContext context) {
if (isList) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null)
Text(
title!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
const Gap(5),
isScreenShare
? const Icon(
Symbols.monitor,
).padding(left: 2),
Row(
children: [
isScreenShare
? const Icon(
Symbols.monitor,
color: Colors.white,
size: 16,
)
: Icon(
audioAvailable ? Symbols.mic : Symbols.mic_off,
color: audioAvailable ? Colors.white : Colors.red,
size: 16,
),
const Gap(3),
if (connectionQuality != ConnectionQuality.unknown)
Icon(
{
ConnectionQuality.excellent: Symbols.signal_cellular_alt,
ConnectionQuality.good: Symbols.signal_cellular_alt_2_bar,
ConnectionQuality.poor: Symbols.signal_cellular_alt_1_bar,
}[connectionQuality],
color: {
ConnectionQuality.excellent: Colors.green,
ConnectionQuality.good: Colors.orange,
ConnectionQuality.poor: Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
size: 16,
)
: Icon(
audioAvailable ? Symbols.mic : Symbols.mic_off,
color: audioAvailable ? Colors.white : Colors.red,
size: 16,
strokeWidth: 2,
),
const Gap(3),
if (connectionQuality != ConnectionQuality.unknown)
Icon(
{
ConnectionQuality.excellent: Symbols.signal_cellular_alt,
ConnectionQuality.good: Symbols.signal_cellular_alt_2_bar,
ConnectionQuality.poor: Symbols.signal_cellular_alt_1_bar,
}[connectionQuality],
color: {
ConnectionQuality.excellent: Colors.green,
ConnectionQuality.good: Colors.orange,
ConnectionQuality.poor: Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
).padding(all: 3),
],
),
).padding(all: 3),
],
)
],
);
}
return Container(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
padding: const EdgeInsets.symmetric(
vertical: 7,
horizontal: 10,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (title != null)
Flexible(
child: Text(
title!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white),
),
),
const Gap(5),
isScreenShare
? const Icon(
Symbols.monitor,
color: Colors.white,
size: 16,
)
: Icon(
audioAvailable ? Symbols.mic : Symbols.mic_off,
color: audioAvailable ? Colors.white : Colors.red,
size: 16,
),
const Gap(3),
if (connectionQuality != ConnectionQuality.unknown)
Icon(
{
ConnectionQuality.excellent: Symbols.signal_cellular_alt,
ConnectionQuality.good: Symbols.signal_cellular_alt_2_bar,
ConnectionQuality.poor: Symbols.signal_cellular_alt_1_bar,
}[connectionQuality],
color: {
ConnectionQuality.excellent: Colors.green,
ConnectionQuality.good: Colors.orange,
ConnectionQuality.poor: Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
).padding(all: 3),
],
),
);
}
}

View File

@ -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(

View File

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

View File

@ -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();

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