Compare commits

...

16 Commits

Author SHA1 Message Date
ae9a7eb0fd 🚀 Launch 2.2.2+61 2025-02-02 13:35:43 +08:00
5d6fb2442f Able to config preferred language 2025-02-01 19:35:50 +08:00
5a85985534 Featured comment 2025-01-31 23:16:14 +08:00
c80499db03 Share to chat channel 2025-01-31 22:52:21 +08:00
b8dcdb2315 💄 Move the connection indicator 2025-01-31 21:50:18 +08:00
b7b921f1f4 📱 Fix new notify indicator on large screen 2025-01-31 20:26:20 +08:00
319d5c7d7f ♻️ Refactor notification indicator 2025-01-31 20:12:46 +08:00
4b5b001739 🐛 Fix open from widget cause multiple activity 2025-01-31 00:39:10 +08:00
db8871a455 🚀 Launch 2.2.2+60 2025-01-31 00:22:06 +08:00
38dcaa6066 AI Post Insight 2025-01-30 14:58:06 +08:00
03275b46ca 🚀 Launch 2.2.2+59 2025-01-29 21:54:00 +08:00
cf3b482fef 🐛 Bug fixes 2025-01-29 20:42:41 +08:00
aa4c04d4ef 🌐 Complete translations 2025-01-29 20:32:56 +08:00
73b82f65e4 Basic wallet page 2025-01-29 15:18:35 +08:00
9471fe40fe In-app language switcher 2025-01-28 23:09:07 +08:00
0d1e18735e 💄 Give a link to open wiki when error occurred. 2025-01-28 22:57:44 +08:00
48 changed files with 2271 additions and 213 deletions

View File

@ -26,7 +26,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:launchMode="singleInstance"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"

View File

@ -15,11 +15,11 @@ body:json {
"client_id": "{{third_client_id}}",
"client_secret":"{{third_client_tk}}",
"type": "general",
"subject": "Merry Christmas!",
"subject": "新年快乐!",
"subtitle": "一条来自 Solar Network 团队的信息",
"content": "今天是 12 月 25 日 (UTC+8),小羊祝您圣诞快乐 🎄",
"content": "今天是农历正月初一,小羊祝您新年快乐 🎉",
"metadata": {
"image": "6EqsYQwmFRCkbmhR"
"image": "D2EDbcrsTugs3xk5"
},
"priority": 10
}

View File

@ -0,0 +1,26 @@
meta {
name: Developer Notify One User
type: http
seq: 2
}
post {
url: {{endpoint}}/cgi/id/dev/notify/1
body: json
auth: inherit
}
body:json {
{
"client_id": "{{third_client_id}}",
"client_secret":"{{third_client_tk}}",
"type": "general",
"subject": "测试",
"subtitle": "Alphabot です",
"content": "全新通知动画",
"metadata": {
"image": "D2EDbcrsTugs3xk5"
},
"priority": 10
}
}

View File

@ -0,0 +1,11 @@
meta {
name: Run Database Maintenance
type: http
seq: 1
}
post {
url: {{endpoint}}/wt/maintenance/database
body: none
auth: inherit
}

View File

@ -18,6 +18,8 @@
"screenAbuseReport": "Abuse Reports",
"screenSettings": "Settings",
"screenAccountSettings": "Account Settings",
"screenFactorSettings": "Auth Factors",
"screenAccountWallet": "Wallet",
"screenNews": "News",
"screenAlbum": "Album",
"screenChat": "Chat",
@ -29,7 +31,6 @@
"screenNotification": "Notification",
"screenPostSearch": "Search Posts",
"screenFriend": "Friends",
"screenFactorSettings": "Auth Factors",
"dialogOkay": "Okay",
"dialogCancel": "Cancel",
"dialogConfirm": "Confirm",
@ -130,6 +131,8 @@
"accountSettingsSubtitle": "Manage your account and make it yours.",
"accountProfileEdit": "Edit your profile",
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
"accountWallet": "Wallet",
"accountWalletSubtitle": "View your balance and transactions.",
"factorSettings": "Auth Factors",
"factorSettingsSubtitle": "Manage your authentication factors.",
"accountProfileEditApplied": "Profile modification applied.",
@ -196,6 +199,9 @@
"other": "{} comments"
},
"settingsAppearance": "Appearance",
"settingsDisplayLanguage": "Display Language",
"settingsDisplayLanguageDescription": "Set the application language.",
"settingsDisplayLanguageSystem": "Follow System",
"settingsAppBarTransparent": "Transparent App Bar",
"settingsAppBarTransparentDescription": "Enable transparent effect for the app bar.",
"settingsDrawerPreferCollapse": "Prefer Drawer Collapse",
@ -235,6 +241,8 @@
"settingsMisc": "Misc",
"settingsMiscAbout": "About",
"settingsMiscAboutDescription": "View the version information of Solian.",
"settingsAccountLanguage": "Account Language",
"settingsAccountLanguageDescription": "Set the language for email, notification, and other account-related content.",
"sensitiveContent": "Sensitive Content",
"sensitiveContentCollapsed": "Sensitive content has been collapsed.",
"sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.",
@ -548,11 +556,15 @@
"postImageShareAds": "Explore posts on the Solar Network",
"postShare": "Share",
"postShareImage": "Share via Image",
"postGetInsight": "Get Insight",
"postGetInsightTitle": "AI Insight",
"postGetInsightDescription": "AI may make mistakes, check important information.",
"appInitializing": "Initializing",
"poweredBy": "Powered by {}",
"shareIntent": "Share",
"shareIntentDescription": "What do you want to do with the content you are sharing?",
"shareIntentPostStory": "Post a Story",
"shareIntentSendChannel": "Share to Channel",
"updateAvailable": "Update Available",
"updateOngoing": "Updating, please wait...",
"custom": "Custom",
@ -565,6 +577,7 @@
"colorSchemeWhite": "White",
"colorSchemeBlack": "Black",
"colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.",
"postFeaturedComment": "Featured Comment",
"postCategoryTechnology": "Technology",
"postCategoryGaming": "Gaming",
"postCategoryLife": "Life",
@ -584,5 +597,17 @@
"newsToday": "Today's News",
"totpPostSetup": "One More Thing",
"totpPostSetupDescription": "Scan the QR Code below with Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden or any of kind of authenticator app which supports TOTP.",
"totpNeverShare": "Never share this QR Code"
"totpNeverShare": "Never share this QR Code",
"needHelp": "Need Help?",
"needHelpLaunch": "Check out our Goatpedia!",
"walletCreate": "Create a Wallet",
"walletCreateSubtitle": "Create a wallet to start using Source Points",
"walletCreatePassword": "Set a payment password for your new wallet below",
"walletCurrencyShort": "SRC",
"walletCurrency": {
"one": "{} Source Point",
"other": "{} Source Points"
},
"aiThinkingProcess": "AI Thinking Process",
"accountSettingsApplied": "Account settings have been applied."
}

View File

@ -16,6 +16,8 @@
"screenAbuseReport": "滥用检举",
"screenSettings": "设置",
"screenAccountSettings": "账号设置",
"screenFactorSettings": "验证因子",
"screenAccountWallet": "钱包",
"screenNews": "新闻",
"screenAlbum": "相册",
"screenChat": "聊天",
@ -113,6 +115,8 @@
"accountSettingsSubtitle": "管理你的帐号并让它更好的服务你。",
"accountProfileEdit": "编辑资料",
"accountProfileEditSubtitle": "使你的 Solarpass 账户更像你。",
"accountWallet": "钱包",
"accountWalletSubtitle": "查看你的余额和交易记录。",
"factorSettings": "验证因子",
"factorSettingsSubtitle": "管理你的登陆验证方式。",
"accountProfileEditApplied": "个人资料修改已被应用。",
@ -193,6 +197,9 @@
"other": "{} 条评论"
},
"settingsAppearance": "外观",
"settingsDisplayLanguage": "显示语言",
"settingsDisplayLanguageDescription": "设置应用程序使用的语言",
"settingsDisplayLanguageSystem": "跟随系统",
"settingsBackgroundImage": "背景图片",
"settingsBackgroundImageDescription": "设置应用全局生效的的背景图片。",
"settingsBackgroundImageClear": "清除现存背景图",
@ -232,6 +239,8 @@
"settingsMisc": "杂项",
"settingsMiscAbout": "关于",
"settingsMiscAboutDescription": "查看 Solian 的版本信息。",
"settingsAccountLanguage": "帐号偏好语言",
"settingsAccountLanguageDescription": "设置邮件、通知和其他帐号相关内容的语言。",
"sensitiveContent": "敏感内容",
"sensitiveContentCollapsed": "敏感内容已折叠。",
"sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
@ -545,11 +554,15 @@
"postImageShareAds": "来 Solar Network 探索更多有趣帖子",
"postShare": "分享",
"postShareImage": "分享帖图",
"postGetInsight": "获取见解",
"postGetInsightTitle": "AI 见解",
"postGetInsightDescription": "AI 可能会出错,检查信息真实性。",
"appInitializing": "正在初始化",
"poweredBy": "由 {} 提供支持",
"shareIntent": "分享",
"shareIntentDescription": "您想对您分享的内容做些什么?",
"shareIntentPostStory": "发布动态",
"shareIntentSendChannel": "分享到聊天频道",
"updateAvailable": "检测到更新可用",
"updateOngoing": "正在更新,请稍后……",
"custom": "自定义",
@ -562,6 +575,7 @@
"colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色",
"colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
"postFeaturedComment": "精选评论",
"postCategoryTechnology": "技术",
"postCategoryGaming": "游戏",
"postCategoryLife": "生活",
@ -581,5 +595,17 @@
"newsToday": "快讯",
"totpPostSetup": "还有一件事",
"totpPostSetupDescription": "使用 Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden 或其他支持 TOTP 的验证器扫描本 QR Code 来添加。",
"totpNeverShare": "永远不要分享这个 QR Code"
"totpNeverShare": "永远不要分享这个 QR Code",
"needHelp": "需要帮助?",
"needHelpLaunch": "查看我们的山羊维基!",
"walletCreate": "创建钱包",
"walletCreateSubtitle": "创建于一个钱包来开始使用源点。",
"walletCreatePassword": "在下方设置你的付款密码",
"walletCurrencyShort": "源点",
"walletCurrency": {
"one": "{} 源点",
"other": "{} 源点"
},
"aiThinkingProcess": "AI 思考过程",
"accountSettingsApplied": "帐号设置已应用。"
}

View File

