Compare commits

...

28 Commits

Author SHA1 Message Date
9cc577adbe Programs, members
🐛 Fix web assets redirecting issue
2025-03-23 22:34:58 +08:00
dd196b7754 Golden points 2025-03-23 18:23:18 +08:00
16c07c2133 🐛 Fix deps 2025-03-23 17:01:21 +08:00
6bcb658d44 🐛 Fix platform specific captcha solution cause build failed. 2025-03-23 16:47:06 +08:00
9311bfc3b5 ⬆️ Upgrade deps & replace to own translation api 2025-03-23 16:26:41 +08:00
8dd6435a30 🐛 Fix some issues on Android and Web 2025-03-23 16:24:53 +08:00
21a1d4a2ad 🐛 Fix unable select answer 2025-03-23 00:01:48 +08:00
603875b1af 🐛 Fix styling issue 2025-03-22 23:07:13 +08:00
4209a13c84 🐛 Fix no nav to use 2025-03-22 22:51:50 +08:00
55b79bfd8f 🐛 Finish bug fixes 2025-03-22 21:50:01 +08:00
6e6c3f42f6 🚀 Launch 2.4.2+84 2025-03-22 20:28:53 +08:00
dc38b46b2c Support captcha 2025-03-22 20:24:05 +08:00
b4990308e9 ♻️ Refactored nav completely 2025-03-22 18:39:01 +08:00
237abe564d ♻️ Refactored drawer nav 2025-03-22 18:14:36 +08:00
71b41d470a Splash screen loading 2025-03-22 16:36:10 +08:00
7052b5b635 Join channel hint
🗃️ Realm local db
2025-03-22 14:12:46 +08:00
f356e08f79 💄 New navigation draft (skip ci) 2025-03-22 12:48:55 +08:00
152872db65 💄 Show nick instead of name in typing indicator 2025-03-22 00:16:59 +08:00
dfe117d04f Auth preference screen 2025-03-22 00:14:55 +08:00
caf63f0cbe Notification preferences 2025-03-21 23:59:42 +08:00
b8f5cc82f9 Add attachments from file 2025-03-20 23:20:24 +08:00
360bc50f21 🐛 Fix linux G_APPLICATION_FLAGS_NONE api deprecated 2025-03-20 22:52:32 +08:00
2de93a0486 🐛 Close #15 with vide coding (not tested) 2025-03-20 22:45:53 +08:00
02227852f8 🚀 Launch 2.4.2+83 for some platforms 2025-03-19 00:48:36 +08:00
ad16de595b 🐛 Fix menubar missing hide 2025-03-19 00:30:58 +08:00
9f8c8923d9 🐛 Bug fixes in posts 2025-03-19 00:29:29 +08:00
060bfa4887 🐛 Fix explore unmixed feed pagination issue 2025-03-19 00:23:57 +08:00
e68ada2d04 💄 Optimize post comments 2025-03-19 00:21:54 +08:00
74 changed files with 6523 additions and 752 deletions

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

View File

@ -130,7 +130,7 @@
"accountPublishersSubtitle": "Manage your publish identities.", "accountPublishersSubtitle": "Manage your publish identities.",
"accountSettings": "Account Settings", "accountSettings": "Account Settings",
"accountSettingsSubtitle": "Manage your account and make it yours.", "accountSettingsSubtitle": "Manage your account and make it yours.",
"accountProfileEdit": "Edit your profile", "accountProfileEdit": "Edit Profile",
"accountProfileEditSubtitle": "Make your Solarpass account more looks like you.", "accountProfileEditSubtitle": "Make your Solarpass account more looks like you.",
"accountWallet": "Wallet", "accountWallet": "Wallet",
"accountWalletSubtitle": "View your balance and transactions.", "accountWalletSubtitle": "View your balance and transactions.",
@ -338,6 +338,7 @@
"fieldAttachmentRandomId": "Random ID", "fieldAttachmentRandomId": "Random ID",
"fieldAttachmentAlt": "Alternative text", "fieldAttachmentAlt": "Alternative text",
"addAttachmentFromAlbum": "Add from album", "addAttachmentFromAlbum": "Add from album",
"addAttachmentFromFiles": "Add from files",
"addAttachmentFromClipboard": "Paste file", "addAttachmentFromClipboard": "Paste file",
"addAttachmentFromCameraPhoto": "Take photo", "addAttachmentFromCameraPhoto": "Take photo",
"addAttachmentFromCameraVideo": "Take video", "addAttachmentFromCameraVideo": "Take video",
@ -846,5 +847,73 @@
"translating": "Translating…", "translating": "Translating…",
"translated": "Translated", "translated": "Translated",
"settingsAutoTranslate": "Auto Translate", "settingsAutoTranslate": "Auto Translate",
"settingsAutoTranslateDescription": "Automatically translate text when viewing posts and messages." "settingsAutoTranslateDescription": "Automatically translate text when viewing posts and messages.",
"trayMenuHide": "Hide",
"accountSettingsNotify": "Notify Settings",
"accountSettingsNotifyDescription": "Adjust the types of notifications you receive.",
"accountSettingsSecurity": "Security Settings",
"accountSettingsSecurityDescription": "Adjust your account security settings.",
"save": "Save",
"notificationTopicPostFeedback": "Post Feedback",
"notificationTopicPostReply": "Post Replies",
"notificationTopicPostSubscription": "Post Subscriptions",
"notificationTopicMessaging": "New Messages",
"notificationTopicMessagingCall": "Incoming Calls",
"notificationTopicGeneral": "General",
"authMaximumAuthSteps": "Maximum Authenticate Steps",
"authMaximumAuthStepsDescription": {
"one": "Maximum ask for {} step authenticate",
"other": "Maximum ask for {} steps authenticate"
},
"authAlwaysRisky": "Always Risky",
"authAlwaysRiskyDescription": "Always ask for the highest steps count of authentication when logging in.",
"chatUnjoined": "Unjoined Channel",
"chatUnjoinedDescription": "You haven't joined this channel, so you can't send messages either view messages in it.",
"chatUnjoinedPublicDescription": "Fortunately, this is a public channel, so you can join it as you want.",
"chatJoin": "Join the Channel",
"appInitStarting": "Starting",
"appInitNetwork": "Initializing Network",
"appInitUserdata": "Initializing User Data",
"appInitWebsocket": "Establishing Solar Link",
"appInitNotification": "Initializing Push Notifications",
"appInitKeyPair": "Initializing Key Pairs",
"appInitStickers": "Initializing Stickers",
"appInitUserDirectory": "Initializing User Directory",
"appInitRealm": "Initializing Realms",
"appInitChat": "Initializing Chat",
"appInitDone": "Completed",
"community": "Community",
"realmCommunity": "{}'s Community",
"postTotalCount": {
"one": "Total {} post",
"other": "Total {} posts"
},
"settingsHideBottomNav": "Hide Bottom Navigation",
"settingsHideBottomNavDescription": "Hide the bottom navigation bar, and show the navigation buttons in the drawer.",
"reCaptcha": "reCaptcha",
"friends": "Friends",
"friendsDescription": "Manage your friendships.",
"album": "Album",
"albumDescription": "View albums and manage attachments.",
"stickers": "Stickers",
"stickersDescription": "View sticker packs and manage stickers.",
"navBottomUnauthorizedCaption": "Or create an account",
"walletCurrencyGoldenShort": "GDP",
"walletCurrencyGolden": {
"one": "{} Golden Point",
"other": "{} Golden Points"
},
"walletTransactionTypeNormal": "Source Point",
"walletTransactionTypeGolden": "Golden Point",
"accountProgram": "Programs",
"accountProgramDescription": "Explore the available member programs.",
"accountProgramJoin": "Join Program",
"accountProgramJoinRequirements": "Requirements",
"accountProgramJoinPricing": "Pricing",
"accountProgramJoinPricingHint": "Billed every (30 days) month.",
"accountProgramLeaveHint": "After leaving the program, the source points will not be refunded.",
"accountProgramJoined": "Joined Program.",
"accountProgramAlreadyJoined": "Joined",
"accountProgramLeft": "Left Program.",
"leave": "Leave"
} }

View File

@ -336,6 +336,7 @@
"fieldAttachmentRandomId": "访问 ID", "fieldAttachmentRandomId": "访问 ID",
"fieldAttachmentAlt": "概述文字", "fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "从相册中添加附件", "addAttachmentFromAlbum": "从相册中添加附件",
"addAttachmentFromFiles": "从文件中添加附件",
"addAttachmentFromClipboard": "粘贴附件", "addAttachmentFromClipboard": "粘贴附件",
"addAttachmentFromCameraPhoto": "拍摄照片", "addAttachmentFromCameraPhoto": "拍摄照片",
"addAttachmentFromCameraVideo": "拍摄视频", "addAttachmentFromCameraVideo": "拍摄视频",
@ -844,5 +845,73 @@
"translating": "正在翻译……", "translating": "正在翻译……",
"translated": "已翻译", "translated": "已翻译",
"settingsAutoTranslate": "自动翻译", "settingsAutoTranslate": "自动翻译",
"settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。" "settingsAutoTranslateDescription": "在查看帖子、消息时自动翻译文本。",
"trayMenuHide": "隐藏",
"accountSettingsNotify": "通知设置",
"accountSettingsNotifyDescription": "调整你所收到的通知种类。",
"accountSettingsSecurity": "安全设置",
"accountSettingsSecurityDescription": "调整你的帐户安全设置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子数据反馈",
"notificationTopicPostReply": "帖子回复",
"notificationTopicPostSubscription": "帖子订阅",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通话",
"notificationTopicGeneral": "杂项",
"authMaximumAuthSteps": "最大验证步骤",
"authMaximumAuthStepsDescription": {
"one": "登入时最多要求 {} 步验证",
"other": "登入时最多要求 {} 步验证"
},
"authAlwaysRisky": "总是风险",
"authAlwaysRiskyDescription": "在登入时始终按最高标准要求验证。",
"chatUnjoined": "未加入频道",
"chatUnjoinedDescription": "你没有加入这个频道,所以你也无法发送消息或者查看这个频道中的消息。",
"chatUnjoinedPublicDescription": "但幸运的是,这是一个公开频道,所以你可以主动加入。",
"chatJoin": "加入频道",
"appInitStarting": "启动中",
"appInitNetwork": "正在初始化网络",
"appInitUserdata": "正在初始化用户数据",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密钥对",
"appInitStickers": "正在初始化贴图包",
"appInitUserDirectory": "正在初始化用户目录",
"appInitRealm": "正在初始化领域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社区",
"realmCommunity": "{}的社区",
"postTotalCount": {
"zero": "没有帖子",
"one": "共 {} 条帖子"
},
"settingsHideBottomNav": "隐藏底部导航栏",
"settingsHideBottomNavDescription": "隐藏底部导航栏,在侧边栏抽屉显示导航按钮。",
"reCaptcha": "人机验证",
"friends": "好友",
"friendsDescription": "管理好友关系。",
"album": "相册",
"albumDescription": "查看相册与管理上传附件。",
"stickers": "贴图",
"stickersDescription": "查看贴图包与管理贴图。",
"navBottomUnauthorizedCaption": "或者注册一个账号",
"walletCurrencyGoldenShort": "金点",
"walletCurrencyGolden": {
"one": "{} 金点",
"other": "{} 金点"
},
"walletTransactionTypeNormal": "源点",
"walletTransactionTypeGolden": "金点",
"accountProgram": "计划",
"accountProgramDescription": "了解可用的成员计划。",
"accountProgramJoin": "加入计划",
"accountProgramJoinRequirements": "要求",
"accountProgramJoinPricing": "价格",
"accountProgramJoinPricingHint": "按月30 天)收费",
"accountProgramLeaveHint": "离开计划后,之前花费的源点不会退款。",
"accountProgramJoined": "已加入计划。",
"accountProgramLeft": "已离开计划。",
"accountProgramAlreadyJoined": "已加入",
"leave": "离开"
} }

View File

@ -336,6 +336,7 @@
"fieldAttachmentRandomId": "訪問 ID", "fieldAttachmentRandomId": "訪問 ID",
"fieldAttachmentAlt": "概述文字", "fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "從相冊中添加附件", "addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromFiles": "從文件中添加附件",
"addAttachmentFromClipboard": "粘貼附件", "addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片", "addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻", "addAttachmentFromCameraVideo": "拍攝視頻",
@ -844,5 +845,55 @@
"translating": "正在翻譯……", "translating": "正在翻譯……",
"translated": "已翻譯", "translated": "已翻譯",
"settingsAutoTranslate": "自動翻譯", "settingsAutoTranslate": "自動翻譯",
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。" "settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
"trayMenuHide": "隱藏",
"accountSettingsNotify": "通知設置",
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
"accountSettingsSecurity": "安全設置",
"accountSettingsSecurityDescription": "調整你的帳户安全設置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子數據反饋",
"notificationTopicPostReply": "帖子回覆",
"notificationTopicPostSubscription": "帖子訂閲",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通話",
"notificationTopicGeneral": "雜項",
"authMaximumAuthSteps": "最大驗證步驟",
"authMaximumAuthStepsDescription": {
"one": "登入時最多要求 {} 步驗證",
"other": "登入時最多要求 {} 步驗證"
},
"authAlwaysRisky": "總是風險",
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
"chatUnjoined": "未加入頻道",
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
"chatJoin": "加入頻道",
"appInitStarting": "啓動中",
"appInitNetwork": "正在初始化網絡",
"appInitUserdata": "正在初始化用户數據",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密鑰對",
"appInitStickers": "正在初始化貼圖包",
"appInitUserDirectory": "正在初始化用户目錄",
"appInitRealm": "正在初始化領域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社區",
"realmCommunity": "{}的社區",
"postTotalCount": {
"zero": "沒有帖子",
"one": "共 {} 條帖子"
},
"settingsHideBottomNav": "隱藏底部導航欄",
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
"reCaptcha": "人機驗證",
"friends": "好友",
"friendsDescription": "管理好友關係。",
"album": "相冊",
"albumDescription": "查看相冊與管理上傳附件。",
"stickers": "貼圖",
"stickersDescription": "查看貼圖包與管理貼圖。",
"navBottomUnauthorizedCaption": "或者註冊一個賬號"
} }

View File

@ -336,6 +336,7 @@
"fieldAttachmentRandomId": "訪問 ID", "fieldAttachmentRandomId": "訪問 ID",
"fieldAttachmentAlt": "概述文字", "fieldAttachmentAlt": "概述文字",
"addAttachmentFromAlbum": "從相冊中添加附件", "addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromFiles": "從文件中添加附件",
"addAttachmentFromClipboard": "粘貼附件", "addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片", "addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻", "addAttachmentFromCameraVideo": "拍攝視頻",
@ -844,5 +845,55 @@
"translating": "正在翻譯……", "translating": "正在翻譯……",
"translated": "已翻譯", "translated": "已翻譯",
"settingsAutoTranslate": "自動翻譯", "settingsAutoTranslate": "自動翻譯",
"settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。" "settingsAutoTranslateDescription": "在查看帖子、消息時自動翻譯文本。",
"trayMenuHide": "隱藏",
"accountSettingsNotify": "通知設置",
"accountSettingsNotifyDescription": "調整你所收到的通知種類。",
"accountSettingsSecurity": "安全設置",
"accountSettingsSecurityDescription": "調整你的帳戶安全設置。",
"save": "保存",
"notificationTopicPostFeedback": "帖子數據反饋",
"notificationTopicPostReply": "帖子回覆",
"notificationTopicPostSubscription": "帖子訂閱",
"notificationTopicMessaging": "消息",
"notificationTopicMessagingCall": "通話",
"notificationTopicGeneral": "雜項",
"authMaximumAuthSteps": "最大驗證步驟",
"authMaximumAuthStepsDescription": {
"one": "登入時最多要求 {} 步驗證",
"other": "登入時最多要求 {} 步驗證"
},
"authAlwaysRisky": "總是風險",
"authAlwaysRiskyDescription": "在登入時始終按最高標準要求驗證。",
"chatUnjoined": "未加入頻道",
"chatUnjoinedDescription": "你沒有加入這個頻道,所以你也無法發送消息或者查看這個頻道中的消息。",
"chatUnjoinedPublicDescription": "但幸運的是,這是一個公開頻道,所以你可以主動加入。",
"chatJoin": "加入頻道",
"appInitStarting": "啟動中",
"appInitNetwork": "正在初始化網絡",
"appInitUserdata": "正在初始化用戶數據",
"appInitWebsocket": "正在建立 Solar Link",
"appInitNotification": "正在初始化推送通知",
"appInitKeyPair": "正在初始化密鑰對",
"appInitStickers": "正在初始化貼圖包",
"appInitUserDirectory": "正在初始化用戶目錄",
"appInitRealm": "正在初始化領域信息",
"appInitChat": "正在初始化聊天",
"appInitDone": "完成",
"community": "社區",
"realmCommunity": "{}的社區",
"postTotalCount": {
"zero": "沒有帖子",
"one": "共 {} 條帖子"
},
"settingsHideBottomNav": "隱藏底部導航欄",
"settingsHideBottomNavDescription": "隱藏底部導航欄,在側邊欄抽屜顯示導航按鈕。",
"reCaptcha": "人機驗證",
"friends": "好友",
"friendsDescription": "管理好友關係。",
"album": "相冊",
"albumDescription": "查看相冊與管理上傳附件。",
"stickers": "貼圖",
"stickersDescription": "查看貼圖包與管理貼圖。",
"navBottomUnauthorizedCaption": "或者註冊一個賬號"
} }