@ -15,6 +15,9 @@
"screenAccountProfileEdit": "編輯資料",
"screenAbuseReport": "濫用檢舉",
"screenSettings": "設置",
"screenAccountSettings": "賬號設置",
"screenFactorSettings": "驗證因子",
"screenAccountWallet": "錢包",
"screenNews": "新聞",
"screenAlbum": "相冊",
"screenChat": "聊天",
@ -88,8 +91,18 @@
},
"loginEnterPassword": "驗證代碼",
"loginSuccess": "登錄為 {}",
"authFactorDelete": "刪除驗證因子",
"authFactorDeleteDescription": "你確定要刪除 {} 驗證因子嗎?",
"authFactorPassword": "密碼",
"authFactorPasswordDescription": "註冊時選擇設置的密碼。",
"authFactorEmail": "電郵一次性驗證碼",
"authFactorEmailDescription": "由我們生成併發送到綁定的的電子郵箱的一次性驗證碼。",
"authFactorTOTP": "時序驗證碼",
"authFactorTOTPDescription": "使用 Google Authenticator 或 Authy 等驗證器生成的一次性驗證碼。",
"authFactorInAppNotify": "應用內通知驗證碼",
"authFactorInAppNotifyDescription": "通過站內通知推送的一次性驗證碼。",
"authFactorAdd": "添加新驗證因子",
"authFactorAddSubtitle": "給你的帳户登陸時提供另一個方案。",
"accountIntroTitle": "喜歡您來!",
"accountIntroSubtitle": "登陸以探索更廣大的世界。",
"accountLogout": "退出登錄",
@ -98,8 +111,14 @@
"accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。",
"accountPublishers": "你的發佈者",
"accountPublishersSubtitle": "管理你的公共形象。",
"accountSettings": "帳號設置",
"accountSettingsSubtitle": "管理你的帳號並讓它更好的服務你。",
"accountProfileEdit": "編輯資料",
"accountProfileEditSubtitle": "使你的 Solarpass 賬户更像你。",
"accountWallet": "錢包",
"accountWalletSubtitle": "查看你的餘額和交易記錄。",
"factorSettings": "驗證因子",
"factorSettingsSubtitle": "管理你的登陸驗證方式。",
"accountProfileEditApplied": "個人資料修改已被應用。",
"publishersNew": "新發布者",
"publisherNewSubtitle": "創建一個新的公共身份。",
@ -178,6 +197,9 @@
"other": "{} 條評論"
},
"settingsAppearance": "外觀",
"settingsDisplayLanguage": "顯示語言",
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
"settingsDisplayLanguageSystem": "跟隨系統",
"settingsBackgroundImage": "背景圖片",
"settingsBackgroundImageDescription": "設置應用全局生效的的背景圖片。",
"settingsBackgroundImageClear": "清除現存背景圖",
@ -217,6 +239,8 @@
"settingsMisc": "雜項",
"settingsMiscAbout": "關於",
"settingsMiscAboutDescription": "查看 Solian 的版本信息。",
"settingsAccountLanguage": "帳號偏好語言",
"settingsAccountLanguageDescription": "設置郵件、通知和其他帳號相關內容的語言。",
"sensitiveContent": "敏感內容",
"sensitiveContentCollapsed": "敏感內容已摺疊。",
"sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。",
@ -530,11 +554,15 @@
"postImageShareAds": "來 Solar Network 探索更多有趣帖子",
"postShare": "分享",
"postShareImage": "分享帖圖",
"postGetInsight": "獲取見解",
"postGetInsightTitle": "AI 見解",
"postGetInsightDescription": "AI 可能會出錯,檢查信息真實性。",
"appInitializing": "正在初始化",
"poweredBy": "由 {} 提供支持",
"shareIntent": "分享",
"shareIntentDescription": "您想對您分享的內容做些什麼?",
"shareIntentPostStory": "發佈動態",
"shareIntentSendChannel": "分享到聊天頻道",
"updateAvailable": "檢測到更新可用",
"updateOngoing": "正在更新,請稍後……",
"custom": "自定義",
@ -547,6 +575,7 @@
"colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
"postFeaturedComment": "精選評論",
"postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲",
"postCategoryLife": "生活",
@ -563,5 +592,20 @@
"newsReadingFromReader": "你正在從 HyperNet.Reader 閲讀文章",
"newsReadingFromOriginal": "你正在閲讀原始文章",
"newsDisclaimer": "本文由 HyperNet.Reader 從互聯網上獲取,我們不擔保其內容的真實性,請自行判斷。本文章的所有內容版權歸原作者所有。",
"newsToday": "快訊"
"newsToday": "快訊",
"totpPostSetup": "還有一件事",
"totpPostSetupDescription": "使用 Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden 或其他支持 TOTP 的驗證器掃描本 QR Code 來添加。",
"totpNeverShare": "永遠不要分享這個 QR Code",
"needHelp": "需要幫助?",
"needHelpLaunch": "查看我們的山羊維基!",
"walletCreate": "創建錢包",
"walletCreateSubtitle": "創建於一個錢包來開始使用源點。",
"walletCreatePassword": "在下方設置你的付款密碼",
"walletCurrencyShort": "源點",
"walletCurrency": {
"one": "{} 源點",
"other": "{} 源點"
},
"aiThinkingProcess": "AI 思考過程",
"accountSettingsApplied": "帳號設置已應用。"
}

View File

@ -15,6 +15,9 @@
"screenAccountProfileEdit": "編輯資料",
"screenAbuseReport": "濫用檢舉",
"screenSettings": "設置",
"screenAccountSettings": "賬號設置",
"screenFactorSettings": "驗證因子",
"screenAccountWallet": "錢包",
"screenNews": "新聞",
"screenAlbum": "相冊",
"screenChat": "聊天",
@ -88,8 +91,18 @@
},
"loginEnterPassword": "驗證代碼",
"loginSuccess": "登錄為 {}",
"authFactorDelete": "刪除驗證因子",
"authFactorDeleteDescription": "你確定要刪除 {} 驗證因子嗎?",
"authFactorPassword": "密碼",
"authFactorPasswordDescription": "註冊時選擇設置的密碼。",
"authFactorEmail": "電郵一次性驗證碼",
"authFactorEmailDescription": "由我們生成併發送到綁定的的電子郵箱的一次性驗證碼。",
"authFactorTOTP": "時序驗證碼",
"authFactorTOTPDescription": "使用 Google Authenticator 或 Authy 等驗證器生成的一次性驗證碼。",
"authFactorInAppNotify": "應用內通知驗證碼",
"authFactorInAppNotifyDescription": "通過站內通知推送的一次性驗證碼。",
"authFactorAdd": "添加新驗證因子",
"authFactorAddSubtitle": "給你的帳戶登陸時提供另一個方案。",
"accountIntroTitle": "喜歡您來!",
"accountIntroSubtitle": "登陸以探索更廣大的世界。",
"accountLogout": "退出登錄",
@ -98,8 +111,14 @@
"accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。",
"accountPublishers": "你的發佈者",
"accountPublishersSubtitle": "管理你的公共形象。",
"accountSettings": "帳號設置",
"accountSettingsSubtitle": "管理你的帳號並讓它更好的服務你。",
"accountProfileEdit": "編輯資料",
"accountProfileEditSubtitle": "使你的 Solarpass 賬戶更像你。",
"accountWallet": "錢包",
"accountWalletSubtitle": "查看你的餘額和交易記錄。",
"factorSettings": "驗證因子",
"factorSettingsSubtitle": "管理你的登陸驗證方式。",
"accountProfileEditApplied": "個人資料修改已被應用。",
"publishersNew": "新發布者",
"publisherNewSubtitle": "創建一個新的公共身份。",
@ -178,6 +197,9 @@
"other": "{} 條評論"
},
"settingsAppearance": "外觀",
"settingsDisplayLanguage": "顯示語言",
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
"settingsDisplayLanguageSystem": "跟隨系統",
"settingsBackgroundImage": "背景圖片",
"settingsBackgroundImageDescription": "設置應用全局生效的的背景圖片。",
"settingsBackgroundImageClear": "清除現存背景圖",
@ -217,6 +239,8 @@
"settingsMisc": "雜項",
"settingsMiscAbout": "關於",
"settingsMiscAboutDescription": "查看 Solian 的版本信息。",
"settingsAccountLanguage": "帳號偏好語言",
"settingsAccountLanguageDescription": "設置郵件、通知和其他帳號相關內容的語言。",
"sensitiveContent": "敏感內容",
"sensitiveContentCollapsed": "敏感內容已摺疊。",
"sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。",
@ -530,11 +554,15 @@
"postImageShareAds": "來 Solar Network 探索更多有趣帖子",
"postShare": "分享",
"postShareImage": "分享帖圖",
"postGetInsight": "獲取見解",
"postGetInsightTitle": "AI 見解",
"postGetInsightDescription": "AI 可能會出錯,檢查信息真實性。",
"appInitializing": "正在初始化",
"poweredBy": "由 {} 提供支持",
"shareIntent": "分享",
"shareIntentDescription": "您想對您分享的內容做些什麼?",
"shareIntentPostStory": "發佈動態",
"shareIntentSendChannel": "分享到聊天頻道",
"updateAvailable": "檢測到更新可用",
"updateOngoing": "正在更新,請稍後……",
"custom": "自定義",
@ -547,6 +575,7 @@
"colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
"postFeaturedComment": "精選評論",
"postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲",
"postCategoryLife": "生活",
@ -563,5 +592,20 @@
"newsReadingFromReader": "你正在從 HyperNet.Reader 閱讀文章",
"newsReadingFromOriginal": "你正在閱讀原始文章",
"newsDisclaimer": "本文由 HyperNet.Reader 從互聯網上獲取,我們不擔保其內容的真實性,請自行判斷。本文章的所有內容版權歸原作者所有。",
"newsToday": "快訊"
"newsToday": "快訊",
"totpPostSetup": "還有一件事",
"totpPostSetupDescription": "使用 Google Authenticator, Microsoft Authenticator, 1Password, Authy, Bitwarden 或其他支持 TOTP 的驗證器掃描本 QR Code 來添加。",
"totpNeverShare": "永遠不要分享這個 QR Code",
"needHelp": "需要幫助?",
"needHelpLaunch": "查看我們的山羊維基!",
"walletCreate": "創建錢包",
"walletCreateSubtitle": "創建於一個錢包來開始使用源點。",
"walletCreatePassword": "在下方設置你的付款密碼",
"walletCurrencyShort": "源點",
"walletCurrency": {
"one": "{} 源點",
"other": "{} 源點"
},
"aiThinkingProcess": "AI 思考過程",
"accountSettingsApplied": "帳號設置已應用。"
}

View File

@ -379,7 +379,7 @@ SPEC CHECKSUMS:
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Firebase: 374a441a91ead896215703a674d58cdb3e9d772b
firebase_analytics: 13ea4ad8a42c5060bad7e6694304dabb8b02fe7e

View File

@ -71,22 +71,29 @@ class NotificationProvider extends ChangeNotifier {
);
}
int showingCount = 0;
List<SnNotification> notifications = List.empty(growable: true);
void listen() {
_ws.stream.stream.listen((event) {
if (event.method == 'notifications.new') {
final notification = SnNotification.fromJson(event.payload!);
if (showingCount < 0) showingCount = 0;
showingCount++;
notifications.add(notification);
Future.delayed(const Duration(seconds: 3), () {
if (showingCount >= 0) showingCount--;
notifyListeners();
});
notifyListeners();
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
if (doHaptic) HapticFeedback.lightImpact();
if (doHaptic) HapticFeedback.mediumImpact();
}
});
}
void clear() {
notifications.clear();
showingCount = 0;
notifyListeners();
}
}

View File

@ -53,4 +53,11 @@ class UserProvider extends ChangeNotifier {
user = null;
notifyListeners();
}
void setLanguage(String? value) {
if (value == null) return;
if (user == null) return;
user = user!.copyWith(language: value);
notifyListeners();
}
}

View File

@ -33,6 +33,7 @@ import 'package:surface/screens/realm/manage.dart';
import 'package:surface/screens/realm/realm_detail.dart';
import 'package:surface/screens/settings.dart';
import 'package:surface/screens/sharing.dart';
import 'package:surface/screens/wallet.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/about.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
@ -72,7 +73,7 @@ final _appRoutes = [
postRepostId: int.tryParse(
state.uri.queryParameters['reposting'] ?? '',
),
extraProps: state.extra as PostEditorExtraProps?,
extraProps: state.extra as PostEditorExtra?,
),
),
GoRoute(
@ -99,6 +100,11 @@ final _appRoutes = [
],
),
GoRoute(path: '/account', name: 'account', builder: (context, state) => const AccountScreen(), routes: [
GoRoute(
path: '/wallet',
name: 'accountWallet',
builder: (context, state) => const WalletScreen(),
),
GoRoute(
path: '/settings',
name: 'accountSettings',
@ -150,6 +156,7 @@ final _appRoutes = [
builder: (context, state) => ChatRoomScreen(
scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!,
extra: state.extra as ChatRoomScreenExtra?,
),
),
GoRoute(

View File

@ -28,7 +28,19 @@ class AccountScreen extends StatelessWidget {
return AppScaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text("screenAccount").tr(),
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(),
flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
? Stack(
fit: StackFit.expand,
@ -144,6 +156,16 @@ class _AuthorizedAccountScreen extends StatelessWidget {
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('accountSettings').tr(),
subtitle: Text('accountSettingsSubtitle').tr(),

View File

@ -1,17 +1,41 @@
import 'package:collection/collection.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
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/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:intl/locale.dart';
class AccountSettingsScreen extends StatelessWidget {
const AccountSettingsScreen({super.key});
Future<void> _setAccountLanguage(BuildContext context, Locale? value) async {
if (value == null) return;
try {
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
await sn.client.put('/cgi/id/users/me/language', data: {
'language': value.toString(),
});
if (!context.mounted) return;
context.showSnackbar('accountSettingsApplied'.tr());
await ua.refreshUser();
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
}
}
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
return AppScaffold(
appBar: AppBar(
leading: PageBackButton(),
@ -21,6 +45,42 @@ class AccountSettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text('settingsAccountLanguage').tr(),
subtitle: Text('settingsAccountLanguageDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.translate),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<Locale?>(
isExpanded: true,
items: [
...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
return DropdownMenuItem<Locale?>(
value: Locale.parse(ele.toString()),
child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
);
}),
],
value: ua.user?.language != null ? Locale.parse(ua.user!.language) : Locale.parse('en-US'),
onChanged: (Locale? value) {
if (value == null) return;
_setAccountLanguage(context, value);
ua.setLanguage(value.toString());
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 5,
),
height: 40,
width: 160,
),
menuItemStyleData: const MenuItemStyleData(
height: 40,
),
),
),
),
ListTile(
title: Text('accountProfileEdit').tr(),
subtitle: Text('accountProfileEditSubtitle').tr(),

View File

@ -238,7 +238,7 @@ class _FactorNewDialogState extends State<_FactorNewDialog> {
class _FactorTotpFactorDialog extends StatelessWidget {
final SnAuthFactor factor;
const _FactorTotpFactorDialog({super.key, required this.factor});
const _FactorTotpFactorDialog({required this.factor});
@override
Widget build(BuildContext context) {

View File

@ -412,8 +412,9 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
await sn.client.post('/cgi/id/users/me/password-reset', data: {
'user_id': lookupResp.data['id'],
});
if (mounted)
if (mounted) {
context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
}
} catch (err) {
if (mounted) context.showErrorDialog(err);
} finally {

View File

@ -44,6 +44,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
'nick': nickname,
'email': email,
'password': password,
'language': EasyLocalization.of(context)!.currentLocale.toString(),
});
if (!context.mounted) return;

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
@ -9,9 +10,12 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/chat_message_controller.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/chat_call.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/chat/call/call_prejoin.dart';
@ -23,14 +27,19 @@ import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../../providers/user_directory.dart';
import '../../providers/userinfo.dart';
class ChatRoomScreenExtra {
final String? initialText;
final List<PostWriteMedia>? initialAttachments;
ChatRoomScreenExtra({this.initialText, this.initialAttachments});
}
class ChatRoomScreen extends StatefulWidget {
final String scope;
final String alias;
final ChatRoomScreenExtra? extra;
const ChatRoomScreen({super.key, required this.scope, required this.alias});
const ChatRoomScreen({super.key, required this.scope, required this.alias, this.extra});
@override
State<ChatRoomScreen> createState() => _ChatRoomScreenState();
@ -177,8 +186,23 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
_messageController = ChatMessageController(context);
_fetchChannel().then((_) async {
await _messageController.initialize(_channel!);
await _messageController.checkUpdate();
await _fetchOngoingCall();
if (widget.extra != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
log('[ChatInput] Setting initial text and attachments...');
if (widget.extra!.initialText != null) {
_inputGlobalKey.currentState?.setInitialText(widget.extra!.initialText!);
}
if (widget.extra!.initialAttachments != null) {
_inputGlobalKey.currentState?.setInitialAttachments(widget.extra!.initialAttachments!);
}
});
}
await Future.wait([
_messageController.checkUpdate(),
_fetchOngoingCall(),
]);
});
final ws = context.read<WebSocketProvider>();

View File

@ -288,6 +288,7 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
child: InkWell(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
@ -302,20 +303,18 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Builder(
builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 2,
children: [
Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75);
}
),
Builder(builder: (context) {
final date = _article!.publishedAt ?? _article!.createdAt;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 2,
children: [
Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
],
).opacity(0.75);
}),
],
).padding(horizontal: 16),
onTap: () {
@ -515,6 +514,11 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
'+${_todayRecord!.resultExperience} EXP',
style: Theme.of(context).textTheme.bodyLarge,
),
if (_todayRecord!.resultCoin >= 0)
Text(
'+${_todayRecord!.resultCoin} ${'walletCurrencyShort'.tr()}',
style: Theme.of(context).textTheme.bodyLarge,
)
],
),
),

View File