File diff suppressed because one or more lines are too long

View File

@ -232,6 +232,8 @@ PODS:
- sqlite3/common - sqlite3/common
- sqlite3/fts5 (3.49.1): - sqlite3/fts5 (3.49.1):
- sqlite3/common - sqlite3/common
- sqlite3/math (3.49.1):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.1): - sqlite3/perf-threadsafe (3.49.1):
- sqlite3/common - sqlite3/common
- sqlite3/rtree (3.49.1): - sqlite3/rtree (3.49.1):
@ -242,6 +244,7 @@ PODS:
- sqlite3 (~> 3.49.1) - sqlite3 (~> 3.49.1)
- sqlite3/dbstatvtab - sqlite3/dbstatvtab
- sqlite3/fts5 - sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe - sqlite3/perf-threadsafe
- sqlite3/rtree - sqlite3/rtree
- SwiftyGif (5.4.5) - SwiftyGif (5.4.5)
@ -457,7 +460,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db sqlite3_flutter_libs: 487032b9008b28de37c72a3aa66849ef3745f3e6
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -12,6 +12,7 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@ -19,6 +20,7 @@ import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart'; import 'package:responsive_framework/responsive_framework.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/firebase_options.dart'; import 'package:surface/firebase_options.dart';
import 'package:surface/logger.dart'; import 'package:surface/logger.dart';
import 'package:surface/providers/channel.dart'; import 'package:surface/providers/channel.dart';
@ -46,6 +48,7 @@ import 'package:surface/router.dart';
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy; import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/menu_bar.dart'; import 'package:surface/widgets/menu_bar.dart';
import 'package:surface/widgets/version_label.dart';
import 'package:tray_manager/tray_manager.dart'; import 'package:tray_manager/tray_manager.dart';
import 'package:version/version.dart'; import 'package:version/version.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
@ -86,19 +89,14 @@ void main() async {
await EasyLocalization.ensureInitialized(); await EasyLocalization.ensureInitialized();
if (!kIsWeb && !Platform.isLinux) { if (!kIsWeb && !Platform.isLinux) {
await Firebase.initializeApp( await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
options: DefaultFirebaseOptions.currentPlatform,
);
} }
GoRouter.optionURLReflectsImperativeAPIs = true; GoRouter.optionURLReflectsImperativeAPIs = true;
usePathUrlStrategy(); usePathUrlStrategy();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
Workmanager().initialize( Workmanager().initialize(appBackgroundDispatcher, isInDebugMode: kDebugMode);
appBackgroundDispatcher,
isInDebugMode: kDebugMode,
);
if (Platform.isAndroid) { if (Platform.isAndroid) {
Workmanager().registerPeriodicTask( Workmanager().registerPeriodicTask(
"widget-update-random-post", "widget-update-random-post",
@ -111,8 +109,7 @@ void main() async {
} }
if (!kIsWeb && Platform.isAndroid) { if (!kIsWeb && Platform.isAndroid) {
final ImagePickerPlatform imagePickerImplementation = final ImagePickerPlatform imagePickerImplementation = ImagePickerPlatform.instance;
ImagePickerPlatform.instance;
if (imagePickerImplementation is ImagePickerAndroid) { if (imagePickerImplementation is ImagePickerAndroid) {
imagePickerImplementation.useAndroidPhotoPicker = true; imagePickerImplementation.useAndroidPhotoPicker = true;
} }
@ -129,12 +126,7 @@ class SolianApp extends StatelessWidget {
return ResponsiveBreakpoints.builder( return ResponsiveBreakpoints.builder(
child: EasyLocalization( child: EasyLocalization(
path: 'assets/translations', path: 'assets/translations',
supportedLocales: [ supportedLocales: [Locale('en', 'US'), Locale('zh', 'CN'), Locale('zh', 'TW'), Locale('zh', 'HK')],
Locale('en', 'US'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
Locale('zh', 'HK'),
],
fallbackLocale: Locale('en', 'US'), fallbackLocale: Locale('en', 'US'),
useFallbackTranslations: true, useFallbackTranslations: true,
assetLoader: JsonAssetLoader(), assetLoader: JsonAssetLoader(),
@ -157,7 +149,7 @@ class SolianApp extends StatelessWidget {
Provider(create: (ctx) => SnNetworkProvider(ctx)), Provider(create: (ctx) => SnNetworkProvider(ctx)),
Provider(create: (ctx) => UserDirectoryProvider(ctx)), Provider(create: (ctx) => UserDirectoryProvider(ctx)),
Provider(create: (ctx) => SnAttachmentProvider(ctx)), Provider(create: (ctx) => SnAttachmentProvider(ctx)),
Provider(create: (ctx) => SnRealmProvider(ctx)), ChangeNotifierProvider(create: (ctx) => SnRealmProvider(ctx)),
Provider(create: (ctx) => SnPostContentProvider(ctx)), Provider(create: (ctx) => SnPostContentProvider(ctx)),
Provider(create: (ctx) => SnRelationshipProvider(ctx)), Provider(create: (ctx) => SnRelationshipProvider(ctx)),
Provider(create: (ctx) => SnLinkPreviewProvider(ctx)), Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
@ -209,10 +201,7 @@ class _AppDelegate extends StatelessWidget {
], ],
routerConfig: appRouter, routerConfig: appRouter,
builder: (context, child) { builder: (context, child) {
return _AppSplashScreen( return _AppSplashScreen(key: const Key('global-splash-screen'), child: child!);
key: const Key('global-splash-screen'),
child: child!,
);
}, },
); );
} }
@ -228,13 +217,15 @@ class _AppSplashScreen extends StatefulWidget {
} }
class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener { class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
bool _isBusy = false;
String _phaseText = 'appInitStarting';
void _tryRequestRating() async { void _tryRequestRating() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('first_boot_time')) { if (prefs.containsKey('first_boot_time')) {
final rawTime = prefs.getString('first_boot_time'); final rawTime = prefs.getString('first_boot_time');
final time = DateTime.tryParse(rawTime ?? ''); final time = DateTime.tryParse(rawTime ?? '');
if (time != null && if (time != null && time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
final inAppReview = InAppReview.instance; final inAppReview = InAppReview.instance;
if (prefs.getBool('rating_requested') == true) return; if (prefs.getBool('rating_requested') == true) return;
if (await inAppReview.isAvailable()) { if (await inAppReview.isAvailable()) {
@ -255,30 +246,17 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
final info = await PackageInfo.fromPlatform(); final info = await PackageInfo.fromPlatform();
final localVersionString = '${info.version}+${info.buildNumber}'; final localVersionString = '${info.version}+${info.buildNumber}';
final resp = await Dio( final resp = await Dio(
BaseOptions( BaseOptions(sendTimeout: const Duration(seconds: 60), receiveTimeout: const Duration(seconds: 60)),
sendTimeout: const Duration(seconds: 60), ).get('https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest');
receiveTimeout: const Duration(seconds: 60),
),
).get(
'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest',
);
final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0'; final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0';
final remoteVersion = Version.parse(remoteVersionString.split('+').first); final remoteVersion = Version.parse(remoteVersionString.split('+').first);
final localVersion = Version.parse(localVersionString.split('+').first); final localVersion = Version.parse(localVersionString.split('+').first);
final remoteBuildNumber = final remoteBuildNumber = int.tryParse(remoteVersionString.split('+').last) ?? 0;
int.tryParse(remoteVersionString.split('+').last) ?? 0; final localBuildNumber = int.tryParse(localVersionString.split('+').last) ?? 0;
final localBuildNumber = logging.info("[Update] Local: $localVersionString, Remote: $remoteVersionString");
int.tryParse(localVersionString.split('+').last) ?? 0; if ((remoteVersion > localVersion || remoteBuildNumber > localBuildNumber) && mounted) {
logging.info(
"[Update] Local: $localVersionString, Remote: $remoteVersionString");
if ((remoteVersion > localVersion ||
remoteBuildNumber > localBuildNumber) &&
mounted) {
final config = context.read<ConfigProvider>(); final config = context.read<ConfigProvider>();
config.setUpdate( config.setUpdate(remoteVersionString, resp.data?['body'] ?? 'No changelog');
remoteVersionString,
resp.data?['body'] ?? 'No changelog',
);
logging.info("[Update] Update available: $remoteVersionString"); logging.info("[Update] Update available: $remoteVersionString");
} }
} catch (e) { } catch (e) {
@ -287,6 +265,11 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
} }
} }
void _setPhaseText(String text) {
_phaseText = 'appInit${text.capitalize()}'.tr();
if (mounted) setState(() {});
}
Future<void> _initialize() async { Future<void> _initialize() async {
try { try {
final cfg = context.read<ConfigProvider>(); final cfg = context.read<ConfigProvider>();
@ -299,31 +282,51 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
// The Network initialization must be done after the HomeWidget initialization // The Network initialization must be done after the HomeWidget initialization
// The Network initialization will save the server url to the HomeWidget // The Network initialization will save the server url to the HomeWidget
// The Network initialization will also save initialize the Config, so it not need to be initialized again // The Network initialization will also save initialize the Config, so it not need to be initialized again
_setPhaseText('network');
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.initializeUserAgent(); await sn.initializeUserAgent();
await sn.setConfigWithNative(); await sn.setConfigWithNative();
if (!mounted) return; if (!mounted) return;
_setPhaseText('userdata');
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
await ua.initialize(); await ua.initialize();
if (!mounted) return; if (!mounted) return;
_setPhaseText('websocket');
final ws = context.read<WebSocketProvider>(); final ws = context.read<WebSocketProvider>();
await ws.tryConnect(); await ws.tryConnect();
if (!mounted) return; try {
final notify = context.read<NotificationProvider>(); if (!mounted) return;
notify.listen(); _setPhaseText('keyPair');
await notify.registerPushNotifications(); final kp = context.read<KeyPairProvider>();
if (!mounted) return; await kp.reloadActive();
final kp = context.read<KeyPairProvider>(); kp.listen();
await kp.reloadActive(); } catch (_) {}
kp.listen(); if (ua.isAuthorized) {
if (!mounted) return; if (!mounted) return;
final sticker = context.read<SnStickerProvider>(); _setPhaseText('notification');
await sticker.listSticker(); final notify = context.read<NotificationProvider>();
if (!mounted) return; notify.listen();
final ud = context.read<UserDirectoryProvider>(); try {
final userCacheSize = await ud.loadAccountCache(); await notify.registerPushNotifications();
logging.info('[Users] Loaded local user cache, size: $userCacheSize'); } catch (_) {}
logging.info('[Bootstrap] Everything initialized!'); if (!mounted) return;
_setPhaseText('stickers');
final sticker = context.read<SnStickerProvider>();
await sticker.listSticker();
if (!mounted) return;
_setPhaseText('userDirectory');
final ud = context.read<UserDirectoryProvider>();
await ud.loadAccountCache();
if (!mounted) return;
_setPhaseText('realm');
final rm = context.read<SnRealmProvider>();
await rm.refreshAvailableRealms();
if (!mounted) return;
_setPhaseText('chat');
final ct = context.read<ChatChannelProvider>();
await ct.refreshAvailableChannels();
_setPhaseText('done');
}
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
await context.showErrorDialog(err); await context.showErrorDialog(err);
@ -341,35 +344,19 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
final Menu _appTrayMenu = Menu( final Menu _appTrayMenu = Menu(
items: [ items: [
MenuItem( MenuItem(key: 'version_label', label: 'Solian', disabled: true),
key: 'version_label',
label: 'Solian',
disabled: true,
),
MenuItem.separator(), MenuItem.separator(),
MenuItem.checkbox( MenuItem.checkbox(checked: false, key: 'mute_notification', label: 'trayMenuMuteNotification'.tr()),
checked: false,
key: 'mute_notification',
label: 'trayMenuMuteNotification'.tr(),
),
MenuItem.separator(), MenuItem.separator(),
MenuItem( MenuItem(key: 'window_show', label: 'trayMenuShow'.tr()),
key: 'window_show', MenuItem(key: 'exit', label: 'trayMenuExit'.tr()),
label: 'trayMenuShow'.tr(),
),
MenuItem(
key: 'exit',
label: 'trayMenuExit'.tr(),
),
], ],
); );
Future<void> _trayInitialization() async { Future<void> _trayInitialization() async {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
final icon = Platform.isWindows final icon = Platform.isWindows ? 'assets/icon/tray-icon.ico' : 'assets/icon/tray-icon.png';
? 'assets/icon/tray-icon.ico'
: 'assets/icon/tray-icon.png';
final appVersion = await PackageInfo.fromPlatform(); final appVersion = await PackageInfo.fromPlatform();
trayManager.addListener(this); trayManager.addListener(this);
@ -387,10 +374,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
Future<void> _notifyInitialization() async { Future<void> _notifyInitialization() async {
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return; if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
await localNotifier.setup( await localNotifier.setup(appName: 'Solian', shortcutPolicy: ShortcutPolicy.requireCreate);
appName: 'Solian',
shortcutPolicy: ShortcutPolicy.requireCreate,
);
} }
AppLifecycleListener? _appLifecycleListener; AppLifecycleListener? _appLifecycleListener;
@ -399,10 +383,9 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
void initState() { void initState() {
super.initState(); super.initState();
_isBusy = true;
if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) { if (!kIsWeb && !(Platform.isIOS || Platform.isAndroid)) {
_appLifecycleListener = AppLifecycleListener( _appLifecycleListener = AppLifecycleListener(onExitRequested: _onExitRequested);
onExitRequested: _onExitRequested,
);
} }
_trayInitialization(); _trayInitialization();
@ -412,6 +395,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
_postInitialization(); _postInitialization();
_tryRequestRating(); _tryRequestRating();
_checkForUpdate(); _checkForUpdate();
setState(() => _isBusy = false);
}); });
} }
@ -501,7 +485,49 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
} }
}); });
return SizeChangedLayoutNotifier( return SizeChangedLayoutNotifier(
child: widget.child, child:
_isBusy
? Material(
key: Key('app-splash-screen-$_isBusy'),
child: Stack(
children: [
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/icon/kanban-1st.jpg'),
fit: BoxFit.cover,
opacity: 0.1,
),
color: Theme.of(context).colorScheme.surface,
backgroundBlendMode: BlendMode.darken,
),
),
Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 240),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/icon/icon.png',
width: 64,
height: 64,
color: Theme.of(context).colorScheme.onSurface,
),
Text('Solar Network').bold(),
AppVersionLabel(),
Gap(8),
Text(_phaseText, textAlign: TextAlign.center),
Gap(16),
const LinearProgressIndicator(),
],
),
),
),
],
),
)
: widget.child,
); );
}, },
), ),

View File

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

View File