@ -21,6 +21,16 @@ import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../providers/userinfo.dart';
import '../widgets/unauthorized_hint.dart';
const Map<String, IconData> kNotificationTopicIcons = {
'general': Symbols.notifications,
'passport.security.alert': Symbols.gpp_maybe,
'passport.security.otp': Symbols.password,
'interactive.subscription': Symbols.subscriptions,
'interactive.feedback': Symbols.add_reaction,
'messaging.callStart': Symbols.call_received,
'wallet.transaction.new': Symbols.receipt,
};
class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key});
@ -36,13 +46,6 @@ class _NotificationScreenState extends State<NotificationScreen> {
final List<SnNotification> _notifications = List.empty(growable: true);
int? _totalCount;
static const Map<String, IconData> kNotificationTopicIcons = {
'passport.security.alert': Symbols.gpp_maybe,
'interactive.subscription': Symbols.subscriptions,
'interactive.feedback': Symbols.add_reaction,
'messaging.callStart': Symbols.call_received,
};
Future<void> _fetchNotifications() async {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;

View File

@ -20,13 +20,13 @@ import 'package:surface/widgets/post/post_meta_editor.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:provider/provider.dart';
class PostEditorExtraProps {
class PostEditorExtra {
final String? text;
final String? title;
final String? description;
final List<PostWriteMedia>? attachments;
const PostEditorExtraProps({
const PostEditorExtra({
this.text,
this.title,
this.description,
@ -39,7 +39,7 @@ class PostEditorScreen extends StatefulWidget {
final int? postEditId;
final int? postReplyId;
final int? postRepostId;
final PostEditorExtraProps? extraProps;
final PostEditorExtra? extraProps;
const PostEditorScreen({
super.key,

View File

@ -82,6 +82,48 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('settingsAppearance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
ListTile(
title: Text('settingsDisplayLanguage').tr(),
subtitle: Text('settingsDisplayLanguageDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.translate),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<Locale?>(
isExpanded: true,
items: [
...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
return DropdownMenuItem<Locale?>(
value: ele,
child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
);
}),
DropdownMenuItem<Locale?>(
value: null,
child: Text('settingsDisplayLanguageSystem').tr().fontSize(14),
),
],
value: EasyLocalization.of(context)!.currentLocale,
onChanged: (Locale? value) {
if (value != null) {
EasyLocalization.of(context)!.setLocale(value);
} else {
EasyLocalization.of(context)!.resetLocale();
}
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 5,
),
height: 40,
width: 160,
),
menuItemStyleData: const MenuItemStyleData(
height: 40,
),
),
),
),
if (!kIsWeb)
ListTile(
title: Text('settingsBackgroundImage').tr(),
@ -147,30 +189,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value);
final color = await showDialog<Color?>(
context: context,
builder: (context) => AlertDialog(
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: pickerColor,
onColorChanged: (color) => pickerColor = color,
enableAlpha: false,
hexInputBar: true,
builder: (context) =>
AlertDialog(
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: pickerColor,
onColorChanged: (color) => pickerColor = color,
enableAlpha: false,
hexInputBar: true,
),
),
actions: <Widget>[
TextButton(
child: const Text('dialogDismiss').tr(),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: const Text('dialogConfirm').tr(),
onPressed: () {
Navigator.of(context).pop(pickerColor);
},
),
],
),
),
actions: <Widget>[
TextButton(
child: const Text('dialogDismiss').tr(),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: const Text('dialogConfirm').tr(),
onPressed: () {
Navigator.of(context).pop(pickerColor);
},
),
],
),
);
if (color == null || !context.mounted) return;
@ -206,11 +249,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
value: _prefs.getInt(kAppColorSchemeStoreKey) == null
? 1
: kColorSchemes.values
.toList()
.indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)),
.toList()
.indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)),
onChanged: (int? value) {
if (value != null && value != -1) {
_prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values.elementAt(value).value);
_prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values
.elementAt(value)
.value);
final th = context.read<ThemeProvider>();
th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value));
setState(() {});
@ -342,7 +387,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
('Custom', _serverUrlController.text),
]
.map(
(item) => DropdownMenuItem<String>(
(item) =>
DropdownMenuItem<String>(
value: item.$2,
child: Column(
mainAxisSize: MainAxisSize.max,
@ -354,7 +400,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
),
)
)
.toList(),
value: _serverUrlController.text,
onChanged: (String? value) {
@ -409,11 +455,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
isExpanded: true,
items: kImageQualityLevel.entries
.map(
(item) => DropdownMenuItem<FilterQuality>(
(item) =>
DropdownMenuItem<FilterQuality>(
value: item.value,
child: Text(item.key).tr().fontSize(14),
),
)
)
.toList(),
onChanged: (FilterQuality? value) {
if (value == null) return;

View File

@ -8,9 +8,20 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:provider/provider.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/screens/chat/room.dart';
import 'package:surface/screens/post/post_editor.dart';
import 'package:surface/types/chat.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
class AppSharingListener extends StatefulWidget {
final Widget child;
@ -51,20 +62,39 @@ class _AppSharingListenerState extends State<AppSharingListener> {
pathParameters: {
'mode': 'stories',
},
extra: PostEditorExtraProps(
extra: PostEditorExtra(
text: value
.where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
.map((e) => e.path).join('\n'),
.map((e) => e.path)
.join('\n'),
attachments: value
.where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
.map((e) => PostWriteMedia.fromFile(XFile(e.path))).toList(),
.where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image]
.contains(e.type))
.map((e) => PostWriteMedia.fromFile(XFile(e.path)))
.toList(),
),
);
Navigator.pop(context);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
leading: Icon(Icons.chat_outlined),
trailing: const Icon(Icons.chevron_right),
title: Text('shareIntentSendChannel').tr(),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => _ShareIntentChannelSelect(value: value),
).then((val) {
if (!context.mounted) return;
if (val == true) Navigator.pop(context);
});
},
),
],
),
).width(280),
)
],
),
@ -103,7 +133,7 @@ class _AppSharingListenerState extends State<AppSharingListener> {
@override
void initState() {
super.initState();
if(!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
_initialize();
_initialHandle();
}
@ -120,3 +150,193 @@ class _AppSharingListenerState extends State<AppSharingListener> {
return widget.child;
}
}
class _ShareIntentChannelSelect extends StatefulWidget {
final Iterable<SharedMediaFile> value;
const _ShareIntentChannelSelect({required this.value});
@override
State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState();
}
class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
bool _isBusy = true;
List<SnChannel>? _channels;
Map<int, SnChatMessage>? _lastMessages;
void _refreshChannels() {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) {
setState(() => _isBusy = false);
return;
}
final chan = context.read<ChatChannelProvider>();
chan.fetchChannels().listen((channels) async {
final lastMessages = await chan.getLastMessages(channels);
_lastMessages = {for (final val in lastMessages) val.channelId: val};
channels.sort((a, b) {
if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
}
if (_lastMessages!.containsKey(a.id)) return -1;
if (_lastMessages!.containsKey(b.id)) return 1;
return 0;
});
if (!mounted) return;
final ud = context.read<UserDirectoryProvider>();
for (final channel in channels) {
if (channel.type == 1) {
await ud.listAccount(
channel.members
?.cast<SnChannelMember?>()
.map((ele) => ele?.accountId)
.where((ele) => ele != null)
.toSet() ??
{},
);
}
}
if (mounted) setState(() => _channels = channels);
})
..onError((err) {
if (!mounted) return;
context.showErrorDialog(err);
setState(() => _isBusy = false);
})
..onDone(() {
if (!mounted) return;
setState(() => _isBusy = false);
});
}
@override
void initState() {
super.initState();
_refreshChannels();
}
@override
Widget build(BuildContext context) {
final ua = context.read<UserProvider>();
final ud = context.read<UserDirectoryProvider>();
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.chat, size: 24),
const Gap(16),
Text('shareIntentSendChannel', style: Theme.of(context).textTheme.titleLarge).tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
LoadingIndicator(isActive: _isBusy),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _refreshChannels()),
child: ListView.builder(
itemCount: _channels?.length ?? 0,
itemBuilder: (context, idx) {
final channel = _channels![idx];
final lastMessage = _lastMessages?[channel.id];
if (channel.type == 1) {
final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
(ele) => ele?.accountId != ua.user?.id,
orElse: () => null,
);
return ListTile(
title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
subtitle: lastMessage != null
? Text(
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
'channelDirectMessageDescription'.tr(args: [
'@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(
content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
),
onTap: () {
GoRouter.of(context).pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
).then((value) {
if (mounted) _refreshChannels();
});
},
);
}
return ListTile(
title: Text(channel.name),
subtitle: lastMessage != null
? Text(
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
channel.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage(
content: null,
fallbackWidget: const Icon(Symbols.chat, size: 20),
),
onTap: () {
Navigator.pop(context, true);
GoRouter.of(context)
.pushNamed(
'chatRoom',
pathParameters: {
'scope': channel.realm?.alias ?? 'global',
'alias': channel.alias,
},
extra: ChatRoomScreenExtra(
initialText: widget.value
.where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
.map((e) => e.path)
.join('\n'),
initialAttachments: widget.value
.where((e) =>
[SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
.map((e) => PostWriteMedia.fromFile(XFile(e.path)))
.toList(),
),
)
.then((value) {
if (value == true) _refreshChannels();
});
},
);
},
),
),
),
),
],
);
}
}

279
lib/screens/wallet.dart Normal file
View File

@ -0,0 +1,279 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/wallet.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class WalletScreen extends StatefulWidget {
const WalletScreen({super.key});
@override
State<WalletScreen> createState() => _WalletScreenState();
}
class _WalletScreenState extends State<WalletScreen> {
bool _isBusy = false;
SnWallet? _wallet;
Future<void> _fetchWallet() async {
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/wa/wallets/me');
_wallet = SnWallet.fromJson(resp.data);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchWallet();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
leading: PageBackButton(),
title: Text('screenAccountWallet').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
if (_wallet == null)
Expanded(
child: _CreateWalletWidget(
onCreate: () {
_fetchWallet();
},
),
)
else
Card(
child: Column(
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(),
symbol: '${'walletCurrencyShort'.tr()} ',
decimalDigits: 2,
).format(double.parse(_wallet!.balance)),
style: Theme.of(context).textTheme.titleLarge,
),
Text('walletCurrency'.plural(double.parse(_wallet!.balance))),
],
).padding(horizontal: 20, vertical: 24),
).padding(horizontal: 8, top: 16, bottom: 4),
if (_wallet != null) Expanded(child: _WalletTransactionList(myself: _wallet!)),
],
),
);
}
}
class _WalletTransactionList extends StatefulWidget {
final SnWallet myself;
const _WalletTransactionList({required this.myself});
@override
State<_WalletTransactionList> createState() => _WalletTransactionListState();
}
class _WalletTransactionListState extends State<_WalletTransactionList> {
bool _isBusy = false;
int? _totalCount;
final List<SnTransaction> _transactions = List.empty(growable: true);
Future<void> _fetchTransactions() async {
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>() ?? [],
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchTransactions();
}
@override
Widget build(BuildContext context) {
return MediaQuery.removePadding(
context: context,
removeTop: true,
child: RefreshIndicator(
onRefresh: _fetchTransactions,
child: InfiniteList(
itemCount: _transactions.length,
isLoading: _isBusy,
hasReachedMax: _totalCount != null && _transactions.length >= _totalCount!,
onFetchData: () {
_fetchTransactions();
},
itemBuilder: (context, idx) {
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),
title: Text(
'${isIncoming ? '+' : '-'}${ele.amount} ${'walletCurrencyShort'.tr()}',
style: TextStyle(color: isIncoming ? Colors.green : Colors.red),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ele.remark),
const Gap(2),
Text(
DateFormat(
null,
EasyLocalization.of(context)!.currentLocale.toString(),
).format(ele.createdAt),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
);
},
),
),
);
}
}
class _CreateWalletWidget extends StatefulWidget {
final Function()? onCreate;
const _CreateWalletWidget({required this.onCreate});
@override
State<_CreateWalletWidget> createState() => _CreateWalletWidgetState();
}
class _CreateWalletWidgetState extends State<_CreateWalletWidget> {
bool _isBusy = false;
Future<void> _createWallet() async {
final TextEditingController passwordController = TextEditingController();
final password = await showDialog<String?>(
context: context,
builder: (ctx) => AlertDialog(
title: Text('walletCreate').tr(),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('walletCreatePassword').tr(),
const Gap(8),
TextField(
autofocus: true,
obscureText: true,
controller: passwordController,
decoration: InputDecoration(
labelText: 'fieldPassword'.tr(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text('cancel').tr(),
),
TextButton(
onPressed: () {
Navigator.of(ctx).pop(passwordController.text);
},
child: Text('next').tr(),
),
],
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
passwordController.dispose();
});
if (password == null || password.isEmpty) return;
if (!mounted) return;
try {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/wa/wallets/me', data: {
'password': password,
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 380),
child: Card(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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(),
const Gap(8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _isBusy ? null : () => _createWallet(),
child: Text('next').tr(),
),
),
],
).padding(horizontal: 20, vertical: 24),
),
),
);
}
}

View File

@ -21,6 +21,7 @@ class SnAccount with _$SnAccount {
required String name,
required String nick,
required Map<String, dynamic> permNodes,
required String language,
required SnAccountProfile? profile,
@Default([]) List<SnAccountBadge> badges,
required DateTime? suspendedAt,

View File

@ -33,6 +33,7 @@ mixin _$SnAccount {
String get name => throw _privateConstructorUsedError;
String get nick => throw _privateConstructorUsedError;
Map<String, dynamic> get permNodes => throw _privateConstructorUsedError;
String get language => throw _privateConstructorUsedError;
SnAccountProfile? get profile => throw _privateConstructorUsedError;
List<SnAccountBadge> get badges => throw _privateConstructorUsedError;
DateTime? get suspendedAt => throw _privateConstructorUsedError;
@ -69,6 +70,7 @@ abstract class $SnAccountCopyWith<$Res> {
String name,
String nick,
Map<String, dynamic> permNodes,
String language,
SnAccountProfile? profile,
List<SnAccountBadge> badges,
DateTime? suspendedAt,
@ -107,6 +109,7 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount>
Object? name = null,
Object? nick = null,
Object? permNodes = null,
Object? language = null,
Object? profile = freezed,
Object? badges = null,
Object? suspendedAt = freezed,
@ -164,6 +167,10 @@ class _$SnAccountCopyWithImpl<$Res, $Val extends SnAccount>
? _value.permNodes
: permNodes // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
language: null == language
? _value.language
: language // ignore: cast_nullable_to_non_nullable
as String,
profile: freezed == profile
? _value.profile
: profile // ignore: cast_nullable_to_non_nullable
@ -231,6 +238,7 @@ abstract class _$$SnAccountImplCopyWith<$Res>
String name,
String nick,
Map<String, dynamic> permNodes,
String language,
SnAccountProfile? profile,
List<SnAccountBadge> badges,
DateTime? suspendedAt,
@ -268,6 +276,7 @@ class __$$SnAccountImplCopyWithImpl<$Res>
Object? name = null,
Object? nick = null,
Object? permNodes = null,
Object? language = null,
Object? profile = freezed,
Object? badges = null,
Object? suspendedAt = freezed,
@ -325,6 +334,10 @@ class __$$SnAccountImplCopyWithImpl<$Res>
? _value._permNodes
: permNodes // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
language: null == language
? _value.language
: language // ignore: cast_nullable_to_non_nullable
as String,
profile: freezed == profile
? _value.profile
: profile // ignore: cast_nullable_to_non_nullable
@ -373,6 +386,7 @@ class _$SnAccountImpl extends _SnAccount {
required this.name,
required this.nick,
required final Map<String, dynamic> permNodes,
required this.language,
required this.profile,
final List<SnAccountBadge> badges = const [],
required this.suspendedAt,
@ -429,6 +443,8 @@ class _$SnAccountImpl extends _SnAccount {
return EqualUnmodifiableMapView(_permNodes);
}
@override
final String language;
@override
final SnAccountProfile? profile;
final List<SnAccountBadge> _badges;
@ -453,7 +469,7 @@ class _$SnAccountImpl extends _SnAccount {
@override
String toString() {
return 'SnAccount(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, confirmedAt: $confirmedAt, contacts: $contacts, avatar: $avatar, banner: $banner, description: $description, name: $name, nick: $nick, permNodes: $permNodes, profile: $profile, badges: $badges, suspendedAt: $suspendedAt, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId)';
return 'SnAccount(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, confirmedAt: $confirmedAt, contacts: $contacts, avatar: $avatar, banner: $banner, description: $description, name: $name, nick: $nick, permNodes: $permNodes, language: $language, profile: $profile, badges: $badges, suspendedAt: $suspendedAt, affiliatedId: $affiliatedId, affiliatedTo: $affiliatedTo, automatedBy: $automatedBy, automatedId: $automatedId)';
}
@override
@ -479,6 +495,8 @@ class _$SnAccountImpl extends _SnAccount {
(identical(other.nick, nick) || other.nick == nick) &&
const DeepCollectionEquality()
.equals(other._permNodes, _permNodes) &&
(identical(other.language, language) ||
other.language == language) &&
(identical(other.profile, profile) || other.profile == profile) &&
const DeepCollectionEquality().equals(other._badges, _badges) &&
(identical(other.suspendedAt, suspendedAt) ||
@ -509,6 +527,7 @@ class _$SnAccountImpl extends _SnAccount {
name,
nick,
const DeepCollectionEquality().hash(_permNodes),
language,
profile,
const DeepCollectionEquality().hash(_badges),
suspendedAt,
@ -548,6 +567,7 @@ abstract class _SnAccount extends SnAccount {
required final String name,
required final String nick,
required final Map<String, dynamic> permNodes,
required final String language,
required final SnAccountProfile? profile,
final List<SnAccountBadge> badges,
required final DateTime? suspendedAt,
@ -586,6 +606,8 @@ abstract class _SnAccount extends SnAccount {
@override
Map<String, dynamic> get permNodes;
@override
String get language;
@override
SnAccountProfile? get profile;
@override
List<SnAccountBadge> get badges;

View File

@ -26,6 +26,7 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
name: json['name'] as String,
nick: json['nick'] as String,
permNodes: json['perm_nodes'] as Map<String, dynamic>,
language: json['language'] as String,
profile: json['profile'] == null
? null
: SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>),
@ -56,6 +57,7 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
'name': instance.name,
'nick': instance.nick,
'perm_nodes': instance.permNodes,
'language': instance.language,
'profile': instance.profile?.toJson(),
'badges': instance.badges.map((e) => e.toJson()).toList(),
'suspended_at': instance.suspendedAt?.toIso8601String(),

View File

@ -16,6 +16,7 @@ class SnCheckInRecord with _$SnCheckInRecord {
required DateTime? deletedAt,
required int resultTier,
required int resultExperience,
required double resultCoin,
required List<int> resultModifiers,
required int accountId,
}) = _SnCheckInRecord;

View File

@ -26,6 +26,7 @@ mixin _$SnCheckInRecord {
DateTime? get deletedAt => throw _privateConstructorUsedError;
int get resultTier => throw _privateConstructorUsedError;
int get resultExperience => throw _privateConstructorUsedError;
double get resultCoin => throw _privateConstructorUsedError;
List<int> get resultModifiers => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError;
@ -52,6 +53,7 @@ abstract class $SnCheckInRecordCopyWith<$Res> {
DateTime? deletedAt,
int resultTier,
int resultExperience,
double resultCoin,
List<int> resultModifiers,
int accountId});
}
@ -77,6 +79,7 @@ class _$SnCheckInRecordCopyWithImpl<$Res, $Val extends SnCheckInRecord>
Object? deletedAt = freezed,
Object? resultTier = null,
Object? resultExperience = null,
Object? resultCoin = null,
Object? resultModifiers = null,
Object? accountId = null,
}) {
@ -105,6 +108,10 @@ class _$SnCheckInRecordCopyWithImpl<$Res, $Val extends SnCheckInRecord>
? _value.resultExperience
: resultExperience // ignore: cast_nullable_to_non_nullable
as int,
resultCoin: null == resultCoin
? _value.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable
as double,
resultModifiers: null == resultModifiers
? _value.resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable
@ -132,6 +139,7 @@ abstract class _$$SnCheckInRecordImplCopyWith<$Res>
DateTime? deletedAt,
int resultTier,
int resultExperience,
double resultCoin,
List<int> resultModifiers,
int accountId});
}
@ -155,6 +163,7 @@ class __$$SnCheckInRecordImplCopyWithImpl<$Res>
Object? deletedAt = freezed,
Object? resultTier = null,
Object? resultExperience = null,
Object? resultCoin = null,
Object? resultModifiers = null,
Object? accountId = null,
}) {
@ -183,6 +192,10 @@ class __$$SnCheckInRecordImplCopyWithImpl<$Res>
? _value.resultExperience
: resultExperience // ignore: cast_nullable_to_non_nullable
as int,
resultCoin: null == resultCoin
? _value.resultCoin
: resultCoin // ignore: cast_nullable_to_non_nullable
as double,
resultModifiers: null == resultModifiers
? _value._resultModifiers
: resultModifiers // ignore: cast_nullable_to_non_nullable
@ -205,6 +218,7 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
required this.deletedAt,
required this.resultTier,
required this.resultExperience,
required this.resultCoin,
required final List<int> resultModifiers,
required this.accountId})
: _resultModifiers = resultModifiers,
@ -225,6 +239,8 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
final int resultTier;
@override
final int resultExperience;
@override
final double resultCoin;
final List<int> _resultModifiers;
@override
List<int> get resultModifiers {
@ -238,7 +254,7 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
@override
String toString() {
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultModifiers: $resultModifiers, accountId: $accountId)';
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)';
}
@override
@ -257,6 +273,8 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
other.resultTier == resultTier) &&
(identical(other.resultExperience, resultExperience) ||
other.resultExperience == resultExperience) &&
(identical(other.resultCoin, resultCoin) ||
other.resultCoin == resultCoin) &&
const DeepCollectionEquality()
.equals(other._resultModifiers, _resultModifiers) &&
(identical(other.accountId, accountId) ||
@ -273,6 +291,7 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
deletedAt,
resultTier,
resultExperience,
resultCoin,
const DeepCollectionEquality().hash(_resultModifiers),
accountId);
@ -301,6 +320,7 @@ abstract class _SnCheckInRecord extends SnCheckInRecord {
required final DateTime? deletedAt,
required final int resultTier,
required final int resultExperience,
required final double resultCoin,
required final List<int> resultModifiers,
required final int accountId}) = _$SnCheckInRecordImpl;
const _SnCheckInRecord._() : super._();
@ -321,6 +341,8 @@ abstract class _SnCheckInRecord extends SnCheckInRecord {
@override
int get resultExperience;
@override
double get resultCoin;
@override
List<int> get resultModifiers;
@override
int get accountId;

View File

@ -17,6 +17,7 @@ _$SnCheckInRecordImpl _$$SnCheckInRecordImplFromJson(
: DateTime.parse(json['deleted_at'] as String),
resultTier: (json['result_tier'] as num).toInt(),
resultExperience: (json['result_experience'] as num).toInt(),
resultCoin: (json['result_coin'] as num).toDouble(),
resultModifiers: (json['result_modifiers'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
@ -32,6 +33,7 @@ Map<String, dynamic> _$$SnCheckInRecordImplToJson(
'deleted_at': instance.deletedAt?.toIso8601String(),
'result_tier': instance.resultTier,
'result_experience': instance.resultExperience,
'result_coin': instance.resultCoin,
'result_modifiers': instance.resultModifiers,
'account_id': instance.accountId,
};

37
lib/types/wallet.dart Normal file
View File

@ -0,0 +1,37 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'wallet.freezed.dart';
part 'wallet.g.dart';
@freezed
class SnWallet with _$SnWallet {
const factory SnWallet({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String balance,
required String password,
required int accountId,
}) = _SnWallet;
factory SnWallet.fromJson(Map<String, dynamic> json) => _$SnWalletFromJson(json);
}
@freezed
class SnTransaction with _$SnTransaction {
const factory SnTransaction({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String remark,
required String amount,
required SnWallet? payer,
required SnWallet? payee,
required int? payerId,
required int? payeeId,
}) = _SnTransaction;
factory SnTransaction.fromJson(Map<String, dynamic> json) => _$SnTransactionFromJson(json);
}

View File

@ -0,0 +1,666 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'wallet.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SnWallet _$SnWalletFromJson(Map<String, dynamic> json) {
return _SnWallet.fromJson(json);
}
/// @nodoc
mixin _$SnWallet {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
String get balance => throw _privateConstructorUsedError;
String get password => throw _privateConstructorUsedError;
int get accountId => throw _privateConstructorUsedError;
/// Serializes this SnWallet to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnWallet
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnWalletCopyWith<SnWallet> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnWalletCopyWith<$Res> {
factory $SnWalletCopyWith(SnWallet value, $Res Function(SnWallet) then) =
_$SnWalletCopyWithImpl<$Res, SnWallet>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String balance,
String password,
int accountId});
}
/// @nodoc
class _$SnWalletCopyWithImpl<$Res, $Val extends SnWallet>
implements $SnWalletCopyWith<$Res> {
_$SnWalletCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnWallet
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? balance = null,
Object? password = null,
Object? accountId = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
balance: null == balance
? _value.balance
: balance // ignore: cast_nullable_to_non_nullable
as String,
password: null == password
? _value.password
: password // ignore: cast_nullable_to_non_nullable
as String,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnWalletImplCopyWith<$Res>
implements $SnWalletCopyWith<$Res> {
factory _$$SnWalletImplCopyWith(
_$SnWalletImpl value, $Res Function(_$SnWalletImpl) then) =
__$$SnWalletImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String balance,
String password,
int accountId});
}
/// @nodoc
class __$$SnWalletImplCopyWithImpl<$Res>
extends _$SnWalletCopyWithImpl<$Res, _$SnWalletImpl>
implements _$$SnWalletImplCopyWith<$Res> {
__$$SnWalletImplCopyWithImpl(
_$SnWalletImpl _value, $Res Function(_$SnWalletImpl) _then)
: super(_value, _then);
/// Create a copy of SnWallet
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? balance = null,
Object? password = null,
Object? accountId = null,
}) {
return _then(_$SnWalletImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
balance: null == balance
? _value.balance
: balance // ignore: cast_nullable_to_non_nullable
as String,
password: null == password
? _value.password
: password // ignore: cast_nullable_to_non_nullable
as String,
accountId: null == accountId
? _value.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnWalletImpl implements _SnWallet {
const _$SnWalletImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.balance,
required this.password,
required this.accountId});
factory _$SnWalletImpl.fromJson(Map<String, dynamic> json) =>
_$$SnWalletImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String balance;
@override
final String password;
@override
final int accountId;
@override
String toString() {
return 'SnWallet(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, balance: $balance, password: $password, accountId: $accountId)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnWalletImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.balance, balance) || other.balance == balance) &&
(identical(other.password, password) ||
other.password == password) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, balance, password, accountId);
/// Create a copy of SnWallet
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnWalletImplCopyWith<_$SnWalletImpl> get copyWith =>
__$$SnWalletImplCopyWithImpl<_$SnWalletImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnWalletImplToJson(
this,
);
}
}
abstract class _SnWallet implements SnWallet {
const factory _SnWallet(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final String balance,
required final String password,
required final int accountId}) = _$SnWalletImpl;
factory _SnWallet.fromJson(Map<String, dynamic> json) =
_$SnWalletImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
String get balance;
@override
String get password;
@override
int get accountId;
/// Create a copy of SnWallet
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnWalletImplCopyWith<_$SnWalletImpl> get copyWith =>
throw _privateConstructorUsedError;
}
SnTransaction _$SnTransactionFromJson(Map<String, dynamic> json) {
return _SnTransaction.fromJson(json);
}
/// @nodoc
mixin _$SnTransaction {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
DateTime? get deletedAt => throw _privateConstructorUsedError;
String get remark => throw _privateConstructorUsedError;
String get amount => throw _privateConstructorUsedError;
SnWallet? get payer => throw _privateConstructorUsedError;
SnWallet? get payee => throw _privateConstructorUsedError;
int? get payerId => throw _privateConstructorUsedError;
int? get payeeId => throw _privateConstructorUsedError;
/// Serializes this SnTransaction to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnTransactionCopyWith<SnTransaction> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnTransactionCopyWith<$Res> {
factory $SnTransactionCopyWith(
SnTransaction value, $Res Function(SnTransaction) then) =
_$SnTransactionCopyWithImpl<$Res, SnTransaction>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String remark,
String amount,
SnWallet? payer,
SnWallet? payee,
int? payerId,
int? payeeId});
$SnWalletCopyWith<$Res>? get payer;
$SnWalletCopyWith<$Res>? get payee;
}
/// @nodoc
class _$SnTransactionCopyWithImpl<$Res, $Val extends SnTransaction>
implements $SnTransactionCopyWith<$Res> {
_$SnTransactionCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? remark = null,
Object? amount = null,
Object? payer = freezed,
Object? payee = freezed,
Object? payerId = freezed,
Object? payeeId = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
remark: null == remark
? _value.remark
: remark // ignore: cast_nullable_to_non_nullable
as String,
amount: null == amount
? _value.amount
: amount // ignore: cast_nullable_to_non_nullable
as String,
payer: freezed == payer
? _value.payer
: payer // ignore: cast_nullable_to_non_nullable
as SnWallet?,
payee: freezed == payee
? _value.payee
: payee // ignore: cast_nullable_to_non_nullable
as SnWallet?,
payerId: freezed == payerId
? _value.payerId
: payerId // ignore: cast_nullable_to_non_nullable
as int?,
payeeId: freezed == payeeId
? _value.payeeId
: payeeId // ignore: cast_nullable_to_non_nullable
as int?,
) as $Val);
}
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnWalletCopyWith<$Res>? get payer {
if (_value.payer == null) {
return null;
}
return $SnWalletCopyWith<$Res>(_value.payer!, (value) {
return _then(_value.copyWith(payer: value) as $Val);
});
}
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnWalletCopyWith<$Res>? get payee {
if (_value.payee == null) {
return null;
}
return $SnWalletCopyWith<$Res>(_value.payee!, (value) {
return _then(_value.copyWith(payee: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$SnTransactionImplCopyWith<$Res>
implements $SnTransactionCopyWith<$Res> {
factory _$$SnTransactionImplCopyWith(
_$SnTransactionImpl value, $Res Function(_$SnTransactionImpl) then) =
__$$SnTransactionImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String remark,
String amount,
SnWallet? payer,
SnWallet? payee,
int? payerId,
int? payeeId});
@override
$SnWalletCopyWith<$Res>? get payer;
@override
$SnWalletCopyWith<$Res>? get payee;
}
/// @nodoc
class __$$SnTransactionImplCopyWithImpl<$Res>
extends _$SnTransactionCopyWithImpl<$Res, _$SnTransactionImpl>
implements _$$SnTransactionImplCopyWith<$Res> {
__$$SnTransactionImplCopyWithImpl(
_$SnTransactionImpl _value, $Res Function(_$SnTransactionImpl) _then)
: super(_value, _then);
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? remark = null,
Object? amount = null,
Object? payer = freezed,
Object? payee = freezed,
Object? payerId = freezed,
Object? payeeId = freezed,
}) {
return _then(_$SnTransactionImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
remark: null == remark
? _value.remark
: remark // ignore: cast_nullable_to_non_nullable
as String,
amount: null == amount
? _value.amount
: amount // ignore: cast_nullable_to_non_nullable
as String,
payer: freezed == payer
? _value.payer
: payer // ignore: cast_nullable_to_non_nullable
as SnWallet?,
payee: freezed == payee
? _value.payee
: payee // ignore: cast_nullable_to_non_nullable
as SnWallet?,
payerId: freezed == payerId
? _value.payerId
: payerId // ignore: cast_nullable_to_non_nullable
as int?,
payeeId: freezed == payeeId
? _value.payeeId
: payeeId // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnTransactionImpl implements _SnTransaction {
const _$SnTransactionImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.remark,
required this.amount,
required this.payer,
required this.payee,
required this.payerId,
required this.payeeId});
factory _$SnTransactionImpl.fromJson(Map<String, dynamic> json) =>
_$$SnTransactionImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String remark;
@override
final String amount;
@override
final SnWallet? payer;
@override
final SnWallet? payee;
@override
final int? payerId;
@override
final int? 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)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnTransactionImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
(identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) &&
(identical(other.remark, remark) || other.remark == remark) &&
(identical(other.amount, amount) || other.amount == amount) &&
(identical(other.payer, payer) || other.payer == payer) &&
(identical(other.payee, payee) || other.payee == payee) &&
(identical(other.payerId, payerId) || other.payerId == payerId) &&
(identical(other.payeeId, payeeId) || other.payeeId == payeeId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, remark, amount, payer, payee, payerId, payeeId);
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnTransactionImplCopyWith<_$SnTransactionImpl> get copyWith =>
__$$SnTransactionImplCopyWithImpl<_$SnTransactionImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnTransactionImplToJson(
this,
);
}
}
abstract class _SnTransaction implements SnTransaction {
const factory _SnTransaction(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final DateTime? deletedAt,
required final String remark,
required final String amount,
required final SnWallet? payer,
required final SnWallet? payee,
required final int? payerId,
required final int? payeeId}) = _$SnTransactionImpl;
factory _SnTransaction.fromJson(Map<String, dynamic> json) =
_$SnTransactionImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
DateTime? get deletedAt;
@override
String get remark;
@override
String get amount;
@override
SnWallet? get payer;
@override
SnWallet? get payee;
@override
int? get payerId;
@override
int? get payeeId;
/// Create a copy of SnTransaction
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnTransactionImplCopyWith<_$SnTransactionImpl> get copyWith =>
throw _privateConstructorUsedError;
}