@ -21,6 +21,7 @@ const kAppRealmCompactView = 'app_realm_compact_view';
const kAppCustomFonts = 'app_custom_fonts'; const kAppCustomFonts = 'app_custom_fonts';
const kAppMixedFeed = 'app_mixed_feed'; const kAppMixedFeed = 'app_mixed_feed';
const kAppAutoTranslate = 'app_auto_translate'; const kAppAutoTranslate = 'app_auto_translate';
const kAppHideBottomNav = 'app_hide_bottom_nav';
const Map<String, FilterQuality> kImageQualityLevel = { const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none, 'settingsImageQualityLowest': FilterQuality.none,
@ -91,6 +92,15 @@ class ConfigProvider extends ChangeNotifier {
return prefs.getBool(kAppAutoTranslate) ?? false; return prefs.getBool(kAppAutoTranslate) ?? false;
} }
bool get hideBottomNav {
return prefs.getBool(kAppHideBottomNav) ?? false;
}
set hideBottomNav(bool value) {
prefs.setBool(kAppHideBottomNav, value);
notifyListeners();
}
set autoTranslate(bool value) { set autoTranslate(bool value) {
prefs.setBool(kAppAutoTranslate, value); prefs.setBool(kAppAutoTranslate, value);
notifyListeners(); notifyListeners();

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/types/realm.dart';
class AppNavDestination { class AppNavDestination {
final String label; final String label;
@ -24,13 +25,10 @@ class NavigationProvider extends ChangeNotifier {
int? get currentIndex => _currentIndex; int? get currentIndex => _currentIndex;
static const List<String> kShowBottomNavScreen = [ List<String> get showBottomNavScreen => destinations
'home', .where((ele) => ele.isPinned)
'explore', .map((ele) => ele.screen)
'account', .toList();
'album',
'chat',
];
static const List<AppNavDestination> kAllDestination = [ static const List<AppNavDestination> kAllDestination = [
AppNavDestination( AppNavDestination(
@ -63,32 +61,12 @@ class NavigationProvider extends ChangeNotifier {
screen: 'news', screen: 'news',
label: 'screenNews', label: 'screenNews',
), ),
AppNavDestination(
icon: Icon(Symbols.emoji_emotions, weight: 400, opticalSize: 20),
screen: 'stickers',
label: 'screenStickers',
),
AppNavDestination(
icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20),
screen: 'album',
label: 'screenAlbum',
),
AppNavDestination(
icon: Icon(Symbols.diversity_4, weight: 400, opticalSize: 20),
screen: 'friend',
label: 'screenFriend',
),
AppNavDestination(
icon: Icon(Symbols.notifications, weight: 400, opticalSize: 20),
screen: 'notification',
label: 'screenNotification',
),
]; ];
static const List<String> kDefaultPinnedDestination = [ static const List<String> kDefaultPinnedDestination = [
'home', 'home',
'explore', 'explore',
'chat', 'chat',
'account', 'realm',
]; ];
List<AppNavDestination> destinations = []; List<AppNavDestination> destinations = [];
@ -143,4 +121,11 @@ class NavigationProvider extends ChangeNotifier {
_currentIndex = idx; _currentIndex = idx;
notifyListeners(); notifyListeners();
} }
SnRealm? focusedRealm;
void setFocusedRealm(SnRealm? realm) {
focusedRealm = realm;
notifyListeners();
}
} }

View File

@ -48,13 +48,11 @@ class NotificationProvider extends ChangeNotifier {
var deviceUuid = await FlutterUdid.consistentUdid; var deviceUuid = await FlutterUdid.consistentUdid;
if (deviceUuid.isEmpty) { if (deviceUuid.isEmpty) {
logging.warning( logging.warning('[Push Notification] Unable to active push notifications, couldn\'t get device uuid');
'[Push Notification] Unable to active push notifications, couldn\'t get device uuid');
return; return;
} else { } else {
logging.info('[Push Notification] Device UUID is $deviceUuid'); logging.info('[Push Notification] Device UUID is $deviceUuid');
logging logging.info('[Push Notification] Registering device push notifications...');
.info('[Push Notification] Registering device push notifications...');
} }
if (Platform.isIOS || Platform.isMacOS) { if (Platform.isIOS || Platform.isMacOS) {
@ -66,14 +64,14 @@ class NotificationProvider extends ChangeNotifier {
} }
logging.info('[Push Notification] Device Push Token is $token'); logging.info('[Push Notification] Device Push Token is $token');
await _sn.client.post( try {
'/cgi/id/notifications/subscription', await _sn.client.post(
data: { '/cgi/id/notifications/subscription',
'provider': provider, data: {'provider': provider, 'device_token': token, 'device_id': deviceUuid},
'device_token': token, );
'device_id': deviceUuid, } catch (err) {
}, logging.error('[Push Notification] Unable to register push notifications: $err');
); }
} }
int showingCount = 0; int showingCount = 0;
@ -91,8 +89,7 @@ class NotificationProvider extends ChangeNotifier {
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true; final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
if (doHaptic) HapticFeedback.mediumImpact(); if (doHaptic) HapticFeedback.mediumImpact();
if (notification.topic == 'messaging.message' && if (notification.topic == 'messaging.message' && skippableNotifyChannel != null) {
skippableNotifyChannel != null) {
if (notification.metadata['channel_id'] != null && if (notification.metadata['channel_id'] != null &&
notification.metadata['channel_id'] == skippableNotifyChannel) { notification.metadata['channel_id'] == skippableNotifyChannel) {
return; return;

View File

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

View File

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

View File

@ -4,8 +4,7 @@ import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:surface/logger.dart'; import 'package:surface/logger.dart';
// TODO self host translate api const kTranslateApiBaseUrl = 'https://translate.solsynth.dev';
const kTranslateApiBaseUrl = 'https://translate.disroot.org';
class SnTranslator { class SnTranslator {
final Dio client = Dio( final Dio client = Dio(

View File

@ -64,6 +64,7 @@ class UserProvider extends ChangeNotifier {
} }
Future<SnAccount?> refreshUser() async { Future<SnAccount?> refreshUser() async {
if (!isAuthorized) return null;
final resp = await _sn.client.get('/cgi/id/users/me'); final resp = await _sn.client.get('/cgi/id/users/me');
final out = SnAccount.fromJson(resp.data); final out = SnAccount.fromJson(resp.data);

View File

@ -9,8 +9,11 @@ import 'package:surface/screens/account/badges.dart';
import 'package:surface/screens/account/contact_methods.dart'; import 'package:surface/screens/account/contact_methods.dart';
import 'package:surface/screens/account/factor_settings.dart'; import 'package:surface/screens/account/factor_settings.dart';
import 'package:surface/screens/account/keypairs.dart'; import 'package:surface/screens/account/keypairs.dart';
import 'package:surface/screens/account/prefs/notify.dart';
import 'package:surface/screens/account/prefs/security.dart';
import 'package:surface/screens/account/profile_page.dart'; import 'package:surface/screens/account/profile_page.dart';
import 'package:surface/screens/account/profile_edit.dart'; import 'package:surface/screens/account/profile_edit.dart';
import 'package:surface/screens/account/programs.dart';
import 'package:surface/screens/account/publishers/publisher_edit.dart'; import 'package:surface/screens/account/publishers/publisher_edit.dart';
import 'package:surface/screens/account/publishers/publisher_new.dart'; import 'package:surface/screens/account/publishers/publisher_new.dart';
import 'package:surface/screens/account/publishers/publishers.dart'; import 'package:surface/screens/account/publishers/publishers.dart';
@ -37,6 +40,7 @@ import 'package:surface/screens/post/post_shuffle.dart';
import 'package:surface/screens/post/publisher_page.dart'; import 'package:surface/screens/post/publisher_page.dart';
import 'package:surface/screens/post/post_search.dart'; import 'package:surface/screens/post/post_search.dart';
import 'package:surface/screens/realm.dart'; import 'package:surface/screens/realm.dart';
import 'package:surface/screens/realm/community.dart';
import 'package:surface/screens/realm/manage.dart'; import 'package:surface/screens/realm/manage.dart';
import 'package:surface/screens/realm/realm_detail.dart'; import 'package:surface/screens/realm/realm_detail.dart';
import 'package:surface/screens/realm/realm_discovery.dart'; import 'package:surface/screens/realm/realm_discovery.dart';
@ -127,6 +131,11 @@ final _appRoutes = [
name: 'account', name: 'account',
builder: (context, state) => const AccountScreen(), builder: (context, state) => const AccountScreen(),
routes: [ routes: [
GoRoute(
path: '/programs',
name: 'accountProgram',
builder: (context, state) => const AccountProgramScreen(),
),
GoRoute( GoRoute(
path: '/contacts', path: '/contacts',
name: 'accountContactMethods', name: 'accountContactMethods',
@ -161,6 +170,18 @@ final _appRoutes = [
path: '/settings', path: '/settings',
name: 'accountSettings', name: 'accountSettings',
builder: (context, state) => AccountSettingsScreen(), builder: (context, state) => AccountSettingsScreen(),
routes: [
GoRoute(
path: '/notify',
name: 'accountSettingsNotify',
builder: (context, state) => const AccountNotifyPrefsScreen(),
),
GoRoute(
path: '/auth',
name: 'accountSettingsSecurity',
builder: (context, state) => const AccountSecurityPrefsScreen(),
),
],
), ),
GoRoute( GoRoute(
path: '/settings/factors', path: '/settings/factors',
@ -245,6 +266,13 @@ final _appRoutes = [
child: const RealmScreen(), child: const RealmScreen(),
), ),
routes: [ routes: [
GoRoute(
path: '/:alias/community',
name: 'realmCommunity',
builder: (context, state) => RealmCommunityScreen(
alias: state.pathParameters['alias']!,
),
),
GoRoute( GoRoute(
path: '/manage', path: '/manage',
name: 'realmManage', name: 'realmManage',

View File

@ -30,19 +30,7 @@ class AccountScreen extends StatelessWidget {
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
title: Text( title: Text("screenAccount").tr(),
"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 flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty
? Stack( ? Stack(
fit: StackFit.expand, fit: StackFit.expand,
@ -158,23 +146,43 @@ class _AuthorizedAccountScreen extends StatelessWidget {
}, },
), ),
ListTile( ListTile(
title: Text('abuseReport').tr(), title: Text('accountProgram').tr(),
subtitle: Text('abuseReportActionDescription').tr(), subtitle: Text('accountProgramDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.flag), leading: const Icon(Symbols.communities),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed('abuseReport'); GoRouter.of(context).pushNamed('accountProgram');
}, },
), ),
ListTile( ListTile(
title: Text('factorSettings').tr(), title: Text('friends').tr(),
subtitle: Text('factorSettingsSubtitle').tr(), subtitle: Text('friendsDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.lock), leading: const Icon(Symbols.person),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed('factorSettings'); GoRouter.of(context).pushNamed('friend');
},
),
ListTile(
title: Text('album').tr(),
subtitle: Text('albumDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.photo_library),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('album');
},
),
ListTile(
title: Text('stickers').tr(),
subtitle: Text('stickersDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.emoji_emotions),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('stickers');
}, },
), ),
ListTile( ListTile(
@ -237,6 +245,16 @@ class _AuthorizedAccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('accountSettings'); GoRouter.of(context).pushNamed('accountSettings');
}, },
), ),
ListTile(
title: Text('abuseReport').tr(),
subtitle: Text('abuseReportActionDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.flag),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('abuseReport');
},
),
ListTile( ListTile(
title: Text('accountLogout').tr(), title: Text('accountLogout').tr(),
subtitle: Text('accountLogoutSubtitle').tr(), subtitle: Text('accountLogoutSubtitle').tr(),
@ -298,9 +316,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
GoRouter.of(context).pushNamed('authLogin').then((value) { GoRouter.of(context).pushNamed('authLogin').then((value) {
if (value == true && context.mounted) { if (value == true && context.mounted) {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
context.showSnackbar('loginSuccess'.tr(args: [ ua.refreshUser();
'@${ua.user?.name} (${ua.user?.nick})',
]));
} }
}); });
}, },

View File

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

View File

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

View File

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

View File

@ -0,0 +1,284 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/experience.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountProgramScreen extends StatefulWidget {
const AccountProgramScreen({super.key});
@override
State<AccountProgramScreen> createState() => _AccountProgramScreenState();
}
class _AccountProgramScreenState extends State<AccountProgramScreen> {
bool _isBusy = false;
final List<SnProgram> _programs = List.empty(growable: true);
final List<SnProgramMember> _programMembers = List.empty(growable: true);
Future<void> _fetchPrograms() async {
_programs.clear();
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/programs');
_programs.addAll(
resp.data.map((ele) => SnProgram.fromJson(ele)).cast<SnProgram>(),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _fetchProgramMembers() async {
_programMembers.clear();
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/programs/members');
_programMembers.addAll(
resp.data
.map((ele) => SnProgramMember.fromJson(ele))
.cast<SnProgramMember>(),
);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
void initState() {
super.initState();
_fetchPrograms();
_fetchProgramMembers();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text('accountProgram').tr(),
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: _programs.length,
itemBuilder: (context, idx) {
final ele = _programs[idx];
return Card(
child: InkWell(
borderRadius: BorderRadius.all(Radius.circular(8)),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => _ProgramJoinPopup(
program: ele,
isJoined: _programMembers
.any((ele) => ele.programId == ele.id),
),
).then((value) {
_fetchProgramMembers();
});
},
child: Column(
children: [
if (ele.appearance['banner'] != null)
AspectRatio(
aspectRatio: 16 / 5,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Theme.of(context)
.colorScheme
.surfaceVariant,
child: Image.network(
ele.appearance['banner'],
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ele.name,
style: Theme.of(context)
.textTheme
.titleMedium,
).bold(),
Text(
ele.description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (_programMembers
.any((ele) => ele.programId == ele.id))
Text('accountProgramAlreadyJoined'.tr())
.opacity(0.75),
],
),
),
],
),
),
],
),
),
).padding(horizontal: 8);
},
),
),
],
),
);
}
}
class _ProgramJoinPopup extends StatefulWidget {
final SnProgram program;
final bool isJoined;
const _ProgramJoinPopup({required this.program, required this.isJoined});
@override
State<_ProgramJoinPopup> createState() => _ProgramJoinPopupState();
}
class _ProgramJoinPopupState extends State<_ProgramJoinPopup> {
bool _isBusy = false;
Future<void> _joinProgram() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/programs/${widget.program.id}');
if (!mounted) return;
Navigator.pop(context, true);
context.showSnackbar('accountProgramJoined'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
Future<void> _leaveProgram() async {
setState(() => _isBusy = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.delete('/cgi/id/programs/${widget.program.id}');
if (!mounted) return;
Navigator.pop(context, true);
context.showSnackbar('accountProgramLeft'.tr());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.add, size: 24),
const Gap(16),
Text(
'accountProgramJoin',
style: Theme.of(context).textTheme.titleLarge,
).tr(),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.program.appearance['banner'] != null)
AspectRatio(
aspectRatio: 16 / 5,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
child: Image.network(
widget.program.appearance['banner'],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
).padding(bottom: 12),
Text(
widget.program.name,
style: Theme.of(context).textTheme.titleMedium,
).bold(),
Text(
widget.program.description,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const Gap(8),
Text(
'accountProgramJoinRequirements',
style: Theme.of(context).textTheme.titleMedium,
).tr().bold(),
Text('≥EXP ${widget.program.expRequirement}'),
Text('≥Lv${getLevelFromExp(widget.program.expRequirement)}'),
const Gap(8),
Text(
'accountProgramJoinPricing',
style: Theme.of(context).textTheme.titleMedium,
).tr().bold(),
Text('walletCurrency${widget.program.price['currency'].toString().capitalize().replaceFirst('Normal', '')}')
.plural(widget.program.price['amount'].toDouble()),
Text('accountProgramJoinPricingHint').tr().opacity(0.75),
const Gap(8),
if (widget.isJoined)
Text('accountProgramLeaveHint')
.tr()
.opacity(0.75)
.padding(bottom: 8),
if (!widget.isJoined)
ElevatedButton(
onPressed: _isBusy ? null : _joinProgram,
child: Text('join').tr(),
)
else
ElevatedButton(
onPressed: _isBusy ? null : _leaveProgram,
child: Text('leave').tr(),
),
],
).padding(horizontal: 24),
],
);
}
}

View File

@ -10,7 +10,6 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/attachment/attachment_zoom.dart'; import 'package:surface/widgets/attachment/attachment_zoom.dart';
import 'package:surface/widgets/attachment/attachment_item.dart'; import 'package:surface/widgets/attachment/attachment_item.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
@ -106,7 +105,7 @@ class _AlbumScreenState extends State<AlbumScreen> {
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
leading: AutoAppBarLeading(), leading: PageBackButton(),
title: Text('screenAlbum').tr(), title: Text('screenAlbum').tr(),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
@ -119,7 +118,8 @@ class _AlbumScreenState extends State<AlbumScreen> {
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: _billing?.includedRatio ?? 0, value: _billing?.includedRatio ?? 0,
strokeWidth: 8, strokeWidth: 8,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHigh,
), ),
).padding(all: 12), ).padding(all: 12),
const Gap(24), const Gap(24),
@ -129,7 +129,8 @@ class _AlbumScreenState extends State<AlbumScreen> {
children: [ children: [
Text('attachmentBillingUploaded').tr().bold(), Text('attachmentBillingUploaded').tr().bold(),
Text( Text(
(_billing?.currentBytes ?? 0).formatBytes(decimals: 4), (_billing?.currentBytes ?? 0)
.formatBytes(decimals: 4),
style: GoogleFonts.robotoMono(), style: GoogleFonts.robotoMono(),
), ),
Text('attachmentBillingDiscount').tr().bold(), Text('attachmentBillingDiscount').tr().bold(),

View File

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

View File

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

View File

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

View File

@ -0,0 +1,54 @@
import 'dart:html' as html;
import 'dart:ui_web' as ui;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class CaptchaScreen extends StatefulWidget {
const CaptchaScreen({super.key});
@override
State<CaptchaScreen> createState() => _CaptchaScreenState();
}
class _CaptchaScreenState extends State<CaptchaScreen> {
@override
void initState() {
super.initState();
_setupWebListener();
}
void _setupWebListener() {
html.window.onMessage.listen((event) {
if (event.data != null && event.data is String) {
final message = event.data as String;
if (message.startsWith("captcha_tk=")) {
String token = message.replaceFirst("captcha_tk=", "");
Navigator.pop(context, token);
}
}
});
final iframe = html.IFrameElement()
..src = '${context.read<ConfigProvider>().serverUrl}/captcha?redirect_uri=web'
..style.border = 'none'
..width = '100%'
..height = '100%';
html.document.body!.append(iframe);
ui.platformViewRegistry.registerViewFactory(
'captcha-iframe',
(int viewId) => iframe,
);
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(title: Text("reCaptcha").tr()),
body: HtmlElementView(viewType: 'captcha-iframe'),
);
}
}

View File

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

View File

@ -449,7 +449,7 @@ class _PostListWidgetState extends State<_PostListWidget> {
data: ele.toJson(), data: ele.toJson(),
createdAt: ele.createdAt)), createdAt: ele.createdAt)),
); );
_hasLoadedAll = postCount >= _feed.length; _hasLoadedAll = _feed.length >= postCount;
if (mounted) setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
} }

View File

@ -46,9 +46,7 @@ class _FriendScreenState extends State<FriendScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/relations?status=1'); final resp = await sn.client.get('/cgi/id/users/me/relations?status=1');
_relations = List<SnRelationship>.from( _relations = List<SnRelationship>.from(resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -66,9 +64,7 @@ class _FriendScreenState extends State<FriendScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3'); final resp = await sn.client.get('/cgi/id/users/me/relations?status=0,3');
_requests = List<SnRelationship>.from( _requests = List<SnRelationship>.from(resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -86,9 +82,7 @@ class _FriendScreenState extends State<FriendScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/me/relations?status=2'); final resp = await sn.client.get('/cgi/id/users/me/relations?status=2');
_blocks = List<SnRelationship>.from( _blocks = List<SnRelationship>.from(resp.data?.map((e) => SnRelationship.fromJson(e)) ?? []);
resp.data?.map((e) => SnRelationship.fromJson(e)) ?? [],
);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -104,11 +98,7 @@ class _FriendScreenState extends State<FriendScreen> {
try { try {
final rel = context.read<SnRelationshipProvider>(); final rel = context.read<SnRelationshipProvider>();
await rel.updateRelationship( await rel.updateRelationship(relation.relatedId, dstStatus, relation.permNodes);
relation.relatedId,
dstStatus,
relation.permNodes,
);
if (!mounted) return; if (!mounted) return;
_fetchRelations(); _fetchRelations();
} catch (err) { } catch (err) {
@ -122,9 +112,7 @@ class _FriendScreenState extends State<FriendScreen> {
Future<void> _deleteRelation(SnRelationship relation) async { Future<void> _deleteRelation(SnRelationship relation) async {
final confirm = await context.showConfirmDialog( final confirm = await context.showConfirmDialog(
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]), 'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
'friendDeleteDescription'.tr(args: [ 'friendDeleteDescription'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
relation.related?.nick ?? 'unknown'.tr(),
]),
); );
if (!confirm) return; if (!confirm) return;
if (!mounted) return; if (!mounted) return;
@ -145,10 +133,9 @@ class _FriendScreenState extends State<FriendScreen> {
} }
void _showRequests() { void _showRequests() {
showModalBottomSheet( showModalBottomSheet(context: context, builder: (context) => _FriendshipListWidget(relations: _requests)).then((
context: context, value,
builder: (context) => _FriendshipListWidget(relations: _requests), ) {
).then((value) {
if (value != null) { if (value != null) {
_fetchRequests(); _fetchRequests();
_fetchRelations(); _fetchRelations();
@ -157,10 +144,9 @@ class _FriendScreenState extends State<FriendScreen> {
} }
void _showBlocks() { void _showBlocks() {
showModalBottomSheet( showModalBottomSheet(context: context, builder: (context) => _FriendshipListWidget(relations: _blocks)).then((
context: context, value,
builder: (context) => _FriendshipListWidget(relations: _blocks), ) {
).then((value) {
if (value != null) { if (value != null) {
_fetchBlocks(); _fetchBlocks();
_fetchRelations(); _fetchRelations();
@ -173,9 +159,7 @@ class _FriendScreenState extends State<FriendScreen> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/users/me/relations', data: { await sn.client.post('/cgi/id/users/me/relations', data: {'related': user.name});
'related': user.name,
});
if (!mounted) return; if (!mounted) return;
context.showSnackbar('friendRequestSent'.tr()); context.showSnackbar('friendRequestSent'.tr());
} catch (err) { } catch (err) {
@ -200,29 +184,19 @@ class _FriendScreenState extends State<FriendScreen> {
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(leading: PageBackButton(), title: Text('screenFriend').tr()),
leading: AutoAppBarLeading(), body: Center(child: UnauthorizedHint()),
title: Text('screenFriend').tr(),
),
body: Center(
child: UnauthorizedHint(),
),
); );
} }
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(leading: AutoAppBarLeading(), title: Text('screenFriend').tr()),
leading: AutoAppBarLeading(),
title: Text('screenFriend').tr(),
),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
onPressed: () async { onPressed: () async {
final user = await showModalBottomSheet<SnAccount?>( final user = await showModalBottomSheet<SnAccount?>(
context: context, context: context,
builder: (context) => AccountSelect( builder: (context) => AccountSelect(title: 'friendNew'.tr()),
title: 'friendNew'.tr(),
),
); );
if (!mounted) return; if (!mounted) return;
if (user == null) return; if (user == null) return;
@ -235,9 +209,7 @@ class _FriendScreenState extends State<FriendScreen> {
if (_requests.isNotEmpty) if (_requests.isNotEmpty)
ListTile( ListTile(
title: Text('friendRequests').tr(), title: Text('friendRequests').tr(),
subtitle: Text( subtitle: Text('friendRequestsDescription').plural(_requests.length),
'friendRequestsDescription',
).plural(_requests.length),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.group_add), leading: const Icon(Symbols.group_add),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
@ -246,9 +218,7 @@ class _FriendScreenState extends State<FriendScreen> {
if (_blocks.isNotEmpty) if (_blocks.isNotEmpty)
ListTile( ListTile(
title: Text('friendBlocklist').tr(), title: Text('friendBlocklist').tr(),
subtitle: Text( subtitle: Text('friendBlocklistDescription').plural(_blocks.length),
'friendBlocklistDescription',
).plural(_blocks.length),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.block), leading: const Icon(Symbols.block),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
@ -260,17 +230,15 @@ class _FriendScreenState extends State<FriendScreen> {
context: context, context: context,
removeTop: true, removeTop: true,
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => Future.wait([ onRefresh: () => Future.wait([_fetchRelations(), _fetchRequests()]),
_fetchRelations(),
_fetchRequests(),
]),
child: ListView.builder( child: ListView.builder(
itemCount: _relations.length, itemCount: _relations.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final relation = _relations[index]; final relation = _relations[index];
final other = relation.related; final other = relation.related;
return ListTile( return ListTile(
contentPadding: const EdgeInsets.only(right: 24, left: 16), contentPadding:
const EdgeInsets.only(right: 24, left: 16),
leading: AccountImage(content: other?.avatar), leading: AccountImage(content: other?.avatar),
title: Text(other?.nick ?? 'unknown'), title: Text(other?.nick ?? 'unknown'),
subtitle: Text(other?.nick ?? 'unknown'), subtitle: Text(other?.nick ?? 'unknown'),
@ -360,11 +328,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
try { try {
final rel = context.read<SnRelationshipProvider>(); final rel = context.read<SnRelationshipProvider>();
await rel.updateRelationship( await rel.updateRelationship(relation.relatedId, dstStatus, relation.permNodes);
relation.relatedId,
dstStatus,
relation.permNodes,
);
if (!mounted) return; if (!mounted) return;
Navigator.pop(context, true); Navigator.pop(context, true);
} catch (err) { } catch (err) {
@ -378,9 +342,7 @@ class _FriendshipListWidgetState extends State<_FriendshipListWidget> {
Future<void> _deleteRelation(SnRelationship relation) async { Future<void> _deleteRelation(SnRelationship relation) async {
final confirm = await context.showConfirmDialog( final confirm = await context.showConfirmDialog(
'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]), 'friendDelete'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
'friendDeleteDescription'.tr(args: [ 'friendDeleteDescription'.tr(args: [relation.related?.nick ?? 'unknown'.tr()]),
relation.related?.nick ?? 'unknown'.tr(),
]),
); );
if (!confirm) return; if (!confirm) return;
if (!mounted) return; if (!mounted) return;

View File

@ -18,6 +18,7 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/special_day.dart'; import 'package:surface/providers/special_day.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/widget.dart'; import 'package:surface/providers/widget.dart';
import 'package:surface/screens/captcha/captcha.dart';
import 'package:surface/types/check_in.dart'; import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
@ -508,11 +509,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
} }
Future<void> _doCheckIn() async { Future<void> _doCheckIn() async {
final captchaTk = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => CaptchaScreen(),
),
);
if (captchaTk == null) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final home = context.read<HomeWidgetProvider>(); final home = context.read<HomeWidgetProvider>();
final resp = await sn.client.post('/cgi/id/check-in'); final resp = await sn.client.post('/cgi/id/check-in', data: {
'captcha_token': captchaTk,
});
_todayRecord = SnCheckInRecord.fromJson(resp.data); _todayRecord = SnCheckInRecord.fromJson(resp.data);
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson()); await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
} catch (err) { } catch (err) {
@ -796,7 +806,7 @@ class _HomeDashNotificationWidgetState
child: IconButton( child: IconButton(
icon: const Icon(Symbols.arrow_right_alt), icon: const Icon(Symbols.arrow_right_alt),
onPressed: () { onPressed: () {
GoRouter.of(context).goNamed('notification'); GoRouter.of(context).pushNamed('notification');
}, },
), ),
), ),

View File

@ -149,8 +149,9 @@ class _NotificationScreenState extends State<NotificationScreen> {
if (!ua.isAuthorized) { if (!ua.isAuthorized) {
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: PageBackButton(),
title: Text('screenNotification').tr()), title: Text('screenNotification').tr(),
),
body: Center(child: UnauthorizedHint()), body: Center(child: UnauthorizedHint()),
); );
} }

View File

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

View File

@ -28,11 +28,8 @@ class _PostShuffleScreenState extends State<PostShuffleScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final pt = context.read<SnPostContentProvider>(); final pt = context.read<SnPostContentProvider>();
final result = await pt.listPosts( final result =
take: 10, await pt.listPosts(take: 10, offset: _posts.length, isShuffle: true);
offset: _posts.length,
isShuffle: true,
);
_posts.addAll(result.$1); _posts.addAll(result.$1);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
@ -57,19 +54,14 @@ class _PostShuffleScreenState extends State<PostShuffleScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(title: Text('postShuffle').tr()),
title: Text('postShuffle').tr(),
),
body: Stack( body: Stack(
children: [ children: [
Column( Column(
children: [ children: [
if (_isBusy || _posts.isEmpty) if (_isBusy || _posts.isEmpty)
const Expanded( const Expanded(
child: Center( child: Center(child: CircularProgressIndicator()))
child: CircularProgressIndicator(),
),
)
else else
Expanded( Expanded(
child: CardSwiper( child: CardSwiper(
@ -81,17 +73,20 @@ class _PostShuffleScreenState extends State<PostShuffleScreen> {
final ele = _posts[idx]; final ele = _posts[idx];
return SingleChildScrollView( return SingleChildScrollView(
child: Center( child: Center(
child: OpenablePostItem( child: Card(
key: ValueKey(ele), color: Theme.of(context).colorScheme.surface,
data: ele, child: OpenablePostItem(
maxWidth: 640, key: ValueKey(ele),
onChanged: (ele) { data: ele,
_posts[idx] = ele; maxWidth: 640,
setState(() {}); onChanged: (ele) {
}, _posts[idx] = ele;
onDeleted: () { setState(() {});
_fetchPosts(); },
}, onDeleted: () {
_fetchPosts();
},
).padding(all: 8),
).padding( ).padding(
all: 24, all: 24,
bottom: bottom:

View File

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

View File

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

View File

@ -4,8 +4,10 @@ import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/channel.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/chat.dart'; import 'package:surface/types/chat.dart';
import 'package:surface/types/realm.dart'; import 'package:surface/types/realm.dart';
@ -57,7 +59,9 @@ class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
title: Text('screenRealmDiscovery').tr(), title: Text('screenRealmDiscovery').tr(),
actions: [ actions: [
IconButton( IconButton(
icon: _isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module), icon: _isCompactView
? const Icon(Symbols.view_list)
: const Icon(Symbols.view_module),
onPressed: () { onPressed: () {
setState(() => _isCompactView = !_isCompactView); setState(() => _isCompactView = !_isCompactView);
context.read<ConfigProvider>().realmCompactView = _isCompactView; context.read<ConfigProvider>().realmCompactView = _isCompactView;
@ -117,7 +121,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
try { try {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public'); final resp =
await sn.client.get('/cgi/im/channels/${widget.realm.alias}/public');
final out = List<SnChannel>.from( final out = List<SnChannel>.from(
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(), resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
); );
@ -135,10 +140,13 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
setState(() => _isJoining = true); setState(() => _isJoining = true);
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
await sn.client.post('/cgi/id/realms/${widget.realm.alias}/members', data: { final rel = context.read<SnRealmProvider>();
await sn.client
.post('/cgi/id/realms/${widget.realm.alias}/members', data: {
'related': ua.user?.name, 'related': ua.user?.name,
}); });
await _joinSelectedChannels(); await _joinSelectedChannels();
rel.addAvailableRealm(widget.realm);
if (!mounted) return; if (!mounted) return;
context.showSnackbar('realmJoined'.tr(args: [widget.realm.name])); context.showSnackbar('realmJoined'.tr(args: [widget.realm.name]));
Navigator.pop(context); Navigator.pop(context);
@ -156,13 +164,20 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
await sn.client.post('/cgi/im/channels/${widget.realm.alias}/$channel/members', data: { await sn.client.post(
'related': ua.user?.name, '/cgi/im/channels/${widget.realm.alias}/$channel/members',
}); data: {
'related': ua.user?.name,
});
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
} }
final ct = context.read<ChatChannelProvider>();
for (final channel
in _channels!.where((ele) => _planJoinChannels.contains(ele.alias))) {
ct.addAvailableChannel(channel);
}
} }
} }
@ -182,7 +197,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
children: [ children: [
const Icon(Symbols.group_add, size: 24), const Icon(Symbols.group_add, size: 24),
const Gap(16), const Gap(16),
Text('realmJoin', style: Theme.of(context).textTheme.titleLarge).tr(), Text('realmJoin', style: Theme.of(context).textTheme.titleLarge)
.tr(),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
Row( Row(
@ -216,7 +232,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
Container( Container(
width: double.infinity, width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh, color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Text('realmCommunityPublicChannelsHint'.tr(), style: Theme.of(context).textTheme.bodyMedium) child: Text('realmCommunityPublicChannelsHint'.tr(),
style: Theme.of(context).textTheme.bodyMedium)
.padding(horizontal: 24, vertical: 8), .padding(horizontal: 24, vertical: 8),
), ),
Expanded( Expanded(

View File

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

View File

@ -9,7 +9,6 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_sticker.dart'; import 'package:surface/providers/sn_sticker.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/attachment.dart'; import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/attachment/attachment_item.dart'; import 'package:surface/widgets/attachment/attachment_item.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
@ -134,7 +133,7 @@ class _StickerScreenState extends State<StickerScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: AutoAppBarLeading(), leading: PageBackButton(),
title: Text('screenStickers').tr(), title: Text('screenStickers').tr(),
actions: [ actions: [
IconButton( IconButton(

View File

@ -45,10 +45,7 @@ class _WalletScreenState extends State<WalletScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(leading: PageBackButton(), title: Text('screenAccountWallet').tr()),
leading: PageBackButton(),
title: Text('screenAccountWallet').tr(),
),
body: Column( body: Column(
children: [ children: [
LoadingIndicator(isActive: _isBusy), LoadingIndicator(isActive: _isBusy),
@ -66,11 +63,6 @@ class _WalletScreenState extends State<WalletScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CircleAvatar(
radius: 28,
child: Icon(Symbols.wallet, size: 28),
),
const Gap(12),
SizedBox(width: double.infinity), SizedBox(width: double.infinity),
Text( Text(
NumberFormat.compactCurrency( NumberFormat.compactCurrency(
@ -81,6 +73,16 @@ class _WalletScreenState extends State<WalletScreen> {
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
Text('walletCurrency'.plural(double.parse(_wallet!.balance))), Text('walletCurrency'.plural(double.parse(_wallet!.balance))),
const Gap(16),
Text(
NumberFormat.compactCurrency(
locale: EasyLocalization.of(context)!.currentLocale.toString(),
symbol: '${'walletCurrencyGoldenShort'.tr()} ',
decimalDigits: 2,
).format(double.parse(_wallet!.goldenBalance)),
style: Theme.of(context).textTheme.titleLarge,
),
Text('walletCurrencyGolden'.plural(double.parse(_wallet!.goldenBalance))),
], ],
).padding(horizontal: 20, vertical: 24), ).padding(horizontal: 20, vertical: 24),
).padding(horizontal: 8, top: 16, bottom: 4), ).padding(horizontal: 8, top: 16, bottom: 4),
@ -109,14 +111,12 @@ class _WalletTransactionListState extends State<_WalletTransactionList> {
try { try {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/wa/transactions/me', queryParameters: { final resp = await sn.client.get(
'take': 10, '/cgi/wa/transactions/me',
'offset': _transactions.length, queryParameters: {'take': 10, 'offset': _transactions.length},
});
_totalCount = resp.data['count'];
_transactions.addAll(
resp.data['data']?.map((e) => SnTransaction.fromJson(e)).cast<SnTransaction>() ?? [],
); );
_totalCount = resp.data['count'];
_transactions.addAll(resp.data['data']?.map((e) => SnTransaction.fromJson(e)).cast<SnTransaction>() ?? []);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -159,12 +159,18 @@ class _WalletTransactionListState extends State<_WalletTransactionList> {
children: [ children: [
Text(ele.remark), Text(ele.remark),
const Gap(2), const Gap(2),
Text( Row(
DateFormat( children: [
null, Text(
EasyLocalization.of(context)!.currentLocale.toString(), 'walletTransactionType${ele.currency.capitalize()}'.tr(),
).format(ele.createdAt), style: Theme.of(context).textTheme.labelSmall,
style: Theme.of(context).textTheme.labelSmall, ),
Text(' · ').textStyle(Theme.of(context).textTheme.labelSmall!).padding(right: 4),
Text(
DateFormat(null, EasyLocalization.of(context)!.currentLocale.toString()).format(ele.createdAt),
style: Theme.of(context).textTheme.labelSmall,
),
],
), ),
], ],
), ),
@ -193,37 +199,33 @@ class _CreateWalletWidgetState extends State<_CreateWalletWidget> {
final TextEditingController passwordController = TextEditingController(); final TextEditingController passwordController = TextEditingController();
final password = await showDialog<String?>( final password = await showDialog<String?>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder:
title: Text('walletCreate').tr(), (ctx) => AlertDialog(
content: Column( title: Text('walletCreate').tr(),
crossAxisAlignment: CrossAxisAlignment.start, content: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
children: [ mainAxisSize: MainAxisSize.min,
Text('walletCreatePassword').tr(), children: [
const Gap(8), Text('walletCreatePassword').tr(),
TextField( const Gap(8),
autofocus: true, TextField(
obscureText: true, autofocus: true,
controller: passwordController, obscureText: true,
decoration: InputDecoration( controller: passwordController,
labelText: 'fieldPassword'.tr(), decoration: InputDecoration(labelText: 'fieldPassword'.tr()),
), ),
],
), ),
], actions: [
), TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text('cancel').tr()),
actions: [ TextButton(
TextButton( onPressed: () {
onPressed: () => Navigator.of(ctx).pop(), Navigator.of(ctx).pop(passwordController.text);
child: Text('cancel').tr(), },
child: Text('next').tr(),
),
],
), ),
TextButton(
onPressed: () {
Navigator.of(ctx).pop(passwordController.text);
},
child: Text('next').tr(),
),
],
),
); );
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
passwordController.dispose(); passwordController.dispose();
@ -234,9 +236,7 @@ class _CreateWalletWidgetState extends State<_CreateWalletWidget> {
try { try {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/wa/wallets/me', data: { await sn.client.post('/cgi/wa/wallets/me', data: {'password': password});
'password': password,
});
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
@ -255,20 +255,14 @@ class _CreateWalletWidgetState extends State<_CreateWalletWidget> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CircleAvatar( CircleAvatar(radius: 28, child: Icon(Symbols.add, size: 28)),
radius: 28,
child: Icon(Symbols.add, size: 28),
),
const Gap(12), const Gap(12),
Text('walletCreate', style: Theme.of(context).textTheme.titleLarge).tr(), Text('walletCreate', style: Theme.of(context).textTheme.titleLarge).tr(),
Text('walletCreateSubtitle', style: Theme.of(context).textTheme.bodyMedium).tr(), Text('walletCreateSubtitle', style: Theme.of(context).textTheme.bodyMedium).tr(),
const Gap(8), const Gap(8),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton( child: TextButton(onPressed: _isBusy ? null : () => _createWallet(), child: Text('next').tr()),
onPressed: _isBusy ? null : () => _createWallet(),
child: Text('next').tr(),
),
), ),
], ],
).padding(horizontal: 20, vertical: 24), ).padding(horizontal: 20, vertical: 24),

View File

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

View File

@ -184,3 +184,42 @@ abstract class SnActionEvent with _$SnActionEvent {
factory SnActionEvent.fromJson(Map<String, Object?> json) => factory SnActionEvent.fromJson(Map<String, Object?> json) =>
_$SnActionEventFromJson(json); _$SnActionEventFromJson(json);
} }
@freezed
abstract class SnProgram with _$SnProgram {
const factory SnProgram({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String name,
required String description,
required String alias,
required int expRequirement,
required Map<String, dynamic> price,
required Map<String, dynamic> badge,
required Map<String, dynamic> group,
required Map<String, dynamic> appearance,
}) = _SnProgram;
factory SnProgram.fromJson(Map<String, Object?> json) =>
_$SnProgramFromJson(json);
}
@freezed
abstract class SnProgramMember with _$SnProgramMember {
const factory SnProgramMember({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required DateTime lastPaid,
required SnAccount account,
required int accountId,
required SnProgram program,
required int programId,
}) = _SnProgramMember;
factory SnProgramMember.fromJson(Map<String, Object?> json) =>
_$SnProgramMemberFromJson(json);
}

View File

@ -3470,4 +3470,763 @@ class __$SnActionEventCopyWithImpl<$Res>
} }
} }
/// @nodoc
mixin _$SnProgram {
int get id;
DateTime get createdAt;
DateTime get updatedAt;
DateTime? get deletedAt;
String get name;
String get description;
String get alias;
int get expRequirement;
Map<String, dynamic> get price;
Map<String, dynamic> get badge;
Map<String, dynamic> get group;
Map<String, dynamic> get appearance;
/// Create a copy of SnProgram
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnProgramCopyWith<SnProgram> get copyWith =>
_$SnProgramCopyWithImpl<SnProgram>(this as SnProgram, _$identity);
/// Serializes this SnProgram to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is SnProgram &&
(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.name, name) || other.name == name) &&
(identical(other.description, description) ||
other.description == description) &&
(identical(other.alias, alias) || other.alias == alias) &&
(identical(other.expRequirement, expRequirement) ||
other.expRequirement == expRequirement) &&
const DeepCollectionEquality().equals(other.price, price) &&
const DeepCollectionEquality().equals(other.badge, badge) &&
const DeepCollectionEquality().equals(other.group, group) &&
const DeepCollectionEquality()
.equals(other.appearance, appearance));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
name,
description,
alias,
expRequirement,
const DeepCollectionEquality().hash(price),
const DeepCollectionEquality().hash(badge),
const DeepCollectionEquality().hash(group),
const DeepCollectionEquality().hash(appearance));
@override
String toString() {
return 'SnProgram(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, name: $name, description: $description, alias: $alias, expRequirement: $expRequirement, price: $price, badge: $badge, group: $group, appearance: $appearance)';
}
}
/// @nodoc
abstract mixin class $SnProgramCopyWith<$Res> {
factory $SnProgramCopyWith(SnProgram value, $Res Function(SnProgram) _then) =
_$SnProgramCopyWithImpl;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String name,
String description,
String alias,
int expRequirement,
Map<String, dynamic> price,
Map<String, dynamic> badge,
Map<String, dynamic> group,
Map<String, dynamic> appearance});
}
/// @nodoc
class _$SnProgramCopyWithImpl<$Res> implements $SnProgramCopyWith<$Res> {
_$SnProgramCopyWithImpl(this._self, this._then);
final SnProgram _self;
final $Res Function(SnProgram) _then;
/// Create a copy of SnProgram
/// 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? name = null,
Object? description = null,
Object? alias = null,
Object? expRequirement = null,
Object? price = null,
Object? badge = null,
Object? group = null,
Object? appearance = null,
}) {
return _then(_self.copyWith(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
name: null == name
? _self.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _self.description
: description // ignore: cast_nullable_to_non_nullable
as String,
alias: null == alias
? _self.alias
: alias // ignore: cast_nullable_to_non_nullable
as String,
expRequirement: null == expRequirement
? _self.expRequirement
: expRequirement // ignore: cast_nullable_to_non_nullable
as int,
price: null == price
? _self.price
: price // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
badge: null == badge
? _self.badge
: badge // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
group: null == group
? _self.group
: group // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
appearance: null == appearance
? _self.appearance
: appearance // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
));
}
}
/// @nodoc
@JsonSerializable()
class _SnProgram implements SnProgram {
const _SnProgram(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.name,
required this.description,
required this.alias,
required this.expRequirement,
required final Map<String, dynamic> price,
required final Map<String, dynamic> badge,
required final Map<String, dynamic> group,
required final Map<String, dynamic> appearance})
: _price = price,
_badge = badge,
_group = group,
_appearance = appearance;
factory _SnProgram.fromJson(Map<String, dynamic> json) =>
_$SnProgramFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final String name;
@override
final String description;
@override
final String alias;
@override
final int expRequirement;
final Map<String, dynamic> _price;
@override
Map<String, dynamic> get price {
if (_price is EqualUnmodifiableMapView) return _price;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_price);
}
final Map<String, dynamic> _badge;
@override
Map<String, dynamic> get badge {
if (_badge is EqualUnmodifiableMapView) return _badge;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_badge);
}
final Map<String, dynamic> _group;
@override
Map<String, dynamic> get group {
if (_group is EqualUnmodifiableMapView) return _group;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_group);
}
final Map<String, dynamic> _appearance;
@override
Map<String, dynamic> get appearance {
if (_appearance is EqualUnmodifiableMapView) return _appearance;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_appearance);
}
/// Create a copy of SnProgram
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnProgramCopyWith<_SnProgram> get copyWith =>
__$SnProgramCopyWithImpl<_SnProgram>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnProgramToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _SnProgram &&
(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.name, name) || other.name == name) &&
(identical(other.description, description) ||
other.description == description) &&
(identical(other.alias, alias) || other.alias == alias) &&
(identical(other.expRequirement, expRequirement) ||
other.expRequirement == expRequirement) &&
const DeepCollectionEquality().equals(other._price, _price) &&
const DeepCollectionEquality().equals(other._badge, _badge) &&
const DeepCollectionEquality().equals(other._group, _group) &&
const DeepCollectionEquality()
.equals(other._appearance, _appearance));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
deletedAt,
name,
description,
alias,
expRequirement,
const DeepCollectionEquality().hash(_price),
const DeepCollectionEquality().hash(_badge),
const DeepCollectionEquality().hash(_group),
const DeepCollectionEquality().hash(_appearance));
@override
String toString() {
return 'SnProgram(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, name: $name, description: $description, alias: $alias, expRequirement: $expRequirement, price: $price, badge: $badge, group: $group, appearance: $appearance)';
}
}
/// @nodoc
abstract mixin class _$SnProgramCopyWith<$Res>
implements $SnProgramCopyWith<$Res> {
factory _$SnProgramCopyWith(
_SnProgram value, $Res Function(_SnProgram) _then) =
__$SnProgramCopyWithImpl;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
String name,
String description,
String alias,
int expRequirement,
Map<String, dynamic> price,
Map<String, dynamic> badge,
Map<String, dynamic> group,
Map<String, dynamic> appearance});
}
/// @nodoc
class __$SnProgramCopyWithImpl<$Res> implements _$SnProgramCopyWith<$Res> {
__$SnProgramCopyWithImpl(this._self, this._then);
final _SnProgram _self;
final $Res Function(_SnProgram) _then;
/// Create a copy of SnProgram
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? name = null,
Object? description = null,
Object? alias = null,
Object? expRequirement = null,
Object? price = null,
Object? badge = null,
Object? group = null,
Object? appearance = null,
}) {
return _then(_SnProgram(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
name: null == name
? _self.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _self.description
: description // ignore: cast_nullable_to_non_nullable
as String,
alias: null == alias
? _self.alias
: alias // ignore: cast_nullable_to_non_nullable
as String,
expRequirement: null == expRequirement
? _self.expRequirement
: expRequirement // ignore: cast_nullable_to_non_nullable
as int,
price: null == price
? _self._price
: price // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
badge: null == badge
? _self._badge
: badge // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
group: null == group
? _self._group
: group // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
appearance: null == appearance
? _self._appearance
: appearance // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>,
));
}
}
/// @nodoc
mixin _$SnProgramMember {
int get id;
DateTime get createdAt;
DateTime get updatedAt;
DateTime? get deletedAt;
DateTime get lastPaid;
SnAccount get account;
int get accountId;
SnProgram get program;
int get programId;
/// Create a copy of SnProgramMember
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnProgramMemberCopyWith<SnProgramMember> get copyWith =>
_$SnProgramMemberCopyWithImpl<SnProgramMember>(
this as SnProgramMember, _$identity);
/// Serializes this SnProgramMember to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is SnProgramMember &&
(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.lastPaid, lastPaid) ||
other.lastPaid == lastPaid) &&
(identical(other.account, account) || other.account == account) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.program, program) || other.program == program) &&
(identical(other.programId, programId) ||
other.programId == programId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, lastPaid, account, accountId, program, programId);
@override
String toString() {
return 'SnProgramMember(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, lastPaid: $lastPaid, account: $account, accountId: $accountId, program: $program, programId: $programId)';
}
}
/// @nodoc
abstract mixin class $SnProgramMemberCopyWith<$Res> {
factory $SnProgramMemberCopyWith(
SnProgramMember value, $Res Function(SnProgramMember) _then) =
_$SnProgramMemberCopyWithImpl;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
DateTime lastPaid,
SnAccount account,
int accountId,
SnProgram program,
int programId});
$SnAccountCopyWith<$Res> get account;
$SnProgramCopyWith<$Res> get program;
}
/// @nodoc
class _$SnProgramMemberCopyWithImpl<$Res>
implements $SnProgramMemberCopyWith<$Res> {
_$SnProgramMemberCopyWithImpl(this._self, this._then);
final SnProgramMember _self;
final $Res Function(SnProgramMember) _then;
/// Create a copy of SnProgramMember
/// 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? lastPaid = null,
Object? account = null,
Object? accountId = null,
Object? program = null,
Object? programId = null,
}) {
return _then(_self.copyWith(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
lastPaid: null == lastPaid
? _self.lastPaid
: lastPaid // ignore: cast_nullable_to_non_nullable
as DateTime,
account: null == account
? _self.account
: account // ignore: cast_nullable_to_non_nullable
as SnAccount,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
program: null == program
? _self.program
: program // ignore: cast_nullable_to_non_nullable
as SnProgram,
programId: null == programId
? _self.programId
: programId // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of SnProgramMember
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
return _then(_self.copyWith(account: value));
});
}
/// Create a copy of SnProgramMember
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnProgramCopyWith<$Res> get program {
return $SnProgramCopyWith<$Res>(_self.program, (value) {
return _then(_self.copyWith(program: value));
});
}
}
/// @nodoc
@JsonSerializable()
class _SnProgramMember implements SnProgramMember {
const _SnProgramMember(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.lastPaid,
required this.account,
required this.accountId,
required this.program,
required this.programId});
factory _SnProgramMember.fromJson(Map<String, dynamic> json) =>
_$SnProgramMemberFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final DateTime? deletedAt;
@override
final DateTime lastPaid;
@override
final SnAccount account;
@override
final int accountId;
@override
final SnProgram program;
@override
final int programId;
/// Create a copy of SnProgramMember
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnProgramMemberCopyWith<_SnProgramMember> get copyWith =>
__$SnProgramMemberCopyWithImpl<_SnProgramMember>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnProgramMemberToJson(
this,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _SnProgramMember &&
(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.lastPaid, lastPaid) ||
other.lastPaid == lastPaid) &&
(identical(other.account, account) || other.account == account) &&
(identical(other.accountId, accountId) ||
other.accountId == accountId) &&
(identical(other.program, program) || other.program == program) &&
(identical(other.programId, programId) ||
other.programId == programId));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, lastPaid, account, accountId, program, programId);
@override
String toString() {
return 'SnProgramMember(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, lastPaid: $lastPaid, account: $account, accountId: $accountId, program: $program, programId: $programId)';
}
}
/// @nodoc
abstract mixin class _$SnProgramMemberCopyWith<$Res>
implements $SnProgramMemberCopyWith<$Res> {
factory _$SnProgramMemberCopyWith(
_SnProgramMember value, $Res Function(_SnProgramMember) _then) =
__$SnProgramMemberCopyWithImpl;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
DateTime? deletedAt,
DateTime lastPaid,
SnAccount account,
int accountId,
SnProgram program,
int programId});
@override
$SnAccountCopyWith<$Res> get account;
@override
$SnProgramCopyWith<$Res> get program;
}
/// @nodoc
class __$SnProgramMemberCopyWithImpl<$Res>
implements _$SnProgramMemberCopyWith<$Res> {
__$SnProgramMemberCopyWithImpl(this._self, this._then);
final _SnProgramMember _self;
final $Res Function(_SnProgramMember) _then;
/// Create a copy of SnProgramMember
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$Res call({
Object? id = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? lastPaid = null,
Object? account = null,
Object? accountId = null,
Object? program = null,
Object? programId = null,
}) {
return _then(_SnProgramMember(
id: null == id
? _self.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _self.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _self.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _self.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
lastPaid: null == lastPaid
? _self.lastPaid
: lastPaid // ignore: cast_nullable_to_non_nullable
as DateTime,
account: null == account
? _self.account
: account // ignore: cast_nullable_to_non_nullable
as SnAccount,
accountId: null == accountId
? _self.accountId
: accountId // ignore: cast_nullable_to_non_nullable
as int,
program: null == program
? _self.program
: program // ignore: cast_nullable_to_non_nullable
as SnProgram,
programId: null == programId
? _self.programId
: programId // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of SnProgramMember
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
return _then(_self.copyWith(account: value));
});
}
/// Create a copy of SnProgramMember
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnProgramCopyWith<$Res> get program {
return $SnProgramCopyWith<$Res>(_self.program, (value) {
return _then(_self.copyWith(program: value));
});
}
}
// dart format on // dart format on

View File

@ -319,3 +319,64 @@ Map<String, dynamic> _$SnActionEventToJson(_SnActionEvent instance) =>
'account': instance.account.toJson(), 'account': instance.account.toJson(),
'account_id': instance.accountId, 'account_id': instance.accountId,
}; };
_SnProgram _$SnProgramFromJson(Map<String, dynamic> json) => _SnProgram(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
name: json['name'] as String,
description: json['description'] as String,
alias: json['alias'] as String,
expRequirement: (json['exp_requirement'] as num).toInt(),
price: json['price'] as Map<String, dynamic>,
badge: json['badge'] as Map<String, dynamic>,
group: json['group'] as Map<String, dynamic>,
appearance: json['appearance'] as Map<String, dynamic>,
);
Map<String, dynamic> _$SnProgramToJson(_SnProgram instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'name': instance.name,
'description': instance.description,
'alias': instance.alias,
'exp_requirement': instance.expRequirement,
'price': instance.price,
'badge': instance.badge,
'group': instance.group,
'appearance': instance.appearance,
};
_SnProgramMember _$SnProgramMemberFromJson(Map<String, dynamic> json) =>
_SnProgramMember(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
lastPaid: DateTime.parse(json['last_paid'] as String),
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
program: SnProgram.fromJson(json['program'] as Map<String, dynamic>),
programId: (json['program_id'] as num).toInt(),
);
Map<String, dynamic> _$SnProgramMemberToJson(_SnProgramMember instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'last_paid': instance.lastPaid.toIso8601String(),
'account': instance.account.toJson(),
'account_id': instance.accountId,
'program': instance.program.toJson(),
'program_id': instance.programId,
};

View File

@ -11,6 +11,7 @@ abstract class SnWallet with _$SnWallet {
required DateTime updatedAt, required DateTime updatedAt,
required DateTime? deletedAt, required DateTime? deletedAt,
required String balance, required String balance,
required String goldenBalance,
required String password, required String password,
required int accountId, required int accountId,
}) = _SnWallet; }) = _SnWallet;
@ -27,6 +28,7 @@ abstract class SnTransaction with _$SnTransaction {
required DateTime? deletedAt, required DateTime? deletedAt,
required String remark, required String remark,
required String amount, required String amount,
required String currency,
required SnWallet? payer, required SnWallet? payer,
required SnWallet? payee, required SnWallet? payee,
required int? payerId, required int? payerId,

View File

@ -20,6 +20,7 @@ mixin _$SnWallet {
DateTime get updatedAt; DateTime get updatedAt;
DateTime? get deletedAt; DateTime? get deletedAt;
String get balance; String get balance;
String get goldenBalance;
String get password; String get password;
int get accountId; int get accountId;
@ -46,6 +47,8 @@ mixin _$SnWallet {
(identical(other.deletedAt, deletedAt) || (identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) && other.deletedAt == deletedAt) &&
(identical(other.balance, balance) || other.balance == balance) && (identical(other.balance, balance) || other.balance == balance) &&
(identical(other.goldenBalance, goldenBalance) ||
other.goldenBalance == goldenBalance) &&
(identical(other.password, password) || (identical(other.password, password) ||
other.password == password) && other.password == password) &&
(identical(other.accountId, accountId) || (identical(other.accountId, accountId) ||
@ -55,11 +58,11 @@ mixin _$SnWallet {
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt, int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, balance, password, accountId); deletedAt, balance, goldenBalance, password, accountId);
@override @override
String toString() { String toString() {
return 'SnWallet(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, balance: $balance, password: $password, accountId: $accountId)'; return 'SnWallet(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, balance: $balance, goldenBalance: $goldenBalance, password: $password, accountId: $accountId)';
} }
} }
@ -74,6 +77,7 @@ abstract mixin class $SnWalletCopyWith<$Res> {
DateTime updatedAt, DateTime updatedAt,
DateTime? deletedAt, DateTime? deletedAt,
String balance, String balance,
String goldenBalance,
String password, String password,
int accountId}); int accountId});
} }
@ -95,6 +99,7 @@ class _$SnWalletCopyWithImpl<$Res> implements $SnWalletCopyWith<$Res> {
Object? updatedAt = null, Object? updatedAt = null,
Object? deletedAt = freezed, Object? deletedAt = freezed,
Object? balance = null, Object? balance = null,
Object? goldenBalance = null,
Object? password = null, Object? password = null,
Object? accountId = null, Object? accountId = null,
}) { }) {
@ -119,6 +124,10 @@ class _$SnWalletCopyWithImpl<$Res> implements $SnWalletCopyWith<$Res> {
? _self.balance ? _self.balance
: balance // ignore: cast_nullable_to_non_nullable : balance // ignore: cast_nullable_to_non_nullable
as String, as String,
goldenBalance: null == goldenBalance
? _self.goldenBalance
: goldenBalance // ignore: cast_nullable_to_non_nullable
as String,
password: null == password password: null == password
? _self.password ? _self.password
: password // ignore: cast_nullable_to_non_nullable : password // ignore: cast_nullable_to_non_nullable
@ -140,6 +149,7 @@ class _SnWallet implements SnWallet {
required this.updatedAt, required this.updatedAt,
required this.deletedAt, required this.deletedAt,
required this.balance, required this.balance,
required this.goldenBalance,
required this.password, required this.password,
required this.accountId}); required this.accountId});
factory _SnWallet.fromJson(Map<String, dynamic> json) => factory _SnWallet.fromJson(Map<String, dynamic> json) =>
@ -156,6 +166,8 @@ class _SnWallet implements SnWallet {
@override @override
final String balance; final String balance;
@override @override
final String goldenBalance;
@override
final String password; final String password;
@override @override
final int accountId; final int accountId;
@ -188,6 +200,8 @@ class _SnWallet implements SnWallet {
(identical(other.deletedAt, deletedAt) || (identical(other.deletedAt, deletedAt) ||
other.deletedAt == deletedAt) && other.deletedAt == deletedAt) &&
(identical(other.balance, balance) || other.balance == balance) && (identical(other.balance, balance) || other.balance == balance) &&
(identical(other.goldenBalance, goldenBalance) ||
other.goldenBalance == goldenBalance) &&
(identical(other.password, password) || (identical(other.password, password) ||
other.password == password) && other.password == password) &&
(identical(other.accountId, accountId) || (identical(other.accountId, accountId) ||
@ -197,11 +211,11 @@ class _SnWallet implements SnWallet {
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt, int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, balance, password, accountId); deletedAt, balance, goldenBalance, password, accountId);
@override @override
String toString() { String toString() {
return 'SnWallet(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, balance: $balance, password: $password, accountId: $accountId)'; return 'SnWallet(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, balance: $balance, goldenBalance: $goldenBalance, password: $password, accountId: $accountId)';
} }
} }
@ -218,6 +232,7 @@ abstract mixin class _$SnWalletCopyWith<$Res>
DateTime updatedAt, DateTime updatedAt,
DateTime? deletedAt, DateTime? deletedAt,
String balance, String balance,
String goldenBalance,
String password, String password,
int accountId}); int accountId});
} }
@ -239,6 +254,7 @@ class __$SnWalletCopyWithImpl<$Res> implements _$SnWalletCopyWith<$Res> {
Object? updatedAt = null, Object? updatedAt = null,
Object? deletedAt = freezed, Object? deletedAt = freezed,
Object? balance = null, Object? balance = null,
Object? goldenBalance = null,
Object? password = null, Object? password = null,
Object? accountId = null, Object? accountId = null,
}) { }) {
@ -263,6 +279,10 @@ class __$SnWalletCopyWithImpl<$Res> implements _$SnWalletCopyWith<$Res> {
? _self.balance ? _self.balance
: balance // ignore: cast_nullable_to_non_nullable : balance // ignore: cast_nullable_to_non_nullable
as String, as String,
goldenBalance: null == goldenBalance
? _self.goldenBalance
: goldenBalance // ignore: cast_nullable_to_non_nullable
as String,
password: null == password password: null == password
? _self.password ? _self.password
: password // ignore: cast_nullable_to_non_nullable : password // ignore: cast_nullable_to_non_nullable
@ -283,6 +303,7 @@ mixin _$SnTransaction {
DateTime? get deletedAt; DateTime? get deletedAt;
String get remark; String get remark;
String get amount; String get amount;
String get currency;
SnWallet? get payer; SnWallet? get payer;
SnWallet? get payee; SnWallet? get payee;
int? get payerId; int? get payerId;
@ -313,6 +334,8 @@ mixin _$SnTransaction {
other.deletedAt == deletedAt) && other.deletedAt == deletedAt) &&
(identical(other.remark, remark) || other.remark == remark) && (identical(other.remark, remark) || other.remark == remark) &&
(identical(other.amount, amount) || other.amount == amount) && (identical(other.amount, amount) || other.amount == amount) &&
(identical(other.currency, currency) ||
other.currency == currency) &&
(identical(other.payer, payer) || other.payer == payer) && (identical(other.payer, payer) || other.payer == payer) &&
(identical(other.payee, payee) || other.payee == payee) && (identical(other.payee, payee) || other.payee == payee) &&
(identical(other.payerId, payerId) || other.payerId == payerId) && (identical(other.payerId, payerId) || other.payerId == payerId) &&
@ -322,11 +345,11 @@ mixin _$SnTransaction {
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt, int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, remark, amount, payer, payee, payerId, payeeId); deletedAt, remark, amount, currency, payer, payee, payerId, payeeId);
@override @override
String toString() { String toString() {
return 'SnTransaction(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, remark: $remark, amount: $amount, payer: $payer, payee: $payee, payerId: $payerId, payeeId: $payeeId)'; return 'SnTransaction(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, remark: $remark, amount: $amount, currency: $currency, payer: $payer, payee: $payee, payerId: $payerId, payeeId: $payeeId)';
} }
} }
@ -343,6 +366,7 @@ abstract mixin class $SnTransactionCopyWith<$Res> {
DateTime? deletedAt, DateTime? deletedAt,
String remark, String remark,
String amount, String amount,
String currency,
SnWallet? payer, SnWallet? payer,
SnWallet? payee, SnWallet? payee,
int? payerId, int? payerId,
@ -371,6 +395,7 @@ class _$SnTransactionCopyWithImpl<$Res>
Object? deletedAt = freezed, Object? deletedAt = freezed,
Object? remark = null, Object? remark = null,
Object? amount = null, Object? amount = null,
Object? currency = null,
Object? payer = freezed, Object? payer = freezed,
Object? payee = freezed, Object? payee = freezed,
Object? payerId = freezed, Object? payerId = freezed,
@ -401,6 +426,10 @@ class _$SnTransactionCopyWithImpl<$Res>
? _self.amount ? _self.amount
: amount // ignore: cast_nullable_to_non_nullable : amount // ignore: cast_nullable_to_non_nullable
as String, as String,
currency: null == currency
? _self.currency
: currency // ignore: cast_nullable_to_non_nullable
as String,
payer: freezed == payer payer: freezed == payer
? _self.payer ? _self.payer
: payer // ignore: cast_nullable_to_non_nullable : payer // ignore: cast_nullable_to_non_nullable
@ -459,6 +488,7 @@ class _SnTransaction implements SnTransaction {
required this.deletedAt, required this.deletedAt,
required this.remark, required this.remark,
required this.amount, required this.amount,
required this.currency,
required this.payer, required this.payer,
required this.payee, required this.payee,
required this.payerId, required this.payerId,
@ -479,6 +509,8 @@ class _SnTransaction implements SnTransaction {
@override @override
final String amount; final String amount;
@override @override
final String currency;
@override
final SnWallet? payer; final SnWallet? payer;
@override @override
final SnWallet? payee; final SnWallet? payee;
@ -516,6 +548,8 @@ class _SnTransaction implements SnTransaction {
other.deletedAt == deletedAt) && other.deletedAt == deletedAt) &&
(identical(other.remark, remark) || other.remark == remark) && (identical(other.remark, remark) || other.remark == remark) &&
(identical(other.amount, amount) || other.amount == amount) && (identical(other.amount, amount) || other.amount == amount) &&
(identical(other.currency, currency) ||
other.currency == currency) &&
(identical(other.payer, payer) || other.payer == payer) && (identical(other.payer, payer) || other.payer == payer) &&
(identical(other.payee, payee) || other.payee == payee) && (identical(other.payee, payee) || other.payee == payee) &&
(identical(other.payerId, payerId) || other.payerId == payerId) && (identical(other.payerId, payerId) || other.payerId == payerId) &&
@ -525,11 +559,11 @@ class _SnTransaction implements SnTransaction {
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt, int get hashCode => Object.hash(runtimeType, id, createdAt, updatedAt,
deletedAt, remark, amount, payer, payee, payerId, payeeId); deletedAt, remark, amount, currency, payer, payee, payerId, payeeId);
@override @override
String toString() { String toString() {
return 'SnTransaction(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, remark: $remark, amount: $amount, payer: $payer, payee: $payee, payerId: $payerId, payeeId: $payeeId)'; return 'SnTransaction(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, remark: $remark, amount: $amount, currency: $currency, payer: $payer, payee: $payee, payerId: $payerId, payeeId: $payeeId)';
} }
} }
@ -548,6 +582,7 @@ abstract mixin class _$SnTransactionCopyWith<$Res>
DateTime? deletedAt, DateTime? deletedAt,
String remark, String remark,
String amount, String amount,
String currency,
SnWallet? payer, SnWallet? payer,
SnWallet? payee, SnWallet? payee,
int? payerId, int? payerId,
@ -578,6 +613,7 @@ class __$SnTransactionCopyWithImpl<$Res>
Object? deletedAt = freezed, Object? deletedAt = freezed,
Object? remark = null, Object? remark = null,
Object? amount = null, Object? amount = null,
Object? currency = null,
Object? payer = freezed, Object? payer = freezed,
Object? payee = freezed, Object? payee = freezed,
Object? payerId = freezed, Object? payerId = freezed,
@ -608,6 +644,10 @@ class __$SnTransactionCopyWithImpl<$Res>
? _self.amount ? _self.amount
: amount // ignore: cast_nullable_to_non_nullable : amount // ignore: cast_nullable_to_non_nullable
as String, as String,
currency: null == currency
? _self.currency
: currency // ignore: cast_nullable_to_non_nullable
as String,
payer: freezed == payer payer: freezed == payer
? _self.payer ? _self.payer
: payer // ignore: cast_nullable_to_non_nullable : payer // ignore: cast_nullable_to_non_nullable