65
lib/types/wallet.g.dart Normal file
View File

@ -0,0 +1,65 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'wallet.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnWalletImpl _$$SnWalletImplFromJson(Map<String, dynamic> json) =>
_$SnWalletImpl(
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),
balance: json['balance'] as String,
password: json['password'] as String,
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$$SnWalletImplToJson(_$SnWalletImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'balance': instance.balance,
'password': instance.password,
'account_id': instance.accountId,
};
_$SnTransactionImpl _$$SnTransactionImplFromJson(Map<String, dynamic> json) =>
_$SnTransactionImpl(
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),
remark: json['remark'] as String,
amount: json['amount'] as String,
payer: json['payer'] == null
? null
: SnWallet.fromJson(json['payer'] as Map<String, dynamic>),
payee: json['payee'] == null
? null
: SnWallet.fromJson(json['payee'] as Map<String, dynamic>),
payerId: (json['payer_id'] as num?)?.toInt(),
payeeId: (json['payee_id'] as num?)?.toInt(),
);
Map<String, dynamic> _$$SnTransactionImplToJson(_$SnTransactionImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'remark': instance.remark,
'amount': instance.amount,
'payer': instance.payer?.toJson(),
'payee': instance.payee?.toJson(),
'payer_id': instance.payerId,
'payee_id': instance.payeeId,
};

View File

@ -152,6 +152,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
child: GestureDetector(
behavior: HitTestBehavior.translucent,
child: Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
children: [
Builder(builder: (context) {

View File

@ -46,6 +46,16 @@ class ChatMessageInputState extends State<ChatMessageInput> {
setState(() => _replyingMessage = value);
}
void setInitialText(String? value) {
_contentController.text = value ?? '';
setState(() {});
}
void setInitialAttachments(List<PostWriteMedia>? value) {
_attachments.addAll(value ?? []);
setState(() {});
}
void setEdit(SnChatMessage? value) {
_contentController.text = value?.body['text'] ?? '';
_attachments.clear();

View File

@ -4,6 +4,7 @@ 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/config.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
@ -13,6 +14,9 @@ class ConnectionIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ws = context.watch<WebSocketProvider>();
final cfg = context.watch<ConfigProvider>();
final marginLeft = cfg.drawerIsCollapsed ? 0.0 : cfg.drawerIsExpanded ? 304.0 : 80.0;
return ListenableBuilder(
listenable: ws,
@ -22,45 +26,50 @@ class ConnectionIndicator extends StatelessWidget {
return IgnorePointer(
ignoring: !show,
child: GestureDetector(
child: Material(
elevation: 2,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
color: Theme.of(context).colorScheme.secondaryContainer,
child: ua.isAuthorized
? Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (ws.isBusy)
Text('serverConnecting').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer)
else if (!ws.isConnected)
Text('serverDisconnected').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer)
else
Text('serverConnected').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer),
const Gap(8),
if (ws.isBusy)
const CircularProgressIndicator(strokeWidth: 2.5)
.width(12)
.height(12)
.padding(horizontal: 4, right: 4)
else if (!ws.isConnected)
const Icon(Symbols.power_off, size: 18)
else
const Icon(Symbols.power, size: 18),
],
).padding(horizontal: 8, vertical: 4)
: const SizedBox.shrink(),
).opacity(show ? 1 : 0, animate: true).animate(
const Duration(milliseconds: 300),
Curves.easeInOut,
),
onTap: () {
if (!ws.isConnected && !ws.isBusy) {
ws.connect();
}
},
),
child: Center(
child: GestureDetector(
child: Material(
elevation: 2,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
color: Theme.of(context).colorScheme.secondaryContainer,
child: ua.isAuthorized
? Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (ws.isBusy)
Text('serverConnecting').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer)
else if (!ws.isConnected)
Text('serverDisconnected')
.tr()
.textColor(Theme.of(context).colorScheme.onSecondaryContainer)
else
Text('serverConnected').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer),
const Gap(8),
if (ws.isBusy)
const CircularProgressIndicator(strokeWidth: 2.5)
.width(12)
.height(12)
.padding(horizontal: 4, right: 4)
else if (!ws.isConnected)
const Icon(Symbols.power_off, size: 18)
else
const Icon(Symbols.power, size: 18),
],
).padding(horizontal: 8, vertical: 4)
: const SizedBox.shrink(),
).opacity(show ? 1 : 0, animate: true).animate(
const Duration(milliseconds: 300),
Curves.easeInOut,
),
onTap: () {
if (!ws.isConnected && !ws.isBusy) {
ws.connect();
}
},
),
).padding(left: marginLeft),
);
},
);

View File

@ -28,7 +28,7 @@ class ContextMenuArea extends StatelessWidget {
// Leave padding for side navigation
mousePosition = cfg.drawerIsExpanded
? mousePosition.copyWith(dx: mousePosition.dx - 304 * 2)
: mousePosition.copyWith(dx: mousePosition.dx - 72 * 2);
: mousePosition.copyWith(dx: mousePosition.dx - 80 * 2);
}
},
child: GestureDetector(

View File

@ -2,7 +2,9 @@ import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
extension AppPromptExtension on BuildContext {
void showSnackbar(String content, {SnackBarAction? action}) {
@ -111,7 +113,34 @@ extension AppPromptExtension on BuildContext {
context: this,
builder: (ctx) => AlertDialog(
title: Text('dialogError').tr(),
content: content,
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: [
content,
Text.rich(
TextSpan(
text: 'needHelp'.tr(),
children: [
TextSpan(text: ' '),
TextSpan(
text: 'needHelpLaunch'.tr(),
style: TextStyle(
color: Theme.of(ctx).colorScheme.primary,
decoration: TextDecoration.underline,
decorationColor: Theme.of(ctx).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrlString('https://kb.solsynth.dev/solar-network');
},
),
],
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
@ -128,17 +157,7 @@ extension ByteFormatter on int {
if (this == 0) return '0 Bytes';
const k = 1024;
final dm = decimals < 0 ? 0 : decimals;
final sizes = [
'Bytes',
'KiB',
'MiB',
'GiB',
'TiB',
'PiB',
'EiB',
'ZiB',
'YiB'
];
final sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
final i = (math.log(this) / math.log(k)).floor().toInt();
return '${(this / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
}

View File

@ -20,6 +20,7 @@ class MarkdownTextContent extends StatelessWidget {
final bool isAutoWarp;
final bool isEnlargeSticker;
final TextScaler? textScaler;
final Color? textColor;
final List<SnAttachment?>? attachments;
const MarkdownTextContent({
@ -28,6 +29,7 @@ class MarkdownTextContent extends StatelessWidget {
this.isAutoWarp = false,
this.isEnlargeSticker = false,
this.textScaler,
this.textColor,
this.attachments,
});
@ -42,6 +44,7 @@ class MarkdownTextContent extends StatelessWidget {
Theme.of(context),
).copyWith(
textScaler: textScaler,
p: textColor != null ? Theme.of(context).textTheme.bodyMedium!.copyWith(color: textColor) : null,
blockquote: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),

View File

@ -31,34 +31,37 @@ class _AppRailNavigationState extends State<AppRailNavigation> {
builder: (context, _) {
final destinations = nav.destinations.where((ele) => ele.isPinned).toList();
return NavigationRail(
selectedIndex:
nav.currentIndex != null && nav.currentIndex! < nav.pinnedDestinationCount ? nav.currentIndex : null,
destinations: [
...destinations.where((ele) => ele.isPinned).map((ele) {
return NavigationRailDestination(
icon: ele.icon,
label: Text(ele.label).tr(),
);
}),
],
trailing: Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: StyledWidget(
IconButton(
icon: const Icon(Symbols.menu),
onPressed: () {
Scaffold.of(context).openDrawer();
},
),
).padding(bottom: 16),
return SizedBox(
width: 80,
child: NavigationRail(
selectedIndex:
nav.currentIndex != null && nav.currentIndex! < nav.pinnedDestinationCount ? nav.currentIndex : null,
destinations: [
...destinations.where((ele) => ele.isPinned).map((ele) {
return NavigationRailDestination(
icon: ele.icon,
label: Text(ele.label).tr(),
);
}),
],
trailing: Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: StyledWidget(
IconButton(
icon: const Icon(Symbols.menu),
onPressed: () {
Scaffold.of(context).openDrawer();
},
),
).padding(bottom: 16),
),
),
onDestinationSelected: (idx) {
nav.setIndex(idx);
GoRouter.of(context).goNamed(destinations[idx].screen);
},
),
onDestinationSelected: (idx) {
nav.setIndex(idx);
GoRouter.of(context).goNamed(destinations[idx].screen);
},
);
},
);

View File

@ -140,6 +140,7 @@ class AppRootScaffold extends StatelessWidget {
);
final safeTop = MediaQuery.of(context).padding.top;
final safeBottom = MediaQuery.of(context).padding.bottom;
return Scaffold(
key: globalRootScaffoldKey,
@ -191,7 +192,10 @@ class AppRootScaffold extends StatelessWidget {
],
),
Positioned(top: safeTop > 0 ? safeTop : 16, right: 8, child: NotifyIndicator()),
Positioned(top: safeTop > 0 ? safeTop : 16, left: 8, child: ConnectionIndicator()),
if (ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE))
Positioned(bottom: safeBottom > 0 ? safeBottom : 16, left: 0, right: 0, child: ConnectionIndicator())
else
Positioned(top: safeTop > 0 ? safeTop : 16, left: 0, right: 0, child: ConnectionIndicator()),
],
),
drawer: !isExpandedDrawer ? AppNavigationDrawer() : null,

View File

@ -1,60 +1,181 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/notification.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/screens/notification.dart';
import 'package:surface/types/notification.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/universal_image.dart';
class NotifyIndicator extends StatelessWidget {
import 'markdown_content.dart';
class NotifyIndicator extends StatefulWidget {
const NotifyIndicator({super.key});
@override
State<NotifyIndicator> createState() => _NotifyIndicatorState();
}
class _NotifyIndicatorState extends State<NotifyIndicator> with SingleTickerProviderStateMixin {
late final AnimationController _animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
void _markOneAsRead(SnNotification notification) async {
final ua = context.read<UserProvider>();
if (!ua.isAuthorized) return;
if (notification.id == 0) return;
if (notification.readAt != null) return;
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.put('/cgi/id/notifications/read/${notification.id}');
if (!mounted) return;
context.showSnackbar(
'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>();
final nty = context.watch<NotificationProvider>();
final show = nty.notifications.isNotEmpty && ua.isAuthorized;
final isMobile = ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE);
final show = nty.showingCount > 0 && ua.isAuthorized;
if (show) {
_animationController.animateTo(1);
} else {
_animationController.animateTo(0);
}
return ListenableBuilder(
listenable: nty,
builder: (context, _) {
final current = nty.notifications.lastOrNull;
return IgnorePointer(
ignoring: !show,
child: GestureDetector(
child: Material(
elevation: 2,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
color: Theme.of(context).colorScheme.secondaryContainer,
child: ua.isAuthorized
? Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
nty.notifications.lastOrNull?.title ??
'notificationUnreadCount'.plural(nty.notifications.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (nty.notifications.lastOrNull?.body != null)
Text(
nty.notifications.lastOrNull!.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).padding(left: 4),
const Gap(8),
const Icon(Symbols.notifications_unread, size: 18),
],
).padding(horizontal: 8, vertical: 4)
: const SizedBox.shrink(),
).opacity(show ? 1 : 0, animate: true).animate(
const Duration(milliseconds: 300),
Curves.easeInOut,
child: Animate(
autoPlay: false,
controller: _animationController,
effects: [
SlideEffect(
begin: isMobile ? Offset(0, -1) : Offset(1, 0),
end: Offset(0, 0),
duration: Duration(milliseconds: 300),
curve: Curves.fastEaseInToSlowEaseOut,
),
FadeEffect(
begin: 0.0,
end: 1.0,
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
],
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
width: double.infinity,
constraints: BoxConstraints(
maxWidth: isMobile ? MediaQuery.of(context).size.width - 16 : 360,
),
child: Material(
elevation: 2,
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainer,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (current?.metadata['avatar'] != null)
CircleAvatar(
radius: 14,
backgroundImage: UniversalImage.provider(
sn.getAttachmentUrl(current!.metadata['avatar']),
),
)
else
Icon(kNotificationTopicIcons[current?.topic] ?? Symbols.notifications),
const Gap(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
current?.title ?? 'Notification',
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
if (current?.subtitle?.isNotEmpty ?? false)
Text(
current!.subtitle!,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
MarkdownTextContent(
content: current?.body ?? '',
isAutoWarp: true,
),
],
),
),
const Gap(16),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(DateFormat('HH:mm').format(current?.createdAt.toLocal() ?? DateTime.now()))
.fontSize(12)
.padding(right: 2),
const Gap(6),
if (current?.metadata['image'] != null)
SizedBox(
width: 40,
height: 40,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(current?.metadata['image']),
fit: BoxFit.cover,
),
),
),
],
),
],
).padding(horizontal: 16, vertical: 12),
),
),
),
onTap: () {
nty.clear();
if (current != null) {
_markOneAsRead(current);
}
},
),
);

View File

@ -1,10 +1,12 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:material_symbols_icons/symbols.dart';
@ -34,6 +36,7 @@ import 'package:surface/widgets/post/post_meta_editor.dart';
import 'package:surface/widgets/post/post_reaction.dart';
import 'package:surface/widgets/post/publisher_popover.dart';
import 'package:surface/widgets/universal_image.dart';
import 'package:xml/xml.dart';
class PostItem extends StatelessWidget {
final SnPost data;
@ -186,6 +189,7 @@ class PostItem extends StatelessWidget {
),
),
Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8),
_PostFeaturedComment(data: data, maxWidth: maxWidth).padding(horizontal: 12),
_PostBottomAction(
data: data,
showComments: showComments,
@ -267,6 +271,7 @@ class PostItem extends StatelessWidget {
LinkPreviewWidget(
text: data.body['content'],
).padding(horizontal: 4),
_PostFeaturedComment(data: data, maxWidth: maxWidth).padding(horizontal: 12),
Container(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
child: Column(
@ -817,6 +822,22 @@ class _PostContentHeader extends StatelessWidget {
},
),
const PopupMenuDivider(),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.book_4_spark),
const Gap(16),
Text('postGetInsight').tr(),
],
),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => _PostGetInsightSheet(postId: data.id),
);
},
),
const PopupMenuDivider(),
PopupMenuItem(
onTap: onShare,
child: Row(
@ -1106,6 +1127,95 @@ class _PostTruncatedHint extends StatelessWidget {
}
}
class _PostFeaturedComment extends StatefulWidget {
final SnPost data;
final double? maxWidth;
const _PostFeaturedComment({required this.data, this.maxWidth});
@override
State<_PostFeaturedComment> createState() => _PostFeaturedCommentState();
}
class _PostFeaturedCommentState extends State<_PostFeaturedComment> {
SnPost? _featuredComment;
Future<void> _fetchComments() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/posts/${widget.data.id}/replies/featured', queryParameters: {
'take': 1,
});
setState(() => _featuredComment = SnPost.fromJson(resp.data[0]));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
@override
void initState() {
super.initState();
if (widget.data.metric.replyCount > 0) {
_fetchComments();
}
}
@override
Widget build(BuildContext context) {
if (widget.data.metric.replyCount == 0) return const SizedBox.shrink();
if (_featuredComment == null) return const SizedBox.shrink();
return AnimateWidgetExtensions(Container(
constraints: BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity),
margin: const EdgeInsets.only(top: 8),
width: double.infinity,
child: Material(
borderRadius: const BorderRadius.all(Radius.circular(8)),
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
builder: (context) => PostCommentListPopup(
postId: widget.data.id,
commentCount: widget.data.metric.replyCount,
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('postFeaturedComment', style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 16)).tr(),
const Gap(4),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
radius: 12,
backgroundImage: UniversalImage.provider(
_featuredComment!.publisher.avatar,
),
),
const Gap(8),
Expanded(
child: MarkdownTextContent(
content: _featuredComment!.body['content'],
isAutoWarp: true,
),
)
],
),
],
).padding(horizontal: 16, vertical: 8),
),
),
)).animate().fadeIn(duration: 300.ms, curve: Curves.easeInOut);
}
}
class _PostAbuseReportDialog extends StatefulWidget {
final SnPost data;
@ -1181,3 +1291,96 @@ class _PostAbuseReportDialogState extends State<_PostAbuseReportDialog> {
);
}
}
class _PostGetInsightSheet extends StatefulWidget {
final int postId;
const _PostGetInsightSheet({required this.postId});
@override
State<_PostGetInsightSheet> createState() => _PostGetInsightSheetState();
}
class _PostGetInsightSheetState extends State<_PostGetInsightSheet> {
String? _response;
String? _thinkingProcess;
Future<void> _fetchResponse() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/posts/${widget.postId}/insight',
options: Options(
sendTimeout: const Duration(minutes: 10),
receiveTimeout: const Duration(minutes: 10),
));
final out = resp.data['response'] as String;
final document = XmlDocument.parse(out);
_thinkingProcess = document.getElement('think')?.innerText.trim();
RegExp cleanThinkingRegExp = RegExp(r'<think>[\s\S]*?</think>');
setState(() => _response = out.replaceAll(cleanThinkingRegExp, '').trim());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
@override
void initState() {
super.initState();
_fetchResponse();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.book_4_spark, size: 24),
const Gap(16),
Text('postGetInsightTitle', style: Theme.of(context).textTheme.titleLarge).tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
const Gap(4),
Text('postGetInsightDescription', style: Theme.of(context).textTheme.bodySmall).tr().padding(horizontal: 20),
const Gap(4),
if (_response == null)
Expanded(
child: Center(
child: CircularProgressIndicator(),
),
)
else
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
if (_thinkingProcess != null && _thinkingProcess!.isNotEmpty)
ExpansionTile(
leading: const Icon(Symbols.info),
title: Text('aiThinkingProcess'.tr()),
tilePadding: const EdgeInsets.symmetric(horizontal: 20),
collapsedBackgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
minTileHeight: 32,
children: [
SelectableText(
_thinkingProcess!,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontStyle: FontStyle.italic),
).padding(horizontal: 20, vertical: 8),
],
).padding(vertical: 8),
SelectionArea(
child: MarkdownTextContent(
content: _response!,
),
).padding(horizontal: 20, top: 8),
],
),
),
),
],
);
}
}