View File

@ -14,6 +14,7 @@ _SnWallet _$SnWalletFromJson(Map<String, dynamic> json) => _SnWallet(
? null ? null
: DateTime.parse(json['deleted_at'] as String), : DateTime.parse(json['deleted_at'] as String),
balance: json['balance'] as String, balance: json['balance'] as String,
goldenBalance: json['golden_balance'] as String,
password: json['password'] as String, password: json['password'] as String,
accountId: (json['account_id'] as num).toInt(), accountId: (json['account_id'] as num).toInt(),
); );
@ -24,6 +25,7 @@ Map<String, dynamic> _$SnWalletToJson(_SnWallet instance) => <String, dynamic>{
'updated_at': instance.updatedAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(),
'balance': instance.balance, 'balance': instance.balance,
'golden_balance': instance.goldenBalance,
'password': instance.password, 'password': instance.password,
'account_id': instance.accountId, 'account_id': instance.accountId,
}; };
@ -38,6 +40,7 @@ _SnTransaction _$SnTransactionFromJson(Map<String, dynamic> json) =>
: DateTime.parse(json['deleted_at'] as String), : DateTime.parse(json['deleted_at'] as String),
remark: json['remark'] as String, remark: json['remark'] as String,
amount: json['amount'] as String, amount: json['amount'] as String,
currency: json['currency'] as String,
payer: json['payer'] == null payer: json['payer'] == null
? null ? null
: SnWallet.fromJson(json['payer'] as Map<String, dynamic>), : SnWallet.fromJson(json['payer'] as Map<String, dynamic>),
@ -56,6 +59,7 @@ Map<String, dynamic> _$SnTransactionToJson(_SnTransaction instance) =>
'deleted_at': instance.deletedAt?.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(),
'remark': instance.remark, 'remark': instance.remark,
'amount': instance.amount, 'amount': instance.amount,
'currency': instance.currency,
'payer': instance.payer?.toJson(), 'payer': instance.payer?.toJson(),
'payee': instance.payee?.toJson(), 'payee': instance.payee?.toJson(),
'payer_id': instance.payerId, 'payer_id': instance.payerId,

View File

@ -65,7 +65,7 @@ class ChatMessage extends StatelessWidget {
key: Key('chat-message-${data.id}'), key: Key('chat-message-${data.id}'),
iconOnLeftSwipe: Symbols.reply, iconOnLeftSwipe: Symbols.reply,
iconOnRightSwipe: Symbols.edit, iconOnRightSwipe: Symbols.edit,
swipeSensitivity: 20, swipeSensitivity: 10,
onLeftSwipe: onReply != null ? (_) => onReply!(data) : null, onLeftSwipe: onReply != null ? (_) => onReply!(data) : null,
onRightSwipe: (onEdit != null && isOwner) ? (_) => onEdit!(data) : null, onRightSwipe: (onEdit != null && isOwner) ? (_) => onEdit!(data) : null,
child: ContextMenuArea( child: ContextMenuArea(

View File

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

View File

@ -16,7 +16,12 @@ class ConnectionIndicator extends StatelessWidget {
final ws = context.watch<WebSocketProvider>(); final ws = context.watch<WebSocketProvider>();
final cfg = context.watch<ConfigProvider>(); final cfg = context.watch<ConfigProvider>();
final marginLeft = cfg.drawerIsCollapsed ? 0.0 : cfg.drawerIsExpanded ? 304.0 : 80.0; final marginLeft =
cfg.drawerIsCollapsed
? 0.0
: cfg.drawerIsExpanded
? 304.0
: 80.0;
return ListenableBuilder( return ListenableBuilder(
listenable: ws, listenable: ws,
@ -32,37 +37,39 @@ class ConnectionIndicator extends StatelessWidget {
elevation: 2, elevation: 2,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
child: ua.isAuthorized child:
? Row( ua.isAuthorized
mainAxisSize: MainAxisSize.min, ? Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ crossAxisAlignment: CrossAxisAlignment.center,
if (ws.isBusy) children: [
Text('serverConnecting').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer) if (ws.isBusy)
else if (!ws.isConnected) Text(
Text('serverDisconnected') 'serverConnecting',
.tr() ).tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer)
.textColor(Theme.of(context).colorScheme.onSecondaryContainer) else if (!ws.isConnected)
else Text(
Text('serverConnected').tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer), 'serverDisconnected',
const Gap(8), ).tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer)
if (ws.isBusy) else
const CircularProgressIndicator(strokeWidth: 2.5) Text(
.width(12) 'serverConnected',
.height(12) ).tr().textColor(Theme.of(context).colorScheme.onSecondaryContainer),
.padding(horizontal: 4, right: 4) const Gap(8),
else if (!ws.isConnected) if (ws.isBusy)
const Icon(Symbols.power_off, size: 18) const CircularProgressIndicator(
else strokeWidth: 2.5,
const Icon(Symbols.power, size: 18), padding: EdgeInsets.zero,
], ).width(12).height(12).padding(horizontal: 4, right: 4)
).padding(horizontal: 8, vertical: 4) else if (!ws.isConnected)
: const SizedBox.shrink(), const Icon(Symbols.power_off, size: 18)
).opacity(show ? 1 : 0, animate: true).animate( else
const Duration(milliseconds: 300), const Icon(Symbols.power, size: 18),
Curves.easeInOut, ],
), ).padding(horizontal: 8, vertical: 4)
: const SizedBox.shrink(),
).opacity(show ? 1 : 0, animate: true).animate(const Duration(milliseconds: 300), Curves.easeInOut),
onTap: () { onTap: () {
if (!ws.isConnected && !ws.isBusy) { if (!ws.isConnected && !ws.isBusy) {
ws.connect(); ws.connect();

View File

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

View File

@ -176,7 +176,7 @@ class MarkdownTextContent extends StatelessWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio( child: AspectRatio(
aspectRatio: attachment.metadata['ratio'] ?? aspectRatio: attachment.metadata['ratio']?.toDouble() ??
switch (attachment.mimetype switch (attachment.mimetype
.split('/') .split('/')
.firstOrNull) { .firstOrNull) {

View File

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -83,6 +84,16 @@ class AppSystemMenuBar extends StatelessWidget {
), ),
], ],
), ),
PlatformMenuItem(
shortcut: const SingleActivator(
LogicalKeyboardKey.keyH,
meta: true,
),
label: 'trayMenuHide'.tr(),
onSelected: () {
appWindow.hide();
},
),
if (onQuit != null) if (onQuit != null)
PlatformMenuItem( PlatformMenuItem(
shortcut: const SingleActivator( shortcut: const SingleActivator(

View File

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

View File

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

View File

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

View File

@ -103,7 +103,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
await sn.client await sn.client
.put('/cgi/co/questions/${widget.parentPost.id}/answer', data: { .put('/cgi/co/questions/${widget.parentPost.id}/answer', data: {
'publisher': answer.publisherId, 'publisher': widget.parentPost.publisherId,
'answer_id': answer.id, 'answer_id': answer.id,
}); });
if (!mounted) return; if (!mounted) return;
@ -151,6 +151,7 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
}, },
), ),
onTap: () { onTap: () {
Navigator.pop(context);
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postDetail', 'postDetail',
pathParameters: {'slug': _posts[idx].id.toString()}, pathParameters: {'slug': _posts[idx].id.toString()},
@ -225,6 +226,9 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
onPost: () { onPost: () {
_childListKey.currentState!.refresh(); _childListKey.currentState!.refresh();
}, },
onExpand: () {
Navigator.pop(context);
},
), ),
), ),
), ),

View File

@ -103,7 +103,7 @@ class OpenablePostItem extends StatelessWidget {
transitionType: ContainerTransitionType.fade, transitionType: ContainerTransitionType.fade,
closedElevation: 0, closedElevation: 0,
closedColor: Theme.of(context).colorScheme.surface.withOpacity( closedColor: Theme.of(context).colorScheme.surface.withOpacity(
cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0.75 : 1, cfg.prefs.getBool(kAppBackgroundStoreKey) == true ? 0 : 1,
), ),
closedShape: const RoundedRectangleBorder( closedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)), borderRadius: BorderRadius.all(Radius.circular(16)),
@ -122,6 +122,7 @@ class PostItem extends StatefulWidget {
final bool showMenu; final bool showMenu;
final bool showFullPost; final bool showFullPost;
final bool showAvatar; final bool showAvatar;
final bool showCompactAvatar;
final bool showExpandableComments; final bool showExpandableComments;
final double? maxWidth; final double? maxWidth;
final Function(SnPost data)? onChanged; final Function(SnPost data)? onChanged;
@ -137,6 +138,7 @@ class PostItem extends StatefulWidget {
this.showMenu = true, this.showMenu = true,
this.showFullPost = false, this.showFullPost = false,
this.showAvatar = true, this.showAvatar = true,
this.showCompactAvatar = false,
this.showExpandableComments = false, this.showExpandableComments = false,
this.maxWidth, this.maxWidth,
this.onChanged, this.onChanged,
@ -277,6 +279,8 @@ class _PostItemState extends State<PostItem> {
final ua = context.read<UserProvider>(); final ua = context.read<UserProvider>();
final isAuthor = final isAuthor =
ua.isAuthorized && widget.data.publisher.accountId == ua.user?.id; ua.isAuthorized && widget.data.publisher.accountId == ua.user?.id;
final isParentAuthor = ua.isAuthorized &&
widget.data.replyTo?.publisher.accountId == ua.user?.id;
final displayableAttachments = widget.data.preload?.attachments final displayableAttachments = widget.data.preload?.attachments
?.where((ele) => ?.where((ele) =>
@ -297,185 +301,181 @@ class _PostItemState extends State<PostItem> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Container( Container(
constraints: constraints: BoxConstraints(
BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity), maxWidth: widget.maxWidth ?? double.infinity,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Row(
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, if (widget.showAvatar)
children: [ _PostAvatar(
Row( data: widget.data,
isCompact: false,
),
if (widget.showAvatar) const Gap(12),
Expanded(
child: Row(
children: [ children: [
if (widget.showAvatar) if (widget.showCompactAvatar)
_PostAvatar( _PostAvatar(
data: widget.data, data: widget.data,
isCompact: false, isCompact: true,
), ),
if (widget.showAvatar) const Gap(12), if (widget.showAvatar) const Gap(8),
Expanded( _PostContentHeader(
child: _PostContentHeader( isRelativeDate: !widget.showFullPost,
isRelativeDate: !widget.showFullPost, isCompact: false,
isCompact: false,
data: widget.data,
),
),
_PostActionPopup(
data: widget.data, data: widget.data,
isAuthor: isAuthor,
onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: widget.onSelectAnswer,
onDeleted: () {
widget.onDeleted?.call();
},
onTranslate: () {
_translateText();
},
), ),
], ],
), ),
const Gap(8), ),
if (widget.data.preload?.thumbnail != null) _PostActionPopup(
Container( data: widget.data,
margin: const EdgeInsets.only(bottom: 8), isAuthor: isAuthor,
decoration: BoxDecoration( isParentAuthor: isParentAuthor,
borderRadius: const BorderRadius.all( onShare: () => _doShare(context),
Radius.circular(8), onShareImage: () => _doShareViaPicture(context),
), onSelectAnswer: widget.onSelectAnswer,
border: Border.all( onDeleted: () {
color: Theme.of(context).dividerColor, widget.onDeleted?.call();
width: 1, },
), onTranslate: () {
), _translateText();
child: AspectRatio( },
aspectRatio: 16 / 9, ),
child: ClipRRect( ],
borderRadius: const BorderRadius.all( ),
Radius.circular(8), const Gap(8),
), if (widget.data.preload?.thumbnail != null)
child: AutoResizeUniversalImage( Container(
sn.getAttachmentUrl( margin: const EdgeInsets.only(bottom: 8),
widget.data.preload!.thumbnail!.rid, decoration: BoxDecoration(
), borderRadius: const BorderRadius.all(
fit: BoxFit.cover, Radius.circular(8),
),
),
),
),
if (widget.data.preload?.video != null)
_PostVideoPlayer(data: widget.data).padding(bottom: 8),
if (widget.data.type == 'question')
_PostQuestionHint(data: widget.data).padding(bottom: 8),
if (_displayDescription.isNotEmpty ||
_displayTitle.isNotEmpty)
_PostHeadline(
title: _displayTitle,
description: _displayDescription,
data: widget.data,
isEnlarge: widget.data.type == 'article' &&
widget.showFullPost,
).padding(bottom: 8),
if (widget.data.type == 'article' && !widget.showFullPost)
Text('postArticle')
.tr()
.fontSize(13)
.opacity(0.75)
.padding(bottom: 8),
if ((_displayText.isNotEmpty) &&
(widget.showFullPost ||
widget.data.type != 'article'))
_PostContentBody(
text: _displayText,
data: widget.data,
isSelectable: widget.showFullPost,
isEnlarge: widget.data.type == 'article' &&
widget.showFullPost,
).padding(bottom: 6),
if (widget.data.visibility > 0)
_PostVisibilityHint(data: widget.data).padding(
vertical: 4,
),
if (widget.data.body['content_truncated'] == true)
_PostTruncatedHint(data: widget.data).padding(
vertical: 4,
),
if (widget.data.tags.isNotEmpty)
_PostTagsList(data: widget.data)
.padding(top: 4, bottom: 6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (widget.showViews)
Row(
children: [
Icon(Symbols.play_circle, size: 20),
const Gap(4),
Text('postViews')
.plural(widget.data.totalViews),
],
).opacity(0.75),
if (_isTranslating)
AnimateWidgetExtensions(Row(
children: [
Icon(Symbols.translate, size: 20),
const Gap(4),
Text('translating').tr(),
],
))
.animate(onPlay: (e) => e.repeat())
.fadeIn(duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
if (_isTranslated)
InkWell(
child: Row(
children: [
Icon(Symbols.translate, size: 20),
const Gap(4),
Text('translated').tr(),
],
).opacity(0.75),
onTap: () {
setState(() {
_displayText =
widget.data.body['content'] ?? '';
_displayTitle =
widget.data.body['title'] ?? '';
_displayDescription =
widget.data.body['description'] ?? '';
_isTranslated = false;
});
},
),
if (widget.data.repostTo != null)
_PostQuoteContent(child: widget.data.repostTo!)
.padding(
top: 4,
bottom: widget.data.preload?.attachments
?.isNotEmpty ??
false
? 12
: 0,
),
],
).padding(
bottom:
widget.showViews || _isTranslated || _isTranslating
? 8
: 0,
), ),
], border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(
widget.data.preload!.thumbnail!.rid,
),
fit: BoxFit.cover,
),
),
),
), ),
).padding(horizontal: 12, top: 8), if (widget.data.preload?.video != null)
_PostVideoPlayer(data: widget.data).padding(bottom: 8),
if (widget.data.type == 'question')
_PostQuestionHint(data: widget.data).padding(bottom: 8),
if (_displayDescription.isNotEmpty || _displayTitle.isNotEmpty)
_PostHeadline(
title: _displayTitle,
description: _displayDescription,
data: widget.data,
isEnlarge:
widget.data.type == 'article' && widget.showFullPost,
).padding(bottom: 8),
if (widget.data.type == 'article' && !widget.showFullPost)
Text('postArticle')
.tr()
.fontSize(13)
.opacity(0.75)
.padding(bottom: 8),
if ((_displayText.isNotEmpty) &&
(widget.showFullPost || widget.data.type != 'article'))
_PostContentBody(
text: _displayText,
data: widget.data,
isSelectable: widget.showFullPost,
isEnlarge:
widget.data.type == 'article' && widget.showFullPost,
).padding(bottom: 6),
if (widget.data.visibility > 0)
_PostVisibilityHint(data: widget.data).padding(
vertical: 4,
),
if (widget.data.body['content_truncated'] == true)
_PostTruncatedHint(data: widget.data).padding(
vertical: 4,
),
if (widget.data.tags.isNotEmpty)
_PostTagsList(data: widget.data).padding(top: 4, bottom: 6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
if (widget.showViews)
Row(
children: [
Icon(Symbols.play_circle, size: 20),
const Gap(4),
Text('postViews').plural(widget.data.totalViews),
],
).opacity(0.75),
if (_isTranslating)
AnimateWidgetExtensions(Row(
children: [
Icon(Symbols.translate, size: 20),
const Gap(4),
Text('translating').tr(),
],
))
.animate(onPlay: (e) => e.repeat())
.fadeIn(duration: 500.ms, curve: Curves.easeOut)
.then()
.fadeOut(
duration: 500.ms,
delay: 1000.ms,
curve: Curves.easeIn,
),
if (_isTranslated)
InkWell(
child: Row(
children: [
Icon(Symbols.translate, size: 20),
const Gap(4),
Text('translated').tr(),
],
).opacity(0.75),
onTap: () {
setState(() {
_displayText = widget.data.body['content'] ?? '';
_displayTitle = widget.data.body['title'] ?? '';
_displayDescription =
widget.data.body['description'] ?? '';
_isTranslated = false;
});
},
),
if (widget.data.repostTo != null)
_PostQuoteContent(child: widget.data.repostTo!).padding(
top: 4,
bottom: widget.data.preload?.attachments?.isNotEmpty ??
false
? 12
: 0,
),
],
).padding(
bottom: widget.showViews || _isTranslated || _isTranslating
? 8
: 0,
),
], ],
), ).padding(horizontal: 12, top: 8),
), ),
if (displayableAttachments?.isNotEmpty ?? false) if (displayableAttachments?.isNotEmpty ?? false)
AttachmentList( AttachmentList(
@ -509,6 +509,7 @@ class _PostItemState extends State<PostItem> {
_PostCommentIntent( _PostCommentIntent(
data: widget.data, data: widget.data,
showAvatar: widget.showAvatar, showAvatar: widget.showAvatar,
maxWidth: widget.maxWidth ?? double.infinity,
).padding(left: 12, right: 12) ).padding(left: 12, right: 12)
else else
_PostFeaturedComment(data: widget.data, maxWidth: widget.maxWidth) _PostFeaturedComment(data: widget.data, maxWidth: widget.maxWidth)
@ -558,15 +559,28 @@ class _PostItemState extends State<PostItem> {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: _PostContentHeader( child: Row(
isRelativeDate: !widget.showFullPost, children: [
isCompact: true, if (widget.showCompactAvatar)
data: widget.data, _PostAvatar(
data: widget.data,
isCompact: true,
),
if (widget.showCompactAvatar) const Gap(8),
Expanded(
child: _PostContentHeader(
isRelativeDate: !widget.showFullPost,
isCompact: true,
data: widget.data,
),
),
],
), ),
), ),
_PostActionPopup( _PostActionPopup(
data: widget.data, data: widget.data,
isAuthor: isAuthor, isAuthor: isAuthor,
isParentAuthor: isParentAuthor,
onShare: () => _doShare(context), onShare: () => _doShare(context),
onShareImage: () => _doShareViaPicture(context), onShareImage: () => _doShareViaPicture(context),
onSelectAnswer: widget.onSelectAnswer, onSelectAnswer: widget.onSelectAnswer,
@ -578,7 +592,7 @@ class _PostItemState extends State<PostItem> {
}, },
), ),
], ],
), ).padding(bottom: widget.showCompactAvatar ? 4 : 0),
if (widget.data.preload?.thumbnail != null) if (widget.data.preload?.thumbnail != null)
Container( Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@ -755,19 +769,28 @@ class _PostItemState extends State<PostItem> {
if (widget.showExpandableComments) if (widget.showExpandableComments)
_PostCommentIntent( _PostCommentIntent(
data: widget.data, data: widget.data,
maxWidth: (widget.maxWidth ?? double.infinity) -
(widget.showAvatar ? 72 : 24),
showAvatar: widget.showAvatar, showAvatar: widget.showAvatar,
).padding(left: widget.showAvatar ? 60 : 12, right: 12) ).padding(left: widget.showAvatar ? 60 : 12, right: 12)
else if (widget.showComments) else if (widget.showComments)
_PostFeaturedComment(data: widget.data, maxWidth: widget.maxWidth) _PostFeaturedComment(data: widget.data, maxWidth: widget.maxWidth)
.padding(left: widget.showAvatar ? 60 : 12, right: 12), .padding(left: widget.showAvatar ? 60 : 12, right: 12),
if (widget.showReactions) if (widget.showReactions)
Padding( Container(
padding: const EdgeInsets.only(top: 4), constraints: BoxConstraints(
child: _PostReactionList( maxWidth: widget.maxWidth ?? double.infinity,
data: widget.data, ),
padding: child: Padding(
EdgeInsets.only(left: widget.showAvatar ? 60 : 12, right: 12), padding: const EdgeInsets.only(top: 4),
onChanged: _onChanged, child: _PostReactionList(
data: widget.data,
padding: EdgeInsets.only(
left: widget.showAvatar ? 60 : 12,
right: 12,
),
onChanged: _onChanged,
),
), ),
), ),
], ],
@ -1298,6 +1321,7 @@ class _PostAvatar extends StatelessWidget {
class _PostActionPopup extends StatelessWidget { class _PostActionPopup extends StatelessWidget {
final SnPost data; final SnPost data;
final bool isAuthor; final bool isAuthor;
final bool isParentAuthor;
final Function onDeleted; final Function onDeleted;
final Function() onShare, onShareImage; final Function() onShare, onShareImage;
final Function()? onSelectAnswer; final Function()? onSelectAnswer;
@ -1305,6 +1329,7 @@ class _PostActionPopup extends StatelessWidget {
const _PostActionPopup({ const _PostActionPopup({
required this.data, required this.data,
required this.isAuthor, required this.isAuthor,
required this.isParentAuthor,
required this.onDeleted, required this.onDeleted,
required this.onShare, required this.onShare,
required this.onShareImage, required this.onShareImage,
@ -1378,7 +1403,7 @@ class _PostActionPopup extends StatelessWidget {
}, },
), ),
if (onTranslate != null) PopupMenuDivider(), if (onTranslate != null) PopupMenuDivider(),
if (isAuthor && onSelectAnswer != null) if (isParentAuthor && onSelectAnswer != null)
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [
@ -1391,7 +1416,7 @@ class _PostActionPopup extends StatelessWidget {
onSelectAnswer?.call(); onSelectAnswer?.call();
}, },
), ),
if (isAuthor && onSelectAnswer != null) PopupMenuDivider(), if (isParentAuthor && onSelectAnswer != null) PopupMenuDivider(),
if (isAuthor) if (isAuthor)
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
@ -1552,19 +1577,24 @@ class _PostContentHeader extends StatelessWidget {
if (isCompact) { if (isCompact) {
return Row( return Row(
children: [ children: [
Text(data.publisher.nick).bold(), Flexible(
child: Text(
data.publisher.nick,
maxLines: 1,
).bold(),
),
const Gap(4), const Gap(4),
Row( Flexible(
children: [ child: Text(
Text( isRelativeDate
isRelativeDate ? RelativeTime(context)
? RelativeTime(context) .format((data.publishedAt ?? data.createdAt).toLocal())
.format((data.publishedAt ?? data.createdAt).toLocal()) : DateFormat('y/M/d HH:mm')
: DateFormat('y/M/d HH:mm') .format((data.publishedAt ?? data.createdAt).toLocal()),
.format((data.publishedAt ?? data.createdAt).toLocal()), maxLines: 1,
).fontSize(13), overflow: TextOverflow.fade,
], ).fontSize(13).opacity(0.8),
).opacity(0.8), ),
], ],
); );
} else { } else {
@ -1583,7 +1613,10 @@ class _PostContentHeader extends StatelessWidget {
), ),
Row( Row(
children: [ children: [
Text('@${data.publisher.name}').fontSize(13), Text(
'@${data.publisher.name}',
maxLines: 1,
).fontSize(13),
const Gap(4), const Gap(4),
Text( Text(
isRelativeDate isRelativeDate
@ -1591,6 +1624,8 @@ class _PostContentHeader extends StatelessWidget {
.format((data.publishedAt ?? data.createdAt).toLocal()) .format((data.publishedAt ?? data.createdAt).toLocal())
: DateFormat('y/M/d HH:mm') : DateFormat('y/M/d HH:mm')
.format((data.publishedAt ?? data.createdAt).toLocal()), .format((data.publishedAt ?? data.createdAt).toLocal()),
maxLines: 1,
overflow: TextOverflow.fade,
).fontSize(13), ).fontSize(13),
], ],
).opacity(0.8), ).opacity(0.8),
@ -1856,7 +1891,12 @@ class _PostTruncatedHint extends StatelessWidget {
class _PostCommentIntent extends StatefulWidget { class _PostCommentIntent extends StatefulWidget {
final SnPost data; final SnPost data;
final bool showAvatar; final bool showAvatar;
const _PostCommentIntent({required this.data, this.showAvatar = false}); final double maxWidth;
const _PostCommentIntent({
required this.data,
this.showAvatar = false,
required this.maxWidth,
});
@override @override
State<_PostCommentIntent> createState() => _PostCommentIntentState(); State<_PostCommentIntent> createState() => _PostCommentIntentState();
@ -1895,54 +1935,69 @@ class _PostCommentIntentState extends State<_PostCommentIntent> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Container(
children: [ constraints: BoxConstraints(maxWidth: widget.maxWidth),
if (_comments.isNotEmpty) child: Column(
Card( children: [
elevation: 4, if (_comments.isNotEmpty)
margin: EdgeInsets.zero, Card(
child: Column( elevation: 4,
spacing: 8, margin: EdgeInsets.zero,
children: [ child: Column(
for (final ele in _comments) spacing: 8,
PostItem( children: [
data: ele, for (final ele in _comments)
showAvatar: false, InkWell(
showExpandableComments: true, borderRadius: const BorderRadius.all(Radius.circular(8)),
showReactions: false, child: PostItem(
showViews: false, data: ele,
maxWidth: double.infinity, showAvatar: false,
).padding(vertical: 8, left: 6), showCompactAvatar: true,
], showExpandableComments: true,
), showReactions: false,
).padding(vertical: 8), showViews: false,
Row( maxWidth: double.infinity,
children: [ ).padding(vertical: 8, left: 6),
Transform.flip( onTap: () {
flipX: true, GoRouter.of(context).pushNamed(
child: const Icon(Symbols.comment, size: 20), 'postDetail',
), pathParameters: {'slug': ele.id.toString()},
const Gap(4), extra: ele,
Text('postCommentsDetailed'.plural(widget.data.metric.replyCount)), );
if (widget.data.metric.replyCount > 0 && !_isAllLoaded) },
SizedBox( ),
width: 20, ],
height: 20, ),
child: IconButton( ).padding(vertical: 8),
visualDensity: VisualDensity(horizontal: -4, vertical: -4), Row(
constraints: const BoxConstraints(), children: [
padding: EdgeInsets.zero, Transform.flip(
icon: const Icon(Symbols.expand_more, size: 18), flipX: true,
onPressed: _isBusy child: const Icon(Symbols.comment, size: 20),
? null ),
: () { const Gap(4),
_fetchComments(); Text(
}, 'postCommentsDetailed'.plural(widget.data.metric.replyCount)),
), if (widget.data.metric.replyCount > 0 && !_isAllLoaded)
).padding(left: 8), SizedBox(
], width: 20,
).opacity(0.75).padding(horizontal: widget.showAvatar ? 4 : 0), height: 20,
], child: IconButton(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
icon: const Icon(Symbols.expand_more, size: 18),
onPressed: _isBusy
? null
: () {
_fetchComments();
},
),
).padding(left: 8),
],
).opacity(0.75).padding(horizontal: widget.showAvatar ? 4 : 0),
],
),
); );
} }
} }

View File

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

View File

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

View File

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

View File

@ -190,6 +190,8 @@ PODS:
- sqlite3/common - sqlite3/common
- sqlite3/fts5 (3.49.1): - sqlite3/fts5 (3.49.1):
- sqlite3/common - sqlite3/common
- sqlite3/math (3.49.1):
- sqlite3/common
- sqlite3/perf-threadsafe (3.49.1): - sqlite3/perf-threadsafe (3.49.1):
- sqlite3/common - sqlite3/common
- sqlite3/rtree (3.49.1): - sqlite3/rtree (3.49.1):
@ -200,6 +202,7 @@ PODS:
- sqlite3 (~> 3.49.1) - sqlite3 (~> 3.49.1)
- sqlite3/dbstatvtab - sqlite3/dbstatvtab
- sqlite3/fts5 - sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe - sqlite3/perf-threadsafe
- sqlite3/rtree - sqlite3/rtree
- tray_manager (0.0.1): - tray_manager (0.0.1):
@ -390,7 +393,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db sqlite3_flutter_libs: 487032b9008b28de37c72a3aa66849ef3745f3e6
tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 tray_manager: 9064e219c56d75c476e46b9a21182087930baf90
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
video_compress: c896234f100791b5fef7f049afa38f6d2ef7b42f video_compress: c896234f100791b5fef7f049afa38f6d2ef7b42f

View File

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

View File

@ -213,10 +213,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: chalkdart name: chalkdart
sha256: e7cfcc9a9d9546843304c1ff87fe0696c7eb82ee70e6df63f555f321b15a40d8 sha256: "82dfa884e3cf97641eb0742a3b9ffd41490666b9ece548b2e32cbfefe540bf86"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.3" version: "2.4.0"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -517,10 +517,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: fast_rsa name: fast_rsa
sha256: "205a36c0412b9fabebf3e18ccb5221d819cc28cfb3da988c0bf7b646368d0270" sha256: a26ad752734dc52fd51abd55248df868d7480e68d8cc8dd12413b0124bba0a7e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.8.0" version: "3.8.1"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@ -541,10 +541,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: ee11ce89f8937c39181bc88d57a455972f7545b86150d8f287d0d9cf95bcdf0a sha256: "8d938fd5c11dc81bf1acd4f7f0486c683fe9e79a0b13419e27730f9ce4d8a25b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.1.0" version: "9.2.1"
file_saver: file_saver:
dependency: "direct main" dependency: "direct main"
description: description:
@ -746,10 +746,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_expandable_fab name: flutter_expandable_fab
sha256: "85275279d19faf4fbe5639dc1f139b4555b150e079d056f085601a45688af12c" sha256: b14caf78720a48f650e6e1a38d724e33b1f5348d646fa1c266570c31a7f87ef3
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.4.0"
flutter_highlight: flutter_highlight:
dependency: "direct main" dependency: "direct main"
description: description:
@ -953,10 +953,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: freezed name: freezed
sha256: a6274c34d61b3d68082f2b0e9a641a3ec197e525d269f35b82f62d5b2c6d9f75 sha256: "7ed2ddaa47524976d5f2aa91432a79da36a76969edd84170777ac5bea82d797c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.4"
freezed_annotation: freezed_annotation:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1177,10 +1177,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_linux name: image_picker_linux
sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.1+1" version: "0.2.1+2"
image_picker_macos: image_picker_macos:
dependency: transitive dependency: transitive
description: description:
@ -1385,10 +1385,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: material_symbols_icons name: material_symbols_icons
sha256: db745002d0323c32097f5bc23711c02b0fb36ef616011a38c8c1798d5684d368 sha256: "99d5b0e7c65232dfe1247e0ac67eeeee2cab9da2d860748fc495d34f5e9e6397"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2810.0" version: "4.2811.0"
media_kit: media_kit:
dependency: "direct main" dependency: "direct main"
description: description:
@ -2102,10 +2102,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqlite3_flutter_libs name: sqlite3_flutter_libs
sha256: "7adb4cc96dc08648a5eb1d80a7619070796ca6db03901ff2b6dcb15ee30468f3" sha256: "1a96b59227828d9eb1463191d684b37a27d66ee5ed7597fcf42eee6452c88a14"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.31" version: "0.5.32"
sqlparser: sqlparser:
dependency: transitive dependency: transitive
description: description:
@ -2174,34 +2174,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: talker name: talker
sha256: a074e0a2c1db1b09c1856050c5284a14ff64faea003403409871ab8335738d08 sha256: "45abef5b92f9b9bd42c3f20133ad4b20ab12e1da2aa206fc0a40ea874bed7c5d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.7.0" version: "4.7.1"
talker_dio_logger: talker_dio_logger:
dependency: "direct main" dependency: "direct main"
description: description:
name: talker_dio_logger name: talker_dio_logger
sha256: "6d713d26bdf90c7440222758a4e02ebfbdb3b323cdbdb50b78082db720f69427" sha256: "52c1b554cccedec6073637a6d4f6a3e267dd4451c1545fe57e1b26897a560ccb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.7.0" version: "4.7.1"
talker_flutter: talker_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: talker_flutter name: talker_flutter
sha256: "25a9faa98a0dcbab8d1ec366f19b3e91d422ede523461ec6e26e198b280cb98d" sha256: "77458ca11638dfefb651e898a26101ee54e60dc0b168ad7481a05b1c97ce2680"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.7.0" version: "4.7.1"
talker_logger: talker_logger:
dependency: transitive dependency: transitive
description: description:
name: talker_logger name: talker_logger
sha256: "0a1a295a9d036a3416e89c7155eaaca9ca6e3e0abb5401a40597f98b9cfc8fe6" sha256: ed9b20b8c09efff9f6b7c63fc6630ee2f84aa92661ae09e5ba04e77272bf2ad2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.7.0" version: "4.7.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -2238,10 +2238,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: tray_manager name: tray_manager
sha256: "80be6c508159a6f3c57983de795209ac13453e9832fd574143b06dceee188ed2" sha256: c2da0f0f1ddb455e721cf68d05d1281fec75cf5df0a1d3cb67b6ca0bdfd5709d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.2" version: "0.4.0"
tuple: tuple:
dependency: transitive dependency: transitive
description: description:

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2.4.2+81 version: 2.4.2+84
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@ -59,7 +59,7 @@ dependencies:
relative_time: ^5.0.0 relative_time: ^5.0.0
image_picker: ^1.1.2 image_picker: ^1.1.2
cross_file: ^0.3.4+2 cross_file: ^0.3.4+2
file_picker: ^9.0.0 # pinned due to compile failed on android, https://github.com/miguelpruivo/flutter_file_picker/issues/1643 file_picker: ^9.2.1
croppy: ^1.3.1 croppy: ^1.3.1
flutter_expandable_fab: ^2.3.0 flutter_expandable_fab: ^2.3.0
dropdown_button2: ^2.3.9 dropdown_button2: ^2.3.9
@ -103,7 +103,7 @@ dependencies:
flutter_svg: ^2.0.16 flutter_svg: ^2.0.16
home_widget: ^0.7.0 home_widget: ^0.7.0
receive_sharing_intent: ^1.8.1 receive_sharing_intent: ^1.8.1
workmanager: workmanager: # use git due to: https://github.com/fluttercommunity/flutter_workmanager/issues/588#issuecomment-2660871645
git: git:
url: https://github.com/fluttercommunity/flutter_workmanager.git url: https://github.com/fluttercommunity/flutter_workmanager.git
path: workmanager path: workmanager
@ -120,7 +120,7 @@ dependencies:
flutter_inappwebview: ^6.1.5 flutter_inappwebview: ^6.1.5
html: ^0.15.5 html: ^0.15.5
xml: ^6.5.0 xml: ^6.5.0
tray_manager: ^0.3.2 tray_manager: ^0.4.0
hotkey_manager: ^0.2.3 hotkey_manager: ^0.2.3
image_picker_android: ^0.8.12+20 image_picker_android: ^0.8.12+20
cached_network_image_platform_interface: ^4.1.1 cached_network_image_platform_interface: ^4.1.1
@ -179,6 +179,7 @@ flutter:
- assets/icon/icon-light-radius.png - assets/icon/icon-light-radius.png
- assets/icon/tray-icon.ico - assets/icon/tray-icon.ico
- assets/icon/tray-icon.png - assets/icon/tray-icon.png
- assets/icon/kanban-1st.jpg
- assets/translations/ - assets/translations/
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see

View File

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

File diff suppressed because it is too large Load Diff

2
web/_redirects Normal file
View File

@ -0,0 +1,2 @@
/assets/assets/translations/en.json /assets/assets/translations/en-US.json 301
/assets/assets/translations/zh.json /assets/assets/translations/zh-CN.json 301

View File

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