View File

@ -61,7 +61,7 @@ class _PostReactionPopupState extends State<PostReactionPopup> {
);
}
}
HapticFeedback.mediumImpact();
HapticFeedback.heavyImpact();
} catch (err) {
// ignore: use_build_context_synchronously
if (context.mounted) context.showErrorDialog(err);

View File

@ -8,6 +8,7 @@ import Foundation
import bitsdojo_window_macos
import connectivity_plus
import device_info_plus
import file_picker
import file_saver
import file_selector_macos
import firebase_analytics
@ -36,6 +37,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
BitsdojoWindowPlugin.register(with: registry.registrar(forPlugin: "BitsdojoWindowPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin"))

View File

@ -8,6 +8,8 @@ PODS:
- FlutterMacOS
- device_info_plus (0.0.1):
- FlutterMacOS
- file_picker (0.0.1):
- FlutterMacOS
- file_saver (0.0.1):
- FlutterMacOS
- file_selector_macos (0.0.1):
@ -185,6 +187,7 @@ DEPENDENCIES:
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin`)
- croppy (from `Flutter/ephemeral/.symlinks/plugins/croppy/macos`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- file_saver (from `Flutter/ephemeral/.symlinks/plugins/file_saver/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- firebase_analytics (from `Flutter/ephemeral/.symlinks/plugins/firebase_analytics/macos`)
@ -237,6 +240,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/croppy/macos
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
file_saver:
:path: Flutter/ephemeral/.symlinks/plugins/file_saver/macos
file_selector_macos:
@ -293,6 +298,7 @@ SPEC CHECKSUMS:
connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695
croppy: 25a638bd7d05411d8c697f481568f261037694fc
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af
file_saver: 44e6fbf666677faf097302460e214e977fdd977b
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
Firebase: 374a441a91ead896215703a674d58cdb3e9d772b

View File

@ -362,10 +362,10 @@ packages:
dependency: "direct main"
description:
name: device_info_plus
sha256: b37d37c2f912ad4e8ec694187de87d05de2a3cb82b465ff1f65f65a2d05de544
sha256: e3fc9a65820fef83035af8ee8c09004a719d5d1d54e6de978fcb0d84bbeb241a
url: "https://pub.dev"
source: hosted
version: "11.2.1"
version: "11.2.2"
device_info_plus_platform_interface:
dependency: transitive
description:
@ -378,10 +378,10 @@ packages:
dependency: "direct main"
description:
name: dio
sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
url: "https://pub.dev"
source: hosted
version: "5.7.0"
version: "5.8.0+1"
dio_smart_retry:
dependency: "direct main"
description:
@ -394,10 +394,10 @@ packages:
dependency: transitive
description:
name: dio_web_adapter
sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8"
sha256: e485c7a39ff2b384fa1d7e09b4e25f755804de8384358049124830b04fc4f93a
url: "https://pub.dev"
source: hosted
version: "2.0.0"
version: "2.1.0"
dismissible_page:
dependency: "direct main"
description:
@ -490,10 +490,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: c904b4ab56d53385563c7c39d8e9fa9af086f91495dfc48717ad84a42c3cf204
sha256: c9943dd7d702ab4199d199bc151a2d79c86db031a02ad84566dab58c494d2adc
url: "https://pub.dev"
source: hosted
version: "8.1.7"
version: "8.3.1"
file_saver:
dependency: "direct main"
description:
@ -886,10 +886,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: daf3ff5570f55396b2d2c9bf8136d7db3a8acf208ac0cef92a3ae2beb9a81550
sha256: "9b736a9fa879d8ad6df7932cbdcc58237c173ab004ef90d8377923d7ad731eaa"
url: "https://pub.dev"
source: hosted
version: "14.7.1"
version: "14.7.2"
google_fonts:
dependency: "direct main"
description:
@ -1334,10 +1334,10 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
sha256: "739e0a5c3c4055152520fa321d0645ee98e932718b4c8efeeb51451968fe0790"
sha256: b15fad91c4d3d1f2b48c053dd41cb82da007c27407dc9ab5f9aa59881d0e39d4
url: "https://pub.dev"
source: hosted
version: "8.1.3"
version: "8.1.4"
package_info_plus_platform_interface:
dependency: transitive
description:
@ -1710,10 +1710,10 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
sha256: c59819dacc6669a1165d54d2735a9543f136f9b3cec94ca65cea6ab8dffc422e
sha256: "688ee90fbfb6989c980254a56cb26ebe9bb30a3a2dff439a78894211f73de67a"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
version: "2.5.1"
shared_preferences_android:
dependency: transitive
description:
@ -2059,10 +2059,10 @@ packages:
dependency: transitive
description:
name: vector_graphics
sha256: "27d5fefe86fb9aace4a9f8375b56b3c292b64d8c04510df230f849850d912cb7"
sha256: "7ed22c21d7fdcc88dd6ba7860384af438cd220b251ad65dfc142ab722fabef61"
url: "https://pub.dev"
source: hosted
version: "1.1.15"
version: "1.1.16"
vector_graphics_codec:
dependency: transitive
description:
@ -2216,7 +2216,7 @@ packages:
source: hosted
version: "1.1.0"
xml:
dependency: transitive
dependency: "direct main"
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2.2.2+58
version: 2.2.2+61
environment:
sdk: ^3.5.4
@ -117,6 +117,7 @@ dependencies:
cached_network_image: ^3.4.1
flutter_inappwebview: ^6.1.5
html: ^0.15.5
xml: ^6.5.0
dev_dependencies:
flutter_test: