Compare commits

..

111 Commits

Author SHA1 Message Date
537e49f1a4 Add typing ahead 2025-10-12 16:15:04 +08:00
75bbd4df71 Desktop page actions on title bar 2025-10-12 16:14:54 +08:00
6ef4580d93 💄 Optimize developer hub 2025-10-12 16:14:44 +08:00
6ffd498761 ♻️ Refactored developer hub 2025-10-12 14:28:18 +08:00
27157e7cc1 ♻️ Refactored web feed, poll 2025-10-12 12:19:50 +08:00
bbb07d574a ♻️ Refactored cloud file picker, stickers 2025-10-12 02:21:39 +08:00
c660a419e2 ♻️ Optimize the creator hub 2025-10-12 00:06:48 +08:00
c3f61467c8 👔 Ignore language, message always translatable 2025-10-11 22:59:34 +08:00
9bc47df452 ⬆️ Upgrade flutter 2025-10-11 22:18:08 +08:00
9ef8ca4d45 🐛 Fix wrong import lead to failed compile on web 2025-10-11 00:52:52 +08:00
b55cbd08d1 ♻️ Optimized the experience of cloud files 2025-10-11 00:49:14 +08:00
8c6bd0feaa 💄 Optimize cloud file text file 2025-10-10 23:58:33 +08:00
7dd4b20628 🐛 Fix some bugs 2025-10-10 23:00:13 +08:00
fec0cb7640 ⬆️ Upgrade dependecies 2025-10-10 22:44:51 +08:00
75deb04a2b ♻️ Better resend message 2025-10-10 22:34:34 +08:00
7c7ed21a96 💄 Optimize message delete when failed to send 2025-10-10 21:50:52 +08:00
a201f20793 💄 Optimize message indicator 2025-10-10 21:38:53 +08:00
598c51bc1a Chat input full featured upload 2025-10-10 20:54:37 +08:00
e1ea61c5f1 🐛 Fix the compose 2025-10-10 01:04:21 +08:00
ac424bde36 💄 Optimize the ability to search 2025-10-10 00:50:17 +08:00
b43b70df3f 💄 Optimize the search message a step further 2025-10-10 00:43:49 +08:00
4321aa621a 💄 Optimize search message design 2025-10-10 00:38:43 +08:00
d5d275fb43 🐛 Fix the message search need tap twice 2025-10-10 00:23:11 +08:00
6bb3307144 🐛 Fix message jumps in search 2025-10-10 00:19:22 +08:00
391604d4a2 🐛 Fix bugs 2025-10-09 01:15:27 +08:00
1d9361c12f 💄 Bug fixes search messages and optimization 2025-10-09 01:11:14 +08:00
a129b9cdd0 💄 Optimize search message tile 2025-10-09 00:41:17 +08:00
3bf815ac61 💄 Optimize message actions 2025-10-09 00:38:45 +08:00
77bae4d6fd 💄 Optimize message action area 2025-10-09 00:00:25 +08:00
0a301c4c9b 💄 Confirm when deleting message 2025-10-08 23:54:59 +08:00
27b390a51c 💄 Hovering actions 2025-10-08 23:48:06 +08:00
018386d14e 💄 Optimize chat message cursor 2025-10-08 22:42:43 +08:00
3825d7c6c7 💄 Optimize display of certain type of message item 2025-10-08 22:33:56 +08:00
bf930291e4 ♻️ Update the embed rendering 2025-10-08 21:58:24 +08:00
a8c4988790 🐛 Fix bugs 2025-10-07 03:05:53 +08:00
28dd204b1a 🐛 Fix bugs 2025-10-06 22:41:23 +08:00
3cbc1a59a7 🐛 Fix some post related bugs 2025-10-06 12:51:28 +08:00
277e9ae3d1 💄 Optimize compose design 2025-10-06 12:28:17 +08:00
27b3ca25b7 💄 Optimize compose 2025-10-06 12:24:09 +08:00
f871cd3b62 ♻️ Refactor post 2025-10-06 11:55:53 +08:00
a8a59ee30c 💄 Optimize compose card 2025-10-05 18:34:54 +08:00
2cd1416a13 Capture screen audio 2025-10-05 12:34:43 +08:00
6be7dfbc61 🐛 Bug fixes 2025-10-05 00:14:08 +08:00
1abbd85614 Fully customizable color scheme 2025-10-04 22:12:39 +08:00
31ac5ad07c 🐛 FIx color extraction 2025-10-04 21:48:56 +08:00
ae2ba495e9 Card opacity and refactored theme 2025-10-04 21:46:32 +08:00
637aa44548 Transfer 2025-10-04 21:22:37 +08:00
44dbfc36d9 💄 Optimized wallet screen 2025-10-04 20:38:42 +08:00
5dbe7371cb Transaction details 2025-10-04 20:33:34 +08:00
6c91093198 💄 Optimized wallet 2025-10-04 15:48:16 +08:00
3f640b7898 Wallet funds 2025-10-04 01:17:09 +08:00
7db164fda6 🐛 Tries to fix 2025-10-03 22:06:05 +08:00
6df1d96cc9 🌐 More localized stellar program 2025-10-03 21:17:47 +08:00
122a796f8c 🌐 Localized gift subscription 2025-10-03 21:11:11 +08:00
fbc7812a16 💄 Optimize gift subscription 2025-10-03 20:45:08 +08:00
0b1a23e81a 💄 Optimize publisher first time UX
♻️ Split up the forms and list screens
💄 Use dropdown forms fields instead of selection
2025-10-03 15:42:56 +08:00
c87e6cfe07 ♻️ Refactored the stellar program tab 2025-10-02 13:03:18 +08:00
53d51b8a0e Plain text rendering 2025-10-02 02:15:48 +08:00
337ae39e08 PDF rendering 2025-10-02 02:10:45 +08:00
8fe3a664a6 ♻️ Better file upload 2025-10-02 01:13:41 +08:00
3bfc0b8181 ♻️ Refactor the bottom nav display 2025-10-01 16:35:41 +08:00
ac2951479b App links 2025-10-01 14:55:04 +08:00
2bfd13d843 🐛 Fixes bugs and optimization
 Add app links
2025-10-01 12:41:48 +08:00
28db6f9f01 Better windows notification 2025-10-01 12:28:23 +08:00
a4f7b8415d 💄 Optimize the draft manager clear hint 2025-09-30 00:14:23 +08:00
2255d3d591 ♻️ Better draft, post saving and auto restore hint 2025-09-30 00:04:51 +08:00
97792ae734 💄 Localized language picker 2025-09-29 23:10:21 +08:00
a5d13250cc 🍱 Update translations 2025-09-29 23:10:10 +08:00
de9e235d0c ♻️ Dialog based editor for normal post 2025-09-29 01:16:32 +08:00
56fb5451cd 💄 Optimize explore compose region 2025-09-29 00:34:30 +08:00
870de961f5 💄 Optimize border radius 2025-09-28 23:09:59 +08:00
22bf6d1c33 💄 Redesign explore 2025-09-28 23:07:22 +08:00
5b62f89531 🐛 Fix web file upload 2025-09-28 01:53:55 +08:00
b1326d8f04 🐛 Dozens of bug fixes 2025-09-28 01:39:07 +08:00
fffca4a78c ♻️ Refactor logger system 2025-09-28 00:39:17 +08:00
42bd7f97cb Add talker and upgrade deps 2025-09-27 23:40:08 +08:00
6377856ae0 💄 Optimize online indicator 2025-09-27 23:01:42 +08:00
0f1c52b9e3 🐛 Fix subscribe 2025-09-27 23:00:04 +08:00
6ed6f60fbc 💄 New chat UI 2025-09-27 22:59:16 +08:00
e65a414065 🐛 Fixes and improvements in syncing 2025-09-27 22:47:31 +08:00
214d5c4a53 Online indicator in chat 2025-09-27 22:17:29 +08:00
fe33931304 Split force update and check for update 2025-09-27 21:49:13 +08:00
113309257e Proper windows update 2025-09-27 21:33:12 +08:00
b95a8b2ed2 Merge branch 'v3' of https://git.solsynth.dev/SolarNetwork/App into v3 2025-09-27 21:25:10 +08:00
LittleSheep
e922971a5e 🔀 Merge pull request #180 from liang-work/appchangetest
[Feature] Able to change theme mode not following System Theme
2025-09-27 21:17:39 +08:00
9d5b71bead 💄 Optimize chat indicator style 2025-09-27 21:17:14 +08:00
890efa2efb upload i18n update file. 2025-09-27 20:55:04 +08:00
674097e425 git commit
Upload code that can run
2025-09-27 20:26:37 +08:00
3379dcb7f3 Dynamic chat online counter basis 2025-09-27 19:25:24 +08:00
eb5a849e1f 💄 Optimize title bar for windows and linux 2025-09-27 18:45:58 +08:00
4981a23e8e Chat summary realtime updates 2025-09-27 17:07:19 +08:00
c64d4bacb6 📝 Update CODE_OF_CONDUCT 2025-09-27 17:05:21 +08:00
838d18013b 🐛 Fix message deletion 2025-09-27 16:54:23 +08:00
3f7902e463 🐛 Fix post detail award button 2025-09-27 16:34:50 +08:00
54560ad5d8 🐛 Fix some bugs in attachment upload sheet 2025-09-27 15:51:26 +08:00
0c729db639 🐛 Fix native window 2025-09-27 15:33:42 +08:00
1fbaac8d88 💄 Optimize chat input a step further 2025-09-27 15:31:57 +08:00
b9dc724f0b 🐛 Fix chat newline on desktop 2025-09-27 00:22:43 +08:00
a2cc55696f Transparent window on desktop 2025-09-27 00:04:04 +08:00
e79f857feb ♻️ Replace bitsdojo_window with window_manager 2025-09-26 23:26:40 +08:00
affba29c04 🐛 Fix IRC style display the message time wrong 2025-09-26 22:30:46 +08:00
756746b144 🍱 Update translation keys 2025-09-24 23:31:47 +08:00
28b6eade48 🚀 Launch 3.2.0+134 2025-09-24 22:37:16 +08:00
1de7ef8c96 🐛 Fix bugs 2025-09-24 22:34:05 +08:00
67eac5dcf5 Optimized rpc 2025-09-24 22:14:40 +08:00
7a44bfa075 ⬆️ Upgrade packages 2025-09-24 21:29:21 +08:00
1c2f25a152 💄 Optimize leveling page 2025-09-24 21:21:51 +08:00
be26ea280e 🌐 Make file info localizable 2025-09-24 21:20:00 +08:00
b4996d069f 🐛 Fix bugs 2025-09-24 21:03:53 +08:00
bf4892b34d 🐛 Fix bugs 2025-09-24 20:52:56 +08:00
5f84751fd5 🐛 Fix file upload 2025-09-24 20:29:30 +08:00
185 changed files with 26130 additions and 9670 deletions

View File

@@ -14,13 +14,13 @@ The backend of the Solar Network is written in Go and is a microservices app. Th
## Commit Messages ## Commit Messages
We're using the gitmoji to clarify the reason and changes of the commit. To learn more about gitmoji, visit https://gitmoji.dev We're using the gitmoji to clarify the reason and changes of the commit. To learn more about gitmoji, visit <https://gitmoji.dev>
All the commit message should follow `:[gitmoji]: <commit message>` syntax All the commit message should follow `:[gitmoji]: <commit message>` syntax
## Translations & Localization ## Translations & Localization
We're not accepting translation and localization improvements, or fixes on the GitHub or Solsynth Git Repository. If you want to contribute to those, please head to our Crowdin project: https://crowdin.com/project/solian We're not accepting translation and localization improvements, or fixes on the GitHub or Solsynth Git Repository. If you want to contribute to those, please head to our Crowdin project: <https://crowdin.com/project/solian>
## New Features ## New Features
@@ -30,7 +30,12 @@ To contribute new features, please create an issue or mention the feature you wa
Read the error message, check for the update (including pre-releases), and wiki before creating an issue. At the same time, be respectful and don't argue with our developers and contributors in the development chat or GitHub issue. Otherwise your issue may got deleted and your Solar Network Account may got a strike. Read the error message, check for the update (including pre-releases), and wiki before creating an issue. At the same time, be respectful and don't argue with our developers and contributors in the development chat or GitHub issue. Otherwise your issue may got deleted and your Solar Network Account may got a strike.
## Styles of Code
Before you create a Pull Request, make sure your code has pass the `flutter analyze` check, if there is any notes, fix as much as possible, if there is no way to fix, do ignore.
When the code contains comments, use English. We do not any other language of comments existing in the codebase. It might confuse future contributors, cause the code hard to understand and maintaiance.
----------- -----------
We appreciate every single commit you contributed. Let's work together and create a better Solar Network! We appreciate every single commit you contributed. Let's work together and create a better Solar Network!

View File

@@ -51,6 +51,12 @@
<data android:scheme="http" android:host="solian.app" /> <data android:scheme="http" android:host="solian.app" />
<data android:scheme="https" /> <data android:scheme="https" />
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="solian" />
</intent-filter>
<!-- Share Intent Filters --> <!-- Share Intent Filters -->
<intent-filter> <intent-filter>

View File

@@ -30,8 +30,6 @@
"fieldEmailAddressMustBeValid": "The email address must be valid.", "fieldEmailAddressMustBeValid": "The email address must be valid.",
"logout": "Logout", "logout": "Logout",
"updateYourProfile": "Profile Settings", "updateYourProfile": "Profile Settings",
"settingsDefaultPool": "Default file pool",
"settingsDefaultPoolHelper": "Select the default storage pool for file uploads",
"accountBasicInfo": "Basic Info", "accountBasicInfo": "Basic Info",
"accountProfile": "Your Profile", "accountProfile": "Your Profile",
"saveChanges": "Save Changes", "saveChanges": "Save Changes",
@@ -48,31 +46,10 @@
"delete": "Delete", "delete": "Delete",
"deletePublisher": "Delete Publisher", "deletePublisher": "Delete Publisher",
"deletePublisherHint": "Are you sure to delete this publisher? This will also deleted all the post and collections under this publisher.", "deletePublisherHint": "Are you sure to delete this publisher? This will also deleted all the post and collections under this publisher.",
"somethingWentWrong": "Something went wrong",
"deletePost": "Delete Post", "deletePost": "Delete Post",
"safetyReport": "Report",
"safetyReportTitle": "Safety Report",
"safetyReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.",
"safetyReportType": "Report Type",
"safetyReportReason": "Additional Details",
"safetyReportReasonHint": "Please provide more details about the issue...",
"safetyReportSubmit": "Submit Report",
"safetyReportSubmitting": "Submitting...",
"safetyReportSuccess": "Report submitted successfully. Thank you for helping keep our community safe.",
"safetyReportError": "Failed to submit report. Please try again.",
"safetyReportReasonRequired": "Please provide details about the issue",
"safetyReportTypeSpam": "Spam or Misleading",
"safetyReportTypeHarassment": "Harassment or Abuse",
"safetyReportTypeHateSpeech": "Hate Speech",
"safetyReportTypeViolence": "Violence or Threats",
"safetyReportTypeAdultContent": "Adult Content",
"safetyReportTypeIntellectualProperty": "Intellectual Property Violation",
"safetyReportTypeOther": "Other",
"safetyReportTypeInappropriate": "Inappropriate Content",
"safetyReportTypeCopyright": "Copyright Violation",
"safetyReportSuccessTitle": "Report Submitted",
"safetyReportErrorTitle": "Error",
"deletePostHint": "Are you sure to delete this post?", "deletePostHint": "Are you sure to delete this post?",
"deleteMessage": "Delete Message",
"deleteMessageConfirmation": "Are you sure you want to delete this message?",
"copyLink": "Copy Link", "copyLink": "Copy Link",
"postCreateAccountTitle": "Thanks for joining!", "postCreateAccountTitle": "Thanks for joining!",
"postCreateAccountNext": "What's next?", "postCreateAccountNext": "What's next?",
@@ -100,8 +77,6 @@
"explore": "Explore", "explore": "Explore",
"exploreFilterSubscriptions": "Subscriptions", "exploreFilterSubscriptions": "Subscriptions",
"exploreFilterFriends": "Friends", "exploreFilterFriends": "Friends",
"discover": "Discover",
"joinRealm": "Join Realm",
"account": "Account", "account": "Account",
"name": "Name", "name": "Name",
"slug": "Slug", "slug": "Slug",
@@ -135,25 +110,6 @@
"other": "{} replies" "other": "{} replies"
}, },
"forward": "Forward", "forward": "Forward",
"award": "Award",
"awardPost": "Award Post",
"awardMessage": "Message",
"awardMessageHint": "Enter your award message...",
"awardAttitude": "Attitude",
"awardAttitudePositive": "Positive",
"awardAttitudeNegative": "Negative",
"awardAmount": "Amount",
"awardAmountHint": "Enter amount...",
"awardAmountRequired": "Amount is required",
"awardAmountInvalid": "Please enter a valid amount",
"awardMessageTooLong": "Message is too long (max 4096 characters)",
"awardSuccess": "Award sent successfully!",
"awardSubmit": "Award",
"awardPostPreview": "Post Preview",
"awardNoContent": "No content available",
"awardByPublisher": "By {}",
"awardBenefits": "Award Benefits",
"awardBenefitsDescription": "Awarding this post increases its value and visibility. Higher valued posts have a better chance of being featured and highlighted in the community.",
"repliedTo": "Replied to", "repliedTo": "Replied to",
"forwarded": "Forwarded", "forwarded": "Forwarded",
"hasAttachments": { "hasAttachments": {
@@ -165,17 +121,9 @@
"other": "{} attachments" "other": "{} attachments"
}, },
"edited": "Edited", "edited": "Edited",
"editedAt": "Edited at {}",
"addVideo": "Add video", "addVideo": "Add video",
"addPhoto": "Add photo", "addPhoto": "Add photo",
"addAudio": "Add audio",
"addFile": "Add file", "addFile": "Add file",
"uploadFile": "Upload File",
"recordAudio": "Record Audio",
"linkAttachment": "Link Attachment",
"fileIdCannotBeEmpty": "File ID cannot be empty",
"fileIdLinkHint": "Haven't upload to the Solar Network? Tap here to open Solar Network Drive to customize your uploads.",
"failedToFetchFile": "Failed to fetch file: {}",
"createDirectMessage": "Send new DM", "createDirectMessage": "Send new DM",
"gotoDirectMessage": "Go to DM", "gotoDirectMessage": "Go to DM",
"react": "React", "react": "React",
@@ -217,7 +165,6 @@
"checkInResultLevel2": "A Normal Day", "checkInResultLevel2": "A Normal Day",
"checkInResultLevel3": "Good Luck", "checkInResultLevel3": "Good Luck",
"checkInResultLevel4": "Best Luck", "checkInResultLevel4": "Best Luck",
"checkInResultLevel5": "Happy Birthday 🥳",
"checkInActivityTitle": "{} checked in on {} and got a {}", "checkInActivityTitle": "{} checked in on {} and got a {}",
"eventCalander": "Event Calander", "eventCalander": "Event Calander",
"eventCalanderEmpty": "No events on that day.", "eventCalanderEmpty": "No events on that day.",
@@ -251,8 +198,6 @@
"settings": "Settings", "settings": "Settings",
"language": "Language", "language": "Language",
"accountLanguageHint": "This language will be used for email and push notifications.", "accountLanguageHint": "This language will be used for email and push notifications.",
"region": "Region",
"accountRegionHint": "This region will be used for content delivery and localization.",
"settingsDisplayLanguage": "Display Language", "settingsDisplayLanguage": "Display Language",
"languageFollowSystem": "Follow System", "languageFollowSystem": "Follow System",
"postsCreatedCount": "Posts", "postsCreatedCount": "Posts",
@@ -308,11 +253,16 @@
"translatorBadgeName": "Translator", "translatorBadgeName": "Translator",
"translatorBadgeDescription": "Helping translate Solar Network into different languages", "translatorBadgeDescription": "Helping translate Solar Network into different languages",
"wallet": "Wallet", "wallet": "Wallet",
"walletStats": "Wallet Statistics",
"totalTransactions": "Total Transactions",
"totalOrders": "Total Orders",
"totalIncome": "Total Income",
"totalOutgoing": "Total Outgoing",
"netBalance": "Net Balance",
"walletCurrencyPoints": "New Solar Points", "walletCurrencyPoints": "New Solar Points",
"walletCurrencyShortPoints": "NSP", "walletCurrencyShortPoints": "NSP",
"walletCurrencyGolds": "The Solar Dollars", "walletCurrencyGolds": "The Solar Dollars",
"walletCurrencyShortGolds": "NSD", "walletCurrencyShortGolds": "NSD",
"retry": "Retry",
"creatorHubUnselectedHint": "Pick / create a publisher to get started.", "creatorHubUnselectedHint": "Pick / create a publisher to get started.",
"relationships": "Relationships", "relationships": "Relationships",
"addFriend": "Send a Friend Request", "addFriend": "Send a Friend Request",
@@ -336,25 +286,11 @@
"levelingProgress": "Leveling Progress", "levelingProgress": "Leveling Progress",
"levelingProgressExperience": "{} EXP", "levelingProgressExperience": "{} EXP",
"levelingProgressLevel": "Level {}", "levelingProgressLevel": "Level {}",
"levelingStage1": "Novice",
"levelingStage2": "Apprentice",
"levelingStage3": "Journeyman",
"levelingStage4": "Adept",
"levelingStage5": "Expert",
"levelingStage6": "Master",
"levelingStage7": "Grandmaster",
"levelingStage8": "Legend",
"levelingStage9": "Myth",
"levelingStage10": "Immortal",
"levelingStage11": "Divine",
"levelingStage12": "Transcendent",
"fileUploadingProgress": "Uploading file #{}: {}%", "fileUploadingProgress": "Uploading file #{}: {}%",
"removeChatMember": "Remove Chat Room Member", "removeChatMember": "Remove Chat Room Member",
"removeChatMemberHint": "Are you sure to remove this member from the room?", "removeChatMemberHint": "Are you sure to remove this member from the room?",
"removeRealmMember": "Remove Realm Member", "removeRealmMember": "Remove Realm Member",
"removeRealmMemberHint": "Are you sure to remove this member from the realm?", "removeRealmMemberHint": "Are you sure to remove this member from the realm?",
"removePublisherMember": "Remove Publisher Member",
"removePublisherMemberHint": "Are you sure to remove this member from the publisher?",
"memberRole": "Member Role", "memberRole": "Member Role",
"memberRoleHint": "Greater number has higher permission.", "memberRoleHint": "Greater number has higher permission.",
"memberRoleEdit": "Edit role for @{}", "memberRoleEdit": "Edit role for @{}",
@@ -371,14 +307,14 @@
"walletCreate": "Create a Wallet", "walletCreate": "Create a Wallet",
"settingsServerUrl": "Server URL", "settingsServerUrl": "Server URL",
"settingsApplied": "The settings has been applied.", "settingsApplied": "The settings has been applied.",
"settingsCustomFontsHelper": "Use comma to seprate.",
"notifications": "Notifications", "notifications": "Notifications",
"posts": "Posts", "posts": "Posts",
"settingsBackgroundImage": "Background Image", "settingsBackgroundImage": "Background Image",
"settingsBackgroundImageEnable": "Show Background Image",
"settingsBackgroundImageClear": "Clear Background Image", "settingsBackgroundImageClear": "Clear Background Image",
"settingsBackgroundGenerateColor": "Generate color scheme from Bacground Image", "settingsBackgroundGenerateColor": "Generate color scheme from Bacground Image",
"messageNone": "No content to display", "messageNone": "No content to display",
"messageUpdateLinks": "Server generated links previews",
"messageUpdateEdited": "Edited a message",
"unreadMessages": { "unreadMessages": {
"one": "{} unread message", "one": "{} unread message",
"other": "{} unread messages" "other": "{} unread messages"
@@ -386,22 +322,19 @@
"chatBreakNone": "None", "chatBreakNone": "None",
"settingsRealmCompactView": "Compact Realm View", "settingsRealmCompactView": "Compact Realm View",
"settingsMixedFeed": "Mixed Feed", "settingsMixedFeed": "Mixed Feed",
"settingsDataSavingMode": "Data Saving Mode",
"dataSavingHint": "Data Saving Mode",
"settingsAutoTranslate": "Auto Translate", "settingsAutoTranslate": "Auto Translate",
"settingsHideBottomNav": "Hide Bottom Navigation", "settingsHideBottomNav": "Hide Bottom Navigation",
"settingsSoundEffects": "Sound Effects", "settingsSoundEffects": "Sound Effects",
"settingsAprilFoolFeatures": "April Fool Features", "settingsAprilFoolFeatures": "April Fool Features",
"settingsEnterToSend": "Enter to Send", "settingsEnterToSend": "Enter to Send",
"settingsTransparentAppBar": "Transparent App Bar", "settingsTransparentAppBar": "Transparent App Bar",
"settingsCardBackgroundOpacity": "Card Background Opacity",
"settingsCustomFonts": "Custom Fonts", "settingsCustomFonts": "Custom Fonts",
"settingsCustomFontsHint": "Custom fonts will be used for all text in the app. Make sure it is installed on your device.", "settingsCustomFontsHint": "Custom fonts will be used for all text in the app. Make sure it is installed on your device.",
"settingsColorScheme": "Color Scheme", "settingsColorScheme": "Color Scheme",
"postTitle": "Title", "postTitle": "Title",
"postDescription": "Description", "postDescription": "Description",
"call": "Call", "call": "Call",
"callLeave": "Leave",
"callEnd": "End this call",
"done": "Done", "done": "Done",
"loginResetPasswordSent": "Password reset link sent, please check your email inbox.", "loginResetPasswordSent": "Password reset link sent, please check your email inbox.",
"accountDeletion": "Delete Account", "accountDeletion": "Delete Account",
@@ -425,10 +358,6 @@
"postContent": "Content", "postContent": "Content",
"postSettings": "Settings", "postSettings": "Settings",
"postPublisherUnselected": "Publisher Unspecified", "postPublisherUnselected": "Publisher Unspecified",
"postType": "Post Type",
"postTypePost": "Post",
"articleAttachmentHint": "Attachments must be uploaded and inserted into the article body to be visible.",
"postVisibility": "Post Visibility",
"postVisibilityPublic": "Public", "postVisibilityPublic": "Public",
"postVisibilityFriends": "Friends Only", "postVisibilityFriends": "Friends Only",
"postVisibilityUnlisted": "Unlisted", "postVisibilityUnlisted": "Unlisted",
@@ -447,7 +376,6 @@
"authFactorSecretHint": "Create an secret for this factor.", "authFactorSecretHint": "Create an secret for this factor.",
"authFactorQrCodeScan": "Scan this QR code with your authenticator app to set up TOTP authentication", "authFactorQrCodeScan": "Scan this QR code with your authenticator app to set up TOTP authentication",
"authFactorNoQrCode": "No QR code available for this authentication factor", "authFactorNoQrCode": "No QR code available for this authentication factor",
"cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
"authFactorAdditional": "One more step", "authFactorAdditional": "One more step",
"authFactorHint": "Contact method hint", "authFactorHint": "Contact method hint",
@@ -462,18 +390,20 @@
"lastActiveAt": "Last active at {}", "lastActiveAt": "Last active at {}",
"authDeviceLogout": "Logout", "authDeviceLogout": "Logout",
"authDeviceLogoutHint": "Are you sure you want to logout this device? This will also disable the push notification to this device.", "authDeviceLogoutHint": "Are you sure you want to logout this device? This will also disable the push notification to this device.",
"authDeviceChallenges": "Device Usage",
"authDeviceHint": "Swipe left to edit label, swipe right to logout device.",
"typingHint": {
"one": "{} is typing...",
"other": "{} are typing..."
},
"authDeviceEditLabel": "Edit Label", "authDeviceEditLabel": "Edit Label",
"authDeviceLabelTitle": "Edit Device Label", "authDeviceLabelTitle": "Edit Device Label",
"authDeviceLabelHint": "Enter a name for this device", "authDeviceLabelHint": "Enter a name for this device",
"authDeviceSwipeEditHint": "Swipe left to edit label", "authDeviceSwipeEditHint": "Swipe left to edit label",
"authDeviceSwipeLogoutHint": "Swipe right to logout device", "authDeviceSwipeLogoutHint": "Swipe right to logout device",
"typingHint": {
"one": "{} is typing...",
"other": "{} are typing..."
},
"settingsAppearance": "Appearance", "settingsAppearance": "Appearance",
"settingsThemeMode": "Theme Mode",
"settingsThemeModeSystem": "System",
"settingsThemeModeLight": "Light",
"settingsThemeModeDark": "Dark",
"settingsServer": "Server", "settingsServer": "Server",
"settingsBehavior": "Behavior", "settingsBehavior": "Behavior",
"settingsDesktop": "Desktop", "settingsDesktop": "Desktop",
@@ -485,31 +415,7 @@
"settingsKeyboardShortcutSettings": "Settings", "settingsKeyboardShortcutSettings": "Settings",
"settingsKeyboardShortcutNewMessage": "New Message", "settingsKeyboardShortcutNewMessage": "New Message",
"settingsKeyboardShortcutCloseDialog": "Close Dialog", "settingsKeyboardShortcutCloseDialog": "Close Dialog",
"settingsMessageDisplayStyle": "Message Display Style",
"close": "Close", "close": "Close",
"drafts": "Drafts",
"noDrafts": "No drafts yet",
"searchDrafts": "Search drafts...",
"noSearchResults": "No search results",
"articleDrafts": "Article drafts",
"postDrafts": "Post drafts",
"saveDraft": "Save draft",
"draftSaved": "Draft saved",
"draftSaveFailed": "Failed to save draft",
"clearAllDrafts": "Clear All Drafts",
"clearAllDraftsConfirm": "Are you sure you want to delete all drafts? This action cannot be undone.",
"clearAll": "Clear All",
"untitled": "Untitled",
"noContent": "No content",
"justNow": "Just now",
"minutesAgo": "{} minutes ago",
"hoursAgo": "{} hours ago",
"daysAgo": "{} days ago",
"public": "Public",
"unlisted": "Unlisted",
"friends": "Friends",
"selected": "Selected",
"private": "Private",
"contactMethod": "Contact Method", "contactMethod": "Contact Method",
"contactMethodType": "Contact Type", "contactMethodType": "Contact Type",
"contactMethodTypeEmail": "Email", "contactMethodTypeEmail": "Email",
@@ -527,7 +433,6 @@
"contactMethodDelete": "Delete Contact", "contactMethodDelete": "Delete Contact",
"contactMethodNew": "New Contact Method", "contactMethodNew": "New Contact Method",
"contactMethodContentEmpty": "Contact content cannot be empty", "contactMethodContentEmpty": "Contact content cannot be empty",
"postContentEmpty": "Post content cannot be empty",
"contactMethodVerificationSent": "Verification code sent to your contact method", "contactMethodVerificationSent": "Verification code sent to your contact method",
"contactMethodVerificationNeeded": "The contact method is added, but not verified yet. You can verify it by tapping it and select verify.", "contactMethodVerificationNeeded": "The contact method is added, but not verified yet. You can verify it by tapping it and select verify.",
"accountContactMethod": "Contact Methods", "accountContactMethod": "Contact Methods",
@@ -537,10 +442,6 @@
"contactMethodSetPrimary": "Set as Primary", "contactMethodSetPrimary": "Set as Primary",
"contactMethodSetPrimaryHint": "Set this contact method as your primary contact method for account recovery and notifications", "contactMethodSetPrimaryHint": "Set this contact method as your primary contact method for account recovery and notifications",
"contactMethodDeleteHint": "Are you sure to delete this contact method? This action cannot be undone.", "contactMethodDeleteHint": "Are you sure to delete this contact method? This action cannot be undone.",
"contactMethodMakePublic": "Make Public",
"contactMethodMakePrivate": "Make Private",
"contactMethodPublic": "Public",
"contactMethodPrivate": "Private",
"chatNotifyLevel": "Notify Level", "chatNotifyLevel": "Notify Level",
"chatNotifyLevelDescription": "Decide how many notifications you will receive.", "chatNotifyLevelDescription": "Decide how many notifications you will receive.",
"chatNotifyLevelAll": "All", "chatNotifyLevelAll": "All",
@@ -584,6 +485,7 @@
"pinCode": "PIN Code", "pinCode": "PIN Code",
"biometric": "Biometric", "biometric": "Biometric",
"enterPinToConfirm": "Enter your 6-digit PIN to confirm payment", "enterPinToConfirm": "Enter your 6-digit PIN to confirm payment",
"enterPin": "Enter your PIN code",
"clearPin": "Clear PIN", "clearPin": "Clear PIN",
"useBiometricToConfirm": "Use biometric authentication to confirm payment", "useBiometricToConfirm": "Use biometric authentication to confirm payment",
"touchSensorToAuthenticate": "Touch the sensor to authenticate", "touchSensorToAuthenticate": "Touch the sensor to authenticate",
@@ -600,19 +502,26 @@
"paymentError": "Payment failed: {error}", "paymentError": "Payment failed: {error}",
"usePinInstead": "Use PIN Code", "usePinInstead": "Use PIN Code",
"levelProgress": "Level Progress", "levelProgress": "Level Progress",
"unlockedFeatures": "Unlocked Features",
"unlockedFeaturesDescription": "Features unlocked at your current level will be displayed here.",
"stellarMembership": "Stellar Membership", "stellarMembership": "Stellar Membership",
"upgradeYourPlan": "Upgrade Your Plan", "upgradeYourPlan": "Upgrade Your Plan",
"chooseYourPlan": "Choose Your Plan", "chooseYourPlan": "Choose Your Plan",
"currentMembership": "Current: {}", "currentMembership": "Current: {}",
"currentMembershipMember": "A member of Stellar Program · {}",
"membershipExpires": "Expires: {}", "membershipExpires": "Expires: {}",
"membershipTierStellar": "Stellar", "membershipTierStellar": "Stellar",
"membershipTierNova": "Nova", "membershipTierNova": "Nova",
"membershipTierSupernova": "Supernova", "membershipTierSupernova": "Supernova",
"membershipTierUnknown": "Unknown", "membershipTierUnknown": "Unknown",
"membershipPriceStellar": "1200 NSP per month, level 3+ required", "membershipFeatureBasic": "Basic features",
"membershipPriceNova": "2400 NSP per month, level 6+ required", "membershipFeaturePrioritySupport": "Priority support",
"membershipPriceSupernova": "3600 NSP per month, level 9+ required", "membershipFeatureAdFree": "Ad-free experience",
"membershipFeatureAllPrimary": "All Primary features",
"membershipFeatureAdvancedCustomization": "Advanced customization",
"membershipFeatureEarlyAccess": "Early access",
"membershipFeatureAllNova": "All Nova features",
"membershipFeatureExclusiveContent": "Exclusive content",
"membershipFeatureVipSupport": "VIP support",
"membershipCurrentBadge": "CURRENT", "membershipCurrentBadge": "CURRENT",
"restorePurchase": "Restore Purchase", "restorePurchase": "Restore Purchase",
"restorePurchaseDescription": "Enter your payment provider and order ID to restore your purchase.", "restorePurchaseDescription": "Enter your payment provider and order ID to restore your purchase.",
@@ -622,9 +531,56 @@
"enterOrderId": "Enter your order ID", "enterOrderId": "Enter your order ID",
"restore": "Restore", "restore": "Restore",
"keyboardShortcuts": "Keyboard Shortcuts", "keyboardShortcuts": "Keyboard Shortcuts",
"safetyReport": "Report",
"safetyReportTitle": "Safety Report",
"safetyReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.",
"safetyReportType": "Report Type",
"safetyReportReason": "Additional Details",
"safetyReportReasonHint": "Please provide more details about the issue...",
"safetyReportSubmit": "Submit Report",
"safetyReportSubmitting": "Submitting...",
"safetyReportSuccess": "Report submitted successfully. Thank you for helping keep our community safe.",
"safetyReportError": "Failed to submit report. Please try again.",
"safetyReportReasonRequired": "Please provide details about the issue",
"safetyReportTypeSpam": "Spam or Misleading",
"safetyReportTypeHarassment": "Harassment or Abuse",
"safetyReportTypeHateSpeech": "Hate Speech",
"safetyReportTypeViolence": "Violence or Threats",
"safetyReportTypeAdultContent": "Adult Content",
"safetyReportTypeIntellectualProperty": "Intellectual Property Violation",
"safetyReportTypeOther": "Other",
"safetyReportTypeInappropriate": "Inappropriate Content",
"safetyReportTypeCopyright": "Copyright Violation",
"safetyReportSuccessTitle": "Report Submitted",
"safetyReportErrorTitle": "Error",
"discover": "Discover",
"joinRealm": "Join Realm",
"removePublisherMember": "Remove Publisher Member",
"removePublisherMemberHint": "Are you sure to remove this member from the publisher?",
"drafts": "Drafts",
"noDrafts": "No drafts yet",
"articleDrafts": "Article drafts",
"postDrafts": "Post drafts",
"saveDraft": "Save draft",
"draftSaved": "Draft saved",
"draftSaveFailed": "Failed to save draft",
"clearAllDrafts": "Clear All Drafts",
"clearAllDraftsConfirm": "Are you sure you want to delete all drafts? This action cannot be undone.",
"clearAll": "Clear All",
"untitled": "Untitled",
"noContent": "No content",
"justNow": "Just now",
"minutesAgo": "{} minutes ago",
"hoursAgo": "{} hours ago",
"daysAgo": "{} days ago",
"public": "Public",
"unlisted": "Unlisted",
"friends": "Friends",
"selected": "Selected",
"private": "Private",
"postContentEmpty": "Post content cannot be empty",
"share": "Share", "share": "Share",
"sharePost": "Share Post", "sharePost": "Share Post",
"sharePostPhoto": "Share Post as Photo",
"quickActions": "Quick Actions", "quickActions": "Quick Actions",
"post": "Post", "post": "Post",
"copy": "Copy", "copy": "Copy",
@@ -650,8 +606,6 @@
"no": "No", "no": "No",
"yes": "Yes", "yes": "Yes",
"navigateToChat": "Navigate to Chat", "navigateToChat": "Navigate to Chat",
"wouldYouLikeToNavigateToChat": "Would You like to navigate to the chat?",
"abuseReports": "Abuse Reports",
"abuseReport": "Report", "abuseReport": "Report",
"abuseReportTitle": "Report Content", "abuseReportTitle": "Report Content",
"abuseReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.", "abuseReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.",
@@ -683,9 +637,6 @@
"chatJoin": "Join the Chat", "chatJoin": "Join the Chat",
"realmJoin": "Join the Realm", "realmJoin": "Join the Realm",
"realmJoinSuccess": "Successfully joined the realm.", "realmJoinSuccess": "Successfully joined the realm.",
"discoverRealms": "Realms",
"discoverPublishers": "Publishers",
"discoverShuffledPost": "Random Posts",
"search": "Search", "search": "Search",
"publisherMembers": "Collaborators", "publisherMembers": "Collaborators",
"developerHub": "Developer Hub", "developerHub": "Developer Hub",
@@ -694,18 +645,6 @@
"enrollDeveloperHint": "Enroll one of your publishers to become a developer.", "enrollDeveloperHint": "Enroll one of your publishers to become a developer.",
"noPublishersToEnroll": "You don't have any publishers that can be enrolled as a developer.", "noPublishersToEnroll": "You don't have any publishers that can be enrolled as a developer.",
"totalCustomApps": "Total Custom Apps", "totalCustomApps": "Total Custom Apps",
"projects": "Projects",
"noProjects": "No projects found.",
"deleteProject": "Delete Project",
"deleteProjectHint": "Are you sure you want to delete this project? This action cannot be undone.",
"createProject": "Create Project",
"editProject": "Edit Project",
"projectDetails": "Project Details",
"createBot": "Create Bot",
"bots": "Bots",
"noBots": "No bots yet.",
"deleteBotHint": "Are you sure you want to delete this bot? This action cannot be undone.",
"deleteBot": "Delete Bot",
"customApps": "Custom Apps", "customApps": "Custom Apps",
"noCustomApps": "No custom apps yet.", "noCustomApps": "No custom apps yet.",
"createCustomApp": "Create Custom App", "createCustomApp": "Create Custom App",
@@ -743,9 +682,28 @@
"publisherFeatureDevelopDescription": "Unlock development abilities for your publisher, including custom apps, API keys, and more.", "publisherFeatureDevelopDescription": "Unlock development abilities for your publisher, including custom apps, API keys, and more.",
"publisherFeatureDevelopHint": "Currently, this feature is under active development, you need send a request to unlock this feature.", "publisherFeatureDevelopHint": "Currently, this feature is under active development, you need send a request to unlock this feature.",
"learnMore": "Learn More", "learnMore": "Learn More",
"discoverWebArticles": "Web Feed Articles",
"webArticlesStand": "Article Stand", "webArticlesStand": "Article Stand",
"about": "About", "about": "About",
"somethingWentWrong": "Something went wrong",
"editedAt": "Edited at {}",
"addAudio": "Add audio",
"recordAudio": "Record Audio",
"linkAttachment": "Link Attachment",
"fileIdCannotBeEmpty": "File ID cannot be empty",
"fileIdLinkHint": "Haven't upload to the Solar Network? Tap here to open Solar Network Drive to customize your uploads.",
"failedToFetchFile": "Failed to fetch file: {}",
"callLeave": "Leave",
"callEnd": "End this call",
"postType": "Post Type",
"articleAttachmentHint": "Attachments must be uploaded and inserted into the article body to be visible.",
"postVisibility": "Post Visibility",
"currentMembershipMember": "A member of Stellar Program · {}",
"membershipPriceStellar": "1200 NSP per month, level 3+ required",
"membershipPriceNova": "2400 NSP per month, level 6+ required",
"membershipPriceSupernova": "3600 NSP per month, level 9+ required",
"sharePostPhoto": "Share Post as Photo",
"wouldYouLikeToNavigateToChat": "Would You like to navigate to the chat?",
"abuseReports": "Abuse Reports",
"membershipCancel": "Cancel Membership", "membershipCancel": "Cancel Membership",
"membershipCancelConfirm": "Are you sure to cancel your membership?", "membershipCancelConfirm": "Are you sure to cancel your membership?",
"membershipCancelHint": "Are you sure to cancel your membership? You will not be charged again. Your membership will remain active until the end of the current billing period. And you will not able to resubscribe until the end of the current subscription ends.", "membershipCancelHint": "Are you sure to cancel your membership? You will not be charged again. Your membership will remain active until the end of the current billing period. And you will not able to resubscribe until the end of the current subscription ends.",
@@ -808,24 +766,25 @@
"rename": "Rename", "rename": "Rename",
"markAsSensitive": "Mark as Sensitive", "markAsSensitive": "Mark as Sensitive",
"fileName": "File name", "fileName": "File name",
"sensitiveCategories.language": "Language", "sensitiveCategories": {
"sensitiveCategories.sexualContent": "Sexual Content", "language": "Language",
"sensitiveCategories.violence": "Violence", "sexualContent": "Sexual Content",
"sensitiveCategories.profanity": "Profanity", "violence": "Violence",
"sensitiveCategories.hateSpeech": "Hate Speech", "profanity": "Profanity",
"sensitiveCategories.racism": "Racism", "hateSpeech": "Hate Speech",
"sensitiveCategories.adultContent": "Adult Content", "racism": "Racism",
"sensitiveCategories.drugAbuse": "Drug Abuse", "adultContent": "Adult Content",
"sensitiveCategories.alcoholAbuse": "Alcohol Abuse", "drugAbuse": "Drug Abuse",
"sensitiveCategories.gambling": "Gambling", "alcoholAbuse": "Alcohol Abuse",
"sensitiveCategories.selfHarm": "Self-harm", "gambling": "Gambling",
"sensitiveCategories.childAbuse": "Child Abuse", "selfHarm": "Self-harm",
"sensitiveCategories.other": "Other", "childAbuse": "Child Abuse",
"other": "Other"
},
"poll": "Poll", "poll": "Poll",
"pollsRecent": "Recent Polls", "pollsRecent": "Recent Polls",
"pollCreateNew": "Create New", "pollCreateNew": "Create New",
"pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.", "pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.",
"pollQuestions": "Questions",
"publisher": "Publisher", "publisher": "Publisher",
"publisherHint": "Enter the publisher name", "publisherHint": "Enter the publisher name",
"publisherCannotBeEmpty": "Publisher cannot be empty", "publisherCannotBeEmpty": "Publisher cannot be empty",
@@ -864,6 +823,7 @@
"one": "+{} file remaining", "one": "+{} file remaining",
"other": "+{} files remaining" "other": "+{} files remaining"
}, },
"pollQuestions": "Questions",
"pollAnswerSubmitted": "Poll answer has been submitted.", "pollAnswerSubmitted": "Poll answer has been submitted.",
"modifyAnswers": "Modify Answers", "modifyAnswers": "Modify Answers",
"back": "Back", "back": "Back",
@@ -901,6 +861,55 @@
"pollOptionLabel": "Option label", "pollOptionLabel": "Option label",
"pollLongTextAnswerPreview": "Long text answer (preview)", "pollLongTextAnswerPreview": "Long text answer (preview)",
"pollShortTextAnswerPreview": "Short text answer (preview)", "pollShortTextAnswerPreview": "Short text answer (preview)",
"award": "Award",
"awardPost": "Award Post",
"awardMessage": "Message",
"awardMessageHint": "Enter your award message...",
"awardAttitude": "Attitude",
"awardAttitudePositive": "Positive",
"awardAttitudeNegative": "Negative",
"awardAmount": "Amount",
"awardAmountHint": "Enter amount...",
"awardAmountRequired": "Amount is required",
"awardAmountInvalid": "Please enter a valid amount",
"awardMessageTooLong": "Message is too long (max 4096 characters)",
"awardSuccess": "Award sent successfully!",
"awardSubmit": "Award",
"awardPostPreview": "Post Preview",
"awardNoContent": "No content available",
"awardByPublisher": "By {}",
"awardBenefits": "Award Benefits",
"awardBenefitsDescription": "Awarding this post increases its value and visibility. Higher valued posts have a better chance of being featured and highlighted in the community.",
"checkInResultLevel5": "Happy Birthday 🥳",
"region": "Region",
"accountRegionHint": "This region will be used for content delivery and localization.",
"settingsCustomFontsHelper": "Use comma to seprate.",
"settingsBackgroundImageEnable": "Show Background Image",
"settingsDataSavingMode": "Data Saving Mode",
"dataSavingHint": "Data Saving Mode",
"postTypePost": "Post",
"searchDrafts": "Search drafts...",
"noSearchResults": "No search results",
"contactMethodMakePublic": "Make Public",
"contactMethodMakePrivate": "Make Private",
"contactMethodPublic": "Public",
"contactMethodPrivate": "Private",
"discoverRealms": "Realms",
"discoverPublishers": "Publishers",
"discoverShuffledPost": "Random Posts",
"projects": "Projects",
"noProjects": "No projects found.",
"deleteProject": "Delete Project",
"deleteProjectHint": "Are you sure you want to delete this project? This action cannot be undone.",
"createProject": "Create Project",
"editProject": "Edit Project",
"projectDetails": "Project Details",
"createBot": "Create Bot",
"bots": "Bots",
"noBots": "No bots yet.",
"deleteBotHint": "Are you sure you want to delete this bot? This action cannot be undone.",
"deleteBot": "Delete Bot",
"discoverWebArticles": "Web Feed Articles",
"messageJumpNotLoaded": "The referenced message was not loaded, unable to jump to it.", "messageJumpNotLoaded": "The referenced message was not loaded, unable to jump to it.",
"postUnlinkRealm": "No linked realm", "postUnlinkRealm": "No linked realm",
"postSlug": "Slug", "postSlug": "Slug",
@@ -908,15 +917,6 @@
"attachmentOnDevice": "On-device", "attachmentOnDevice": "On-device",
"attachmentOnCloud": "On-cloud", "attachmentOnCloud": "On-cloud",
"attachments": "Attachments", "attachments": "Attachments",
"uploadAttachment": "Upload Attachment",
"attachmentPreview": "Attachment Preview",
"selectPool": "Select Pool",
"choosePool": "Choose a pool",
"errorLoadingPools": "Error loading pools",
"quotaCostInfo": "This upload will cost {} quota points",
"uploadConstraints": "Upload Constraints",
"fileSizeExceeded": "File size exceeds the maximum limit of {}",
"fileTypeNotAccepted": "File type is not accepted by this pool",
"publisherCollabInvitation": "Collabration invitations", "publisherCollabInvitation": "Collabration invitations",
"publisherCollabInvitationCount": { "publisherCollabInvitationCount": {
"zero": "No invitation", "zero": "No invitation",
@@ -1019,6 +1019,10 @@
"searchLinks": "Links", "searchLinks": "Links",
"searchAttachments": "Attachments", "searchAttachments": "Attachments",
"noMessagesFound": "No messages found", "noMessagesFound": "No messages found",
"Searching...": "Searching...",
"searchError": "Search failed. Please try again.",
"tryDifferentKeywords": "Try different keywords or remove search filters",
"retry": "Retry",
"openInBrowser": "Open in Browser", "openInBrowser": "Open in Browser",
"highlightPost": "Highlight Post", "highlightPost": "Highlight Post",
"filters": "Filters", "filters": "Filters",
@@ -1033,11 +1037,6 @@
"expandPoll": "Expand Poll", "expandPoll": "Expand Poll",
"collapsePoll": "Collapse Poll", "collapsePoll": "Collapse Poll",
"embedView": "Embed View", "embedView": "Embed View",
"auto": "Auto",
"manual": "Manual",
"iframeCode": "Iframe Code",
"iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">",
"parseIframe": "Parse Iframe",
"embedUri": "Embed URI", "embedUri": "Embed URI",
"aspectRatio": "Aspect Ratio", "aspectRatio": "Aspect Ratio",
"renderer": "Renderer", "renderer": "Renderer",
@@ -1049,8 +1048,42 @@
"noEmbed": "No embed yet", "noEmbed": "No embed yet",
"save": "Save", "save": "Save",
"webView": "Web View", "webView": "Web View",
"settingsDefaultPool": "Default file pool",
"settingsDefaultPoolHelper": "Select the default storage pool for file uploads",
"uploadFile": "Upload File",
"authDeviceChallenges": "Device Usage",
"authDeviceHint": "Swipe left to edit label, swipe right to logout device.",
"settingsMessageDisplayStyle": "Message Display Style",
"settingsWindowOpacity": "Window Opacity",
"auto": "Auto",
"manual": "Manual",
"iframeCode": "Iframe Code",
"iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">",
"parseIframe": "Parse Iframe",
"messageActions": "Message Actions", "messageActions": "Message Actions",
"messageContent": "Message Content",
"viewEmbedLoadHint": "Tap to load", "viewEmbedLoadHint": "Tap to load",
"levelingStage1": "Novice",
"levelingStage2": "Apprentice",
"levelingStage3": "Journeyman",
"levelingStage4": "Adept",
"levelingStage5": "Expert",
"levelingStage6": "Master",
"levelingStage7": "Grandmaster",
"levelingStage8": "Legend",
"levelingStage9": "Myth",
"levelingStage10": "Immortal",
"levelingStage11": "Divine",
"levelingStage12": "Transcendent",
"uploadAttachment": "Upload Attachment",
"attachmentPreview": "Attachment Preview",
"selectPool": "Select Pool",
"choosePool": "Choose a pool",
"errorLoadingPools": "Error loading pools",
"quotaCostInfo": "This upload will cost {} quota points",
"uploadConstraints": "Upload Constraints",
"fileSizeExceeded": "File size exceeds the maximum limit of {}",
"fileTypeNotAccepted": "File type is not accepted by this pool",
"files": "Files", "files": "Files",
"confirmDeleteFile": "Are you sure you want to delete this file?", "confirmDeleteFile": "Are you sure you want to delete this file?",
"deleteFile": "Delete File", "deleteFile": "Delete File",
@@ -1062,5 +1095,125 @@
"deleteRecycledFiles": "Delete Recycled Files", "deleteRecycledFiles": "Delete Recycled Files",
"recycledFilesDeleted": "Recycled files deleted successfully", "recycledFilesDeleted": "Recycled files deleted successfully",
"failedToDeleteRecycledFiles": "Failed to delete recycled files", "failedToDeleteRecycledFiles": "Failed to delete recycled files",
"upload": "Upload" "upload": "Upload",
"updateAvailable": "Update available",
"noChangelogProvided": "No changelog provided.",
"useSecondarySourceForDownload": "Use secondary source for download",
"installUpdate": "Install update",
"openReleasePage": "Open release page",
"postCompose": "Compose Post",
"postPublish": "Publish Post",
"restoreDraftTitle": "Restore Draft",
"restoreDraftMessage": "A draft was found. Do you want to restore it?",
"draft": "Draft",
"purchaseGift": "Purchase Gift",
"selectRecipient": "Select Recipient",
"changeRecipient": "Change Recipient",
"addMessage": "Add Message",
"skipRecipient": "Skip Recipient",
"giftSubscriptions": "Gift Subscriptions",
"purchaseAGift": "Purchase a Gift",
"redeemAGift": "Redeem a Gift",
"giftHistory": "Gift History",
"sentGifts": "Sent Gifts",
"receivedGifts": "Received Gifts",
"noSentGifts": "No sent gifts",
"noReceivedGifts": "No received gifts",
"stellarGift": "Stellar Gift",
"novaGift": "Nova Gift",
"supernovaGift": "Supernova Gift",
"sameAsMembership": "Same as membership",
"enterGiftCodeToRedeem": "Enter gift code to redeem",
"enterGiftCode": "Enter gift code",
"giftPurchased": "Gift Purchased!",
"shareCodeWithRecipient": "Share this code with the recipient to redeem the gift.",
"openGiftAnyoneCanRedeem": "This is an open gift that anyone can redeem.",
"ok": "OK",
"selectedRecipient": "Selected recipient",
"noRecipientSelected": "No recipient selected",
"thisWillBeAnOpenGift": "This will be an open gift",
"personalMessage": "Personal Message",
"addPersonalMessageForRecipient": "Add a personal message for the recipient",
"cancel": "Cancel",
"giftStatusCreated": "Created",
"giftStatusSent": "Sent",
"giftStatusRedeemed": "Redeemed",
"giftStatusCancelled": "Cancelled",
"giftStatusExpired": "Expired",
"giftStatusUnknown": "Unknown",
"giftCodeCopiedToClipboard": "Gift code copied to clipboard",
"codeLabel": "Code: ",
"subscriptionLabel": "Subscription: ",
"toLabel": "To: ",
"fromLabel": "From: ",
"messageLabel": "Message: ",
"giftRedeemed": "Gift Redeemed!",
"giftRedeemedSuccessfully": "You have successfully redeemed the gift. Your new subscription is now active.",
"cancelGift": "Cancel Gift",
"cancelGiftConfirm": "Are you sure you want to cancel this gift? This action cannot be undone.",
"giftCancelledSuccessfully": "Gift cancelled successfully",
"createFund": "Create Fund",
"fundAmount": "Fund Amount",
"enterAmount": "Enter Amount",
"selectCurrency": "Select Currency",
"splitType": "Split Type",
"evenSplit": "Even Split",
"equalAmountEach": "Equal amount for each recipient",
"randomSplit": "Random Split",
"randomAmountEach": "Random amount for each recipient",
"recipientCount": "Recipient Count",
"numberOfRecipients": "Number of Recipients",
"addPersonalMessageForRecipients": "Add a personal message for recipients",
"invalidAmount": "Invalid amount",
"invalidRecipientCount": "Invalid recipient count",
"fundOverview": "Fund Overview",
"totalFundsSent": "Total Funds Sent",
"totalFundsReceived": "Total Funds Received",
"transactions": "Transactions",
"myFunds": "My Funds",
"availableFunds": "Available Funds",
"fundStatusCreated": "Created",
"fundStatusPartial": "Partially Claimed",
"fundStatusCompleted": "Fully Claimed",
"fundStatusExpired": "Expired",
"fundStatusUnknown": "Unknown",
"recipients": "Recipients",
"fundClaimedSuccessfully": "Fund claimed successfully!",
"claim": "Claim",
"noFundsCreated": "No funds created yet",
"createYourFirstFund": "Create your first fund to get started",
"noAvailableFunds": "No available funds",
"fundsWillAppearHere": "Funds you can claim will appear here",
"fundCreatedSuccessfully": "Fund created successfully!",
"selectRecipients": "Select Recipients",
"noRecipientsSelected": "No recipients selected",
"selectRecipientsToSendFund": "Select recipients to send the fund to",
"addRecipient": "Add Recipient",
"addMoreRecipients": "Add More Recipients",
"transactionDetails": "Transaction Details",
"remarks": "Remarks",
"payer": "Payer",
"payee": "Payee",
"transactionType": "Transaction Type",
"transfer": "Transfer",
"payment": "Payment",
"systemWallet": "System Wallet",
"date": "Date",
"createTransfer": "Create Transfer",
"transferAmount": "Transfer Amount",
"selectPayee": "Select Payee",
"selectedPayee": "Selected Payee",
"noPayeeSelected": "No payee selected",
"selectPayeeToTransfer": "Select payee to transfer to",
"addRemark": "Add Remark",
"transferRemark": "Transfer Remark",
"addRemarkForTransfer": "Add remark for transfer",
"enterPinToConfirmTransfer": "Enter your 6-digit PIN to confirm transfer",
"transferCreatedSuccessfully": "Transfer created successfully!",
"postUpdate": "Update",
"fileMetadata": "File Metadata",
"resend": "Resend",
"fileInfoTitle": "File Information",
"download": "Download",
"info": "Info"
} }

1079
assets/i18n/es-ES.json Normal file

File diff suppressed because it is too large Load Diff

1079
assets/i18n/ja-JP.json Normal file

File diff suppressed because it is too large Load Diff

1079
assets/i18n/ko-KR.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -122,9 +122,6 @@
"addVideo": "添加视频", "addVideo": "添加视频",
"addPhoto": "添加照片", "addPhoto": "添加照片",
"addFile": "添加文件", "addFile": "添加文件",
"uploadFile": "上传文件",
"settingsDefaultPool": "选择文件池",
"settingsDefaultPoolHelper": "为文件上传选择一个默认池",
"createDirectMessage": "创建新私人消息", "createDirectMessage": "创建新私人消息",
"gotoDirectMessage": "前往私信", "gotoDirectMessage": "前往私信",
"react": "反应", "react": "反应",
@@ -161,12 +158,11 @@
"checkIn": "签到", "checkIn": "签到",
"checkInNone": "尚未签到", "checkInNone": "尚未签到",
"checkInNoneHint": "通过签到获取您的财富提示和每日奖励。", "checkInNoneHint": "通过签到获取您的财富提示和每日奖励。",
"checkInResultLevel0": "大凶", "checkInResultLevel0": "最差运气",
"checkInResultLevel1": "", "checkInResultLevel1": "坏运气",
"checkInResultLevel2": "中平", "checkInResultLevel2": "一个普通的日常",
"checkInResultLevel3": "", "checkInResultLevel3": "好运",
"checkInResultLevel4": "大吉", "checkInResultLevel4": "最佳运气",
"checkInResultLevel5": "生日快乐 🥳",
"checkInActivityTitle": "{} 在 {} 签到并获得了 {}", "checkInActivityTitle": "{} 在 {} 签到并获得了 {}",
"eventCalander": "活动日历", "eventCalander": "活动日历",
"eventCalanderEmpty": "该日无活动。", "eventCalanderEmpty": "该日无活动。",
@@ -283,18 +279,6 @@
"levelingProgress": "等级进度", "levelingProgress": "等级进度",
"levelingProgressExperience": "{} 经验值", "levelingProgressExperience": "{} 经验值",
"levelingProgressLevel": "等级 {}", "levelingProgressLevel": "等级 {}",
"levelingStage1": "新手",
"levelingStage2": "学徒",
"levelingStage3": "熟练工",
"levelingStage4": "行家",
"levelingStage5": "专家",
"levelingStage6": "大师",
"levelingStage7": "宗师",
"levelingStage8": "传奇",
"levelingStage9": "神话",
"levelingStage10": "不朽",
"levelingStage11": "神圣",
"levelingStage12": "超凡",
"fileUploadingProgress": "正在上传文件 #{}: {}%", "fileUploadingProgress": "正在上传文件 #{}: {}%",
"removeChatMember": "移除聊天室成员", "removeChatMember": "移除聊天室成员",
"removeChatMemberHint": "确定要将此成员从聊天室中移除吗?", "removeChatMemberHint": "确定要将此成员从聊天室中移除吗?",
@@ -316,11 +300,9 @@
"walletCreate": "创建钱包", "walletCreate": "创建钱包",
"settingsServerUrl": "服务器 URL", "settingsServerUrl": "服务器 URL",
"settingsApplied": "设置已应用。", "settingsApplied": "设置已应用。",
"settingsCustomFontsHelper": "用逗号分隔。",
"notifications": "通知", "notifications": "通知",
"posts": "帖子", "posts": "帖子",
"settingsBackgroundImage": "背景图片", "settingsBackgroundImage": "背景图片",
"settingsBackgroundImageEnable": "显示背景图片",
"settingsBackgroundImageClear": "清除背景图片", "settingsBackgroundImageClear": "清除背景图片",
"settingsBackgroundGenerateColor": "从背景图像生成主题色", "settingsBackgroundGenerateColor": "从背景图像生成主题色",
"messageNone": "没有内容可显示", "messageNone": "没有内容可显示",
@@ -331,8 +313,6 @@
"chatBreakNone": "无", "chatBreakNone": "无",
"settingsRealmCompactView": "紧凑领域视图", "settingsRealmCompactView": "紧凑领域视图",
"settingsMixedFeed": "混合动态", "settingsMixedFeed": "混合动态",
"settingsDataSavingMode": "流量节省模式",
"dataSavingHint": "流量节省模式",
"settingsAutoTranslate": "自动翻译", "settingsAutoTranslate": "自动翻译",
"settingsHideBottomNav": "隐藏底部导航", "settingsHideBottomNav": "隐藏底部导航",
"settingsSoundEffects": "音效", "settingsSoundEffects": "音效",
@@ -364,7 +344,7 @@
"accountSettingsHelpContent": "此页面允许您管理您的帐户安全性、隐私和其他设置。如果您需要帮助,请联系管理员。", "accountSettingsHelpContent": "此页面允许您管理您的帐户安全性、隐私和其他设置。如果您需要帮助,请联系管理员。",
"unauthorized": "未授权", "unauthorized": "未授权",
"unauthorizedHint": "您未登录或会话已过期,请重新登录。", "unauthorizedHint": "您未登录或会话已过期,请重新登录。",
"publisherBelongsTo": "属于 {}", "publisherBelongsTo": "属于",
"postContent": "内容", "postContent": "内容",
"postSettings": "设置", "postSettings": "设置",
"postPublisherUnselected": "未指定发布者", "postPublisherUnselected": "未指定发布者",
@@ -688,7 +668,6 @@
"publisherFeatureDevelopDescription": "为你的开发者解锁包括应用套件API 及更多开发功能。", "publisherFeatureDevelopDescription": "为你的开发者解锁包括应用套件API 及更多开发功能。",
"publisherFeatureDevelopHint": "目前该功能还在开发中,你需要邀请才可解锁。", "publisherFeatureDevelopHint": "目前该功能还在开发中,你需要邀请才可解锁。",
"learnMore": "了解更多", "learnMore": "了解更多",
"discoverWebArticles": "来自站外的文章",
"webArticlesStand": "文章亭", "webArticlesStand": "文章亭",
"about": "关于", "about": "关于",
"somethingWentWrong": "发生了一些错误", "somethingWentWrong": "发生了一些错误",
@@ -711,8 +690,6 @@
"sharePostPhoto": "通过图片分享帖子", "sharePostPhoto": "通过图片分享帖子",
"wouldYouLikeToNavigateToChat": "你想要前往聊天页面吗?", "wouldYouLikeToNavigateToChat": "你想要前往聊天页面吗?",
"abuseReports": "举报", "abuseReports": "举报",
"discoverRealms": "发现领域",
"discoverPublishers": "发现发布者",
"membershipCancel": "取消会员订阅", "membershipCancel": "取消会员订阅",
"membershipCancelConfirm": "你确定要取消会员订阅吗?", "membershipCancelConfirm": "你确定要取消会员订阅吗?",
"membershipCancelHint": "你确定要取消会员订阅吗?你将不会再次被扣费。你的会员资格将在当前计费周期结束前保持有效。并且你将无法重新订阅,直到当前订阅结束。", "membershipCancelHint": "你确定要取消会员订阅吗?你将不会再次被扣费。你的会员资格将在当前计费周期结束前保持有效。并且你将无法重新订阅,直到当前订阅结束。",
@@ -832,12 +809,100 @@
"one": "+{} 个文件被折叠", "one": "+{} 个文件被折叠",
"other": "+{} 个文件被折叠" "other": "+{} 个文件被折叠"
}, },
"pollQuestions": "问题",
"pollAnswerSubmitted": "调查问卷已提交。",
"modifyAnswers": "修改问卷",
"back": "返回",
"submit": "提交",
"pollOptionDefaultLabel": "选项 1",
"pollUpdated": "投票已更新。",
"pollCreated": "投票已创建。",
"pollCreate": "创建投票",
"pollEdit": "编辑投票",
"pollPreviewJsonDebug": "调试预览",
"pollTitleRequired": "标题不可为空",
"pollEndDateOptional": "结束日期和时间 (可选)",
"notSet": "未设置",
"pick": "选择",
"clear": "清除",
"questions": "问题",
"pollAddQuestion": "新增问题",
"pollQuestionTypeSingleChoice": "单选",
"pollQuestionTypeMultipleChoice": "多选",
"pollQuestionTypeFreeText": "自由文本",
"pollQuestionTypeYesNo": "是/否",
"pollQuestionTypeRating": "评价",
"pollNoQuestionsYet": "没有问题",
"pollNoQuestionsHint": "点击「添加问题」开始建立您的问卷调查。",
"pollDebugPreview": "调试预览",
"pollUntitledQuestion": "无标题的问题",
"moveUp": "上移",
"moveDown": "下移",
"required": "必填项",
"pollQuestionTitle": "题目",
"pollQuestionTitleRequired": "题目是必填项",
"pollQuestionDescriptionOptional": "题目描述(可选)",
"options": "选项",
"pollAddOption": "添加选项",
"pollOptionLabel": "选项标签",
"pollLongTextAnswerPreview": "长文本答案 (预览)",
"pollShortTextAnswerPreview": "短文本答案 (预览)",
"award": "赞赏",
"awardPost": "赞赏帖子",
"awardMessage": "消息",
"awardMessageHint": "输入您的赞赏信息……",
"awardAttitude": "态度",
"awardAttitudePositive": "积极",
"awardAttitudeNegative": "消极的",
"awardAmount": "金额",
"awardAmountHint": "输入金额……",
"awardAmountRequired": "「金额」为必填字段",
"awardAmountInvalid": "请输入有效金额",
"awardMessageTooLong": "消息太长最多4096个字符",
"awardSuccess": "奖励已成功发送!",
"awardSubmit": "赞赏",
"awardPostPreview": "帖子预览",
"awardNoContent": "暂无内容",
"awardByPublisher": "由 {} 发表",
"awardBenefits": "赞赏福利",
"awardBenefitsDescription": "为该帖子授予奖励可以提升其价值和曝光度。价值更高的帖子更有可能在社区中被推荐和突出显示。",
"checkInResultLevel5": "生日快乐 🥳",
"region": "区域",
"accountRegionHint": "该地区将用于内容交付和本地化。",
"settingsCustomFontsHelper": "用逗号分隔。",
"settingsBackgroundImageEnable": "显示背景图片",
"settingsDataSavingMode": "流量节省模式",
"dataSavingHint": "流量节省模式",
"postTypePost": "帖子",
"searchDrafts": "搜索草稿……",
"noSearchResults": "无搜索结果",
"contactMethodMakePublic": "设为公开",
"contactMethodMakePrivate": "设为仅自己可见",
"contactMethodPublic": "公开",
"contactMethodPrivate": "私密",
"discoverRealms": "发现领域",
"discoverPublishers": "发现发布者",
"discoverShuffledPost": "随机的帖子",
"projects": "项目",
"noProjects": "未找到项目。",
"deleteProject": "删除项目",
"deleteProjectHint": "确定要删除此项目吗?此操作无法撤销。",
"createProject": "新建项目",
"editProject": "编辑项目",
"projectDetails": "项目详情",
"createBot": "创建机器人",
"bots": "机器人",
"noBots": "暂无机器人。",
"deleteBotHint": "您确定要删除此机器人吗?此操作无法撤消。",
"deleteBot": "删除机器人",
"discoverWebArticles": "来自站外的文章",
"messageJumpNotLoaded": "引用的消息没有被加载,无法跳转。", "messageJumpNotLoaded": "引用的消息没有被加载,无法跳转。",
"postUnlinkRealm": "不关联领域", "postUnlinkRealm": "不关联领域",
"postSlug": "别名", "postSlug": "别名",
"postSlugHint": "这个别名可以用于在网页通过 URL 浏览到你的帖子,它应该在同一发布者中是唯一。", "postSlugHint": "这个别名可以用于在网页通过 URL 浏览到你的帖子,它应该在同一发布者中是唯一。",
"attachmentOnDevice": "离线", "attachmentOnDevice": "离线",
"attachmentOnCloud": "在线", "attachmentOnCloud": "在线",
"attachments": "附件",
"publisherCollabInvitation": "协作邀请", "publisherCollabInvitation": "协作邀请",
"publisherCollabInvitationCount": { "publisherCollabInvitationCount": {
"zero": "无邀请", "zero": "无邀请",
@@ -845,24 +910,58 @@
"other": "{} 个可用邀请" "other": "{} 个可用邀请"
}, },
"failedToLoadUserInfo": "加载用户信息失败", "failedToLoadUserInfo": "加载用户信息失败",
"failedToLoadUserInfoNetwork": "这看起来是个网络问题,你可以按下面的按钮来重试", "failedToLoadUserInfoNetwork": "这看起来是个网络问题,你可以按下面的按钮来重试",
"failedToLoadUserInfoUnauthorized": "看来您的会话已被注销或不再可用,如果您愿意,您仍然可以再次尝试获取用户信息。", "failedToLoadUserInfoUnauthorized": "看来您的会话已被注销或不再可用,如果您愿意,您仍然可以再次尝试获取用户信息。",
"okay": "了解", "okay": "了解",
"postDetail": "帖子详情", "postDetail": "帖子详情",
"postCount": {
"zero": "没有帖子",
"one": "{} 帖子",
"other": "{} 帖子"
},
"mimeType": "类型", "mimeType": "类型",
"fileSize": "大小", "fileSize": "大小",
"fileHash": "哈希", "fileHash": "哈希",
"exifData": "EXIF 数据", "exifData": "EXIF 数据",
"postShuffle": "随机帖子",
"leveling": "等级", "leveling": "等级",
"levelingHistory": "经验记录", "levelingHistory": "经验记录",
"stellarProgram": "恒星计划", "stellarProgram": "恒星计划",
"socialCredits": "社会信用点", "socialCredits": "社会信用点",
"credits": "信用", "credits": "信用",
"creditsStatus": "积分状态",
"socialCreditsDescription": "社会信用是 Solar Network 评价用户的一种方式。它基于用户的行为和互动来计算。以 100 分为基准,分数越高表示用户在社区中的信誉越好。分数会随着时间的推移而变化,反映用户的最新行为。信用等级高的用户可以享受到更多的福利,反之的用户部份功能可能受到限制。", "socialCreditsDescription": "社会信用是 Solar Network 评价用户的一种方式。它基于用户的行为和互动来计算。以 100 分为基准,分数越高表示用户在社区中的信誉越好。分数会随着时间的推移而变化,反映用户的最新行为。信用等级高的用户可以享受到更多的福利,反之的用户部份功能可能受到限制。",
"socialCreditsLevelPoor": "糟糕", "socialCreditsLevelPoor": "糟糕",
"socialCreditsLevelNormal": "正常", "socialCreditsLevelNormal": "正常",
"socialCreditsLevelGood": "良好", "socialCreditsLevelGood": "良好",
"socialCreditsLevelExcellent": "优秀", "socialCreditsLevelExcellent": "优秀",
"orderByPopularity": "按热度排序",
"orderByReleaseDate": "按发布日期排序",
"editBot": "编辑机器人",
"botAutomatedBy": "由 {} 自动化",
"botDetails": "机器人详情",
"overview": "总揽",
"keys": "密钥",
"botNotFound": "机器人未找到。",
"newBotKey": "新建密钥",
"newBotKeyHint": "输入新密钥的名称,密钥只会显示一次。",
"revokeBotKey": "撤销密钥",
"revokeBotKeyHint": "你确定要撤销这个密钥?这个操作无法撤回,所有使用该密钥的应用程式会停止工作。",
"noBotKeys": "还没有密钥。",
"revoke": "撤销",
"keyName": "密钥名称",
"newKeyGenerated": "新密钥已生成",
"copyKeyHint": "请安全的保存该密钥,你不会再次看到它。",
"rotateKey": "旋转密钥",
"rotateBotKey": "旋转密钥",
"rotateBotKeyHint": "你确认要旋转这个密钥?久的密钥会立即失效,该操作无法撤销。",
"webFeedArticleCount": {
"zero": "没有文章",
"one": "{} 篇文章",
"other": "{} 篇文章"
},
"webFeedSubscribed": "你已经订阅了这个源",
"webFeedUnsubscribed": "你已经取消订阅这个源",
"appDetails": "应用详情", "appDetails": "应用详情",
"secrets": "密钥", "secrets": "密钥",
"appNotFound": "应用未找到。", "appNotFound": "应用未找到。",
@@ -875,9 +974,112 @@
"copySecretHint": "请复制此密钥并将其存放在安全的地方。您将无法再次看到它。", "copySecretHint": "请复制此密钥并将其存放在安全的地方。您将无法再次看到它。",
"expiresIn": "过期时间(秒)", "expiresIn": "过期时间(秒)",
"isOidc": "OIDC 兼容", "isOidc": "OIDC 兼容",
"pinPost": "置顶帖子",
"unpinPost": "取消置顶",
"pinnedPost": "已置顶",
"publisherPage": "发布者页面",
"realmPage": "领域页面",
"replyPage": "回复页面",
"pinPostPublisherHint": "将此帖子置顶于发布者页面",
"pinPostRealmHint": "将此帖子置顶于领域页面",
"pinPostRealmDisabledHint": "这个帖子不属于任何领域",
"pinPostReplyHint": "将此帖子置顶于回复页面",
"pinPostReplyDisabledHint": "这个帖子不是回复",
"pin": "置顶",
"unpinPostHint": "你确定要取消置顶该帖子吗?",
"all": "全部",
"statusPresent": "至今", "statusPresent": "至今",
"accountAutomated": "机器人", "accountAutomated": "机器人",
"chatBreakClearButton": "清除",
"chatBreak5m": "5 分钟",
"chatBreak10m": "10 分钟",
"chatBreak15m": "15 分钟",
"chatBreak30m": "30 分钟",
"chatBreakCustomMinutes": "自定义(分钟)",
"errorGeneric": "错误: {}",
"searchMessages": "搜索消息",
"messagesCount": "{} 消息",
"dotSeparator": "·",
"roleValidationHint": "成员角色必须设置在0到100之间",
"searchMessagesHint": "搜索消息……",
"searchLinks": "链接",
"searchAttachments": "附件",
"noMessagesFound": "未找到消息",
"openInBrowser": "在浏览器中打开", "openInBrowser": "在浏览器中打开",
"highlightPost": "精选帖子", "highlightPost": "精选帖子",
"notableDayNext": "距离 {} 还有" "filters": "过滤器",
"apply": "申请",
"pubName": "题目名称",
"realm": "领域",
"shuffle": "随机",
"pinned": "已置顶",
"noResultsFound": "未找到结果",
"toggleFilters": "切换过滤器",
"notableDayNext": "距离 {} 还有",
"expandPoll": "展开投票",
"collapsePoll": "折叠投票",
"embedView": "嵌入视图",
"embedUri": "嵌入的 URI",
"aspectRatio": "长宽比",
"renderer": "渲染器",
"addEmbed": "添加嵌入",
"editEmbed": "编辑嵌入",
"deleteEmbed": "删除嵌入",
"deleteEmbedConfirm": "您确定要删除此嵌入吗?",
"currentEmbed": "当前嵌入",
"noEmbed": "尚未嵌入",
"save": "保存",
"webView": "Web 视图",
"settingsDefaultPool": "选择文件池",
"settingsDefaultPoolHelper": "为文件上传选择一个默认池",
"uploadFile": "上传文件",
"authDeviceChallenges": "设备活动",
"authDeviceHint": "向左轻扫以编辑标签,向右轻扫以注销登录设备。",
"settingsMessageDisplayStyle": "消息样式",
"auto": "自动",
"manual": "手动",
"iframeCode": "Iframe代码",
"iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">",
"parseIframe": "解析Iframe",
"messageActions": "消息选项",
"viewEmbedLoadHint": "点击以加载",
"levelingStage1": "新手",
"levelingStage2": "学徒",
"levelingStage3": "熟练工",
"levelingStage4": "行家",
"levelingStage5": "专家",
"levelingStage6": "大师",
"levelingStage7": "宗师",
"levelingStage8": "传奇",
"levelingStage9": "神话",
"levelingStage10": "不朽",
"levelingStage11": "神圣",
"levelingStage12": "超凡",
"uploadAttachment": "上传附件",
"attachmentPreview": "附件预览",
"selectPool": "选择储存池",
"choosePool": "选择一个储存池",
"errorLoadingPools": "加载池时出错",
"quotaCostInfo": "此上传将消耗 {} 配额点",
"uploadConstraints": "上传限制",
"fileSizeExceeded": "文件大小超过了 {} 的最大限制",
"fileTypeNotAccepted": "此储存池不接受该文件类型",
"files": "附件",
"confirmDeleteFile": "你确定要删除此文件吗?",
"deleteFile": "删除文件",
"failedToDeleteFile": "删除文件失败",
"drive": "云盘",
"allPools": "所有池",
"includeRecycled": "包括已回收的文件",
"confirmDeleteRecycledFiles": "你确定要删除所有被回收的文件吗?",
"deleteRecycledFiles": "删除被回收的文件",
"recycledFilesDeleted": "被回收文件成功删除",
"failedToDeleteRecycledFiles": "删除被回收文件失败",
"upload": "上传",
"systemWallet": "中央统筹",
"postCompose": "撰写帖子",
"postPublish": "发布帖子",
"restoreDraftTitle": "恢复草稿",
"restoreDraftMessage": "发现了一个草稿。你想要恢复它吗?",
"draft": "草稿"
} }

1079
assets/i18n/zh-OG.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -116,16 +116,12 @@
}, },
"postHasAttachments": { "postHasAttachments": {
"one": "{} 個附件", "one": "{} 個附件",
"other": "{}個附件" "other": "{} 個附件"
}, },
"edited": "已編輯", "edited": "已編輯",
"addVideo": "添加視頻", "addVideo": "添加視頻",
"addPhoto": "添加照片", "addPhoto": "添加照片",
"addFile": "添加文件", "addFile": "添加文件",
"uploadFile": "上傳文件",
"settingsDefaultPool": "選擇文件池",
"settingsDefaultPoolHelper": "爲文件上傳選擇一個默認池",
"createDirectMessage": "創建新私人消息", "createDirectMessage": "創建新私人消息",
"gotoDirectMessage": "前往私信", "gotoDirectMessage": "前往私信",
"react": "反應", "react": "反應",
@@ -307,7 +303,6 @@
"notifications": "通知", "notifications": "通知",
"posts": "帖子", "posts": "帖子",
"settingsBackgroundImage": "背景圖片", "settingsBackgroundImage": "背景圖片",
"settingsBackgroundImageEnable": "顯示背景圖片",
"settingsBackgroundImageClear": "清除背景圖片", "settingsBackgroundImageClear": "清除背景圖片",
"settingsBackgroundGenerateColor": "從背景圖像生成主題色", "settingsBackgroundGenerateColor": "從背景圖像生成主題色",
"messageNone": "沒有內容可顯示", "messageNone": "沒有內容可顯示",
@@ -319,8 +314,6 @@
"settingsRealmCompactView": "緊湊領域視圖", "settingsRealmCompactView": "緊湊領域視圖",
"settingsMixedFeed": "混合動態", "settingsMixedFeed": "混合動態",
"settingsAutoTranslate": "自動翻譯", "settingsAutoTranslate": "自動翻譯",
"settingsDataSavingMode": "低數據模式",
"dataSavingHint": "低數據模式",
"settingsHideBottomNav": "隱藏底部導航", "settingsHideBottomNav": "隱藏底部導航",
"settingsSoundEffects": "音效", "settingsSoundEffects": "音效",
"settingsAprilFoolFeatures": "愚人節功能", "settingsAprilFoolFeatures": "愚人節功能",
@@ -675,7 +668,6 @@
"publisherFeatureDevelopDescription": "為你的開發者解鎖包括應用套件API 及更多開發功能。", "publisherFeatureDevelopDescription": "為你的開發者解鎖包括應用套件API 及更多開發功能。",
"publisherFeatureDevelopHint": "目前該功能還在開發中,你需要邀請才可解鎖。", "publisherFeatureDevelopHint": "目前該功能還在開發中,你需要邀請才可解鎖。",
"learnMore": "瞭解更多", "learnMore": "瞭解更多",
"discoverWebArticles": "來自站外的文章",
"webArticlesStand": "文章亭", "webArticlesStand": "文章亭",
"about": "關於", "about": "關於",
"somethingWentWrong": "發生了一些錯誤", "somethingWentWrong": "發生了一些錯誤",
@@ -698,8 +690,6 @@
"sharePostPhoto": "通過圖片分享帖子", "sharePostPhoto": "通過圖片分享帖子",
"wouldYouLikeToNavigateToChat": "你想要前往聊天頁面嗎?", "wouldYouLikeToNavigateToChat": "你想要前往聊天頁面嗎?",
"abuseReports": "舉報", "abuseReports": "舉報",
"discoverRealms": "發現領域",
"discoverPublishers": "發現發佈者",
"membershipCancel": "取消會員訂閱", "membershipCancel": "取消會員訂閱",
"membershipCancelConfirm": "你確定要取消會員訂閱嗎?", "membershipCancelConfirm": "你確定要取消會員訂閱嗎?",
"membershipCancelHint": "你確定要取消會員訂閱嗎?你將不會再次被扣費。你的會員資格將在當前計費週期結束前保持有效。並且你將無法重新訂閱,直到當前訂閱結束。", "membershipCancelHint": "你確定要取消會員訂閱嗎?你將不會再次被扣費。你的會員資格將在當前計費週期結束前保持有效。並且你將無法重新訂閱,直到當前訂閱結束。",
@@ -819,6 +809,159 @@
"one": "+{} 個文件被摺疊", "one": "+{} 個文件被摺疊",
"other": "+{} 個文件被摺疊" "other": "+{} 個文件被摺疊"
}, },
"pollQuestions": "問題",
"pollAnswerSubmitted": "投票答案已提交。",
"modifyAnswers": "修改答案",
"back": "返回",
"submit": "提交",
"pollOptionDefaultLabel": "選項1",
"pollUpdated": "投票已更新。",
"pollCreated": "投票已創建。",
"pollCreate": "創建投票",
"pollEdit": "編輯投票",
"pollPreviewJsonDebug": "調試預覽",
"pollTitleRequired": "標題不可為空",
"pollEndDateOptional": "結束日期和時間 (可選)",
"notSet": "未設定",
"pick": "選擇",
"clear": "清除",
"questions": "問題",
"pollAddQuestion": "添加問題",
"pollQuestionTypeSingleChoice": "單選框",
"pollQuestionTypeMultipleChoice": "多選框",
"pollQuestionTypeFreeText": "自由文本",
"pollQuestionTypeYesNo": "是 / 不是",
"pollQuestionTypeRating": "評分",
"pollNoQuestionsYet": "尚未有問題",
"pollNoQuestionsHint": "使用「添加問題」開始建立您的投票。",
"pollDebugPreview": "調試預覽",
"pollUntitledQuestion": "無標題問題",
"moveUp": "往上移動",
"moveDown": "往下移動",
"required": "必需的",
"pollQuestionTitle": "問題標題",
"pollQuestionTitleRequired": "問題標題是必需的",
"pollQuestionDescriptionOptional": "問題描述(選填)",
"options": "選項",
"pollAddOption": "添加選項",
"pollOptionLabel": "選項標籤",
"pollLongTextAnswerPreview": "長文本答案 (預覽)",
"pollShortTextAnswerPreview": "短文本答案 (預覽)",
"award": "讚賞",
"awardPost": "讚賞帖子",
"awardMessage": "消息",
"awardMessageHint": "輸入您的讚賞消息...",
"awardAttitude": "態度",
"awardAttitudePositive": "積極",
"awardAttitudeNegative": "消极",
"awardAmount": "金額",
"awardAmountHint": "輸入金額……",
"awardAmountRequired": "「金額」為必填字段",
"awardAmountInvalid": "請輸入有效金額",
"awardMessageTooLong": "消息太長最多4096個字符",
"awardSuccess": "獎勵已成功發送!",
"awardSubmit": "讚賞",
"awardPostPreview": "帖子預覽",
"awardNoContent": "暫無內容",
"awardByPublisher": "由 {} 發表",
"awardBenefits": "讚賞福利",
"awardBenefitsDescription": "為該帖子授予獎勵可以提升其價值和曝光度。價值更高的帖子更有可能在社區中被推薦和突出顯示。",
"checkInResultLevel5": "生日快樂 🥳",
"region": "區域",
"accountRegionHint": "這個區域將用於內容傳遞和本地化。",
"settingsCustomFontsHelper": "使用逗號分隔。",
"settingsBackgroundImageEnable": "顯示背景圖片",
"settingsDataSavingMode": "低數據模式",
"dataSavingHint": "低數據模式",
"postTypePost": "帖子",
"searchDrafts": "搜尋草稿……",
"noSearchResults": "無搜尋結果",
"contactMethodMakePublic": "設為公開",
"contactMethodMakePrivate": "設定為僅自己可見",
"contactMethodPublic": "公開",
"contactMethodPrivate": "私密",
"discoverRealms": "發現領域",
"discoverPublishers": "發現發佈者",
"discoverShuffledPost": "隨機帖子",
"projects": "項目",
"noProjects": "未找到項目。",
"deleteProject": "刪除項目",
"deleteProjectHint": "確定要刪除此項目嗎?此操作無法撤銷。",
"createProject": "新建專案",
"editProject": "編輯項目",
"projectDetails": "專案描述",
"createBot": "創建機器人",
"bots": "機器人",
"noBots": "還沒有機器人。",
"deleteBotHint": "您確定要刪除這個機器人嗎?此操作無法撤銷。",
"deleteBot": "刪除機器人",
"discoverWebArticles": "來自站外的文章",
"messageJumpNotLoaded": "引用的訊息未加載,無法跳轉到該訊息。",
"postUnlinkRealm": "未連結到領域",
"postSlug": "別名",
"postSlugHint": "這個別名可以用於在網頁通過 URL 瀏覽到你的帖子,它應該在同一發布者中是唯一。",
"attachmentOnDevice": "離線",
"attachmentOnCloud": "在線",
"attachments": "附件",
"publisherCollabInvitation": "協作邀請",
"publisherCollabInvitationCount": {
"zero": "無邀請",
"one": "{} 個可用邀請",
"other": "{} 個可用邀請"
},
"failedToLoadUserInfo": "無法加載用戶資訊",
"failedToLoadUserInfoNetwork": "看起來是網絡問題,您可以點擊下面的按鈕再試一次。",
"failedToLoadUserInfoUnauthorized": "看起來您的會話已經登出或不再可用,如果您想的話,您仍然可以嘗試再次獲取用戶資訊。",
"okay": "好的",
"postDetail": "帖子詳情",
"postCount": {
"zero": "沒有帖子",
"one": "{} 帖子",
"other": "{} 帖子"
},
"mimeType": "類型",
"fileSize": "文件大小",
"fileHash": "文件哈希",
"exifData": "EXIF 數據",
"postShuffle": "隨機帖子",
"leveling": "等級",
"levelingHistory": "經驗記錄",
"stellarProgram": "恆星計畫",
"socialCredits": "社會信用點",
"credits": "信用",
"creditsStatus": "積分狀態",
"socialCreditsDescription": "社會信用是 Solar Network 評價用戶的一種方式。它基於用戶的行為和互動來計算。以 100 分為基準,分數越高表示用戶在社區中的信譽越好。分數會隨著時間的推移而變化,反映用戶的最新行為。信用等級高的用戶可以享受到更多的福利,反之的用戶部分功能可能受到限制。",
"socialCreditsLevelPoor": "糟糕",
"socialCreditsLevelNormal": "正常",
"socialCreditsLevelGood": "良好",
"socialCreditsLevelExcellent": "優秀",
"orderByPopularity": "按熱度排序",
"orderByReleaseDate": "按發佈日期排序",
"editBot": "編輯機器人",
"botAutomatedBy": "由 {} 自動化",
"botDetails": "機器人描述",
"overview": "概述",
"keys": "密鑰",
"botNotFound": "機器人未找到。",
"newBotKey": "新建密鑰",
"newBotKeyHint": "輸入新密鑰的名稱,密鑰只會顯示一次。",
"revokeBotKey": "撤銷密鑰",
"revokeBotKeyHint": "你確定要撤銷這個密鑰?這個操作無法撤回,所有使用該密鑰的應用程式會停止工作。",
"noBotKeys": "機器人未找到。",
"revoke": "撤銷",
"keyName": "密鑰名稱",
"newKeyGenerated": "新密鑰已生成",
"copyKeyHint": "請安全地保存該密鑰,你不會再次看到它。",
"rotateKey": "旋轉密鑰",
"rotateBotKey": "旋轉密鑰",
"rotateBotKeyHint": "你確認要旋轉這個密鑰?久的密鑰會立即失效,該操作無法撤銷。",
"webFeedArticleCount": {
"zero": "無文章",
"one": "{} 文章",
"other": "{} 文章"
},
"webFeedSubscribed": "你已經訂閱了這個來源",
"webFeedUnsubscribed": "你已經取消訂閱這個來源",
"appDetails": "應用程式詳情", "appDetails": "應用程式詳情",
"secrets": "密鑰", "secrets": "密鑰",
"appNotFound": "找不到應用程式。", "appNotFound": "找不到應用程式。",
@@ -830,5 +973,109 @@
"newSecretGenerated": "已產生新密鑰", "newSecretGenerated": "已產生新密鑰",
"copySecretHint": "請複製此密鑰並將其存放在安全的地方。您將無法再次看到它。", "copySecretHint": "請複製此密鑰並將其存放在安全的地方。您將無法再次看到它。",
"expiresIn": "過期時間(秒)", "expiresIn": "過期時間(秒)",
"isOidc": "OIDC 相容" "isOidc": "OIDC 相容",
"pinPost": "置頂帖子",
"unpinPost": "取消置頂",
"pinnedPost": "已置顶",
"publisherPage": "發布者頁面",
"realmPage": "領域頁面",
"replyPage": "回覆頁面",
"pinPostPublisherHint": "將這篇文章置顶到您的發佈者頁面",
"pinPostRealmHint": "將這篇文章置顶到領域頁面",
"pinPostRealmDisabledHint": "這個帖子不屬於任何領域",
"pinPostReplyHint": "將這篇文章置顶到回覆頁面",
"pinPostReplyDisabledHint": "這篇帖子不是回覆",
"pin": "置顶",
"unpinPostHint": "你確定要取消置顶這篇帖子嗎?",
"all": "所有",
"statusPresent": "至今",
"accountAutomated": "機器人",
"chatBreakClearButton": "清除",
"chatBreak5m": "5 分鐘",
"chatBreak10m": "10 分鐘",
"chatBreak15m": "15 分鐘",
"chatBreak30m": "30 分鐘",
"chatBreakCustomMinutes": "自訂(分鐘)",
"errorGeneric": "錯誤:{}",
"searchMessages": "搜尋消息",
"messagesCount": "{} 消息",
"dotSeparator": ".",
"roleValidationHint": "成員角色必須設置在0到100之間",
"searchMessagesHint": "搜尋消息…",
"searchLinks": "連結",
"searchAttachments": "附件",
"noMessagesFound": "未找到消息",
"openInBrowser": "在瀏覽器打開",
"highlightPost": "精選帖子",
"filters": "過濾器",
"apply": "應用",
"pubName": "題目名稱",
"realm": "領域",
"shuffle": "隨機",
"pinned": "已置顶",
"noResultsFound": "未找到結果",
"toggleFilters": "切換篩檢器",
"notableDayNext": "距離 {} 還有",
"expandPoll": "展開投票",
"collapsePoll": "摺叠投票",
"embedView": "嵌入視圖",
"embedUri": "嵌入URL",
"aspectRatio": "縱橫比",
"renderer": "渲染器",
"addEmbed": "添加嵌入",
"editEmbed": "編輯嵌入",
"deleteEmbed": "刪除嵌入",
"deleteEmbedConfirm": "您確定要刪除這個嵌入嗎?",
"currentEmbed": "當前嵌入",
"noEmbed": "尚未嵌入",
"save": "保存",
"webView": "網頁視圖",
"settingsDefaultPool": "預設檔案池",
"settingsDefaultPoolHelper": "選擇文件上傳的默認儲存池",
"uploadFile": "上傳檔案",
"authDeviceChallenges": "設備活動",
"authDeviceHint": "向左滑動以編輯標籤,向右滑動以登出設備。",
"settingsMessageDisplayStyle": "訊息顯示樣式",
"auto": "自動",
"manual": "手動",
"iframeCode": "Iframe 代碼",
"iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">",
"parseIframe": "解析 Iframe",
"messageActions": "消息選項",
"viewEmbedLoadHint": "點擊以載入",
"levelingStage1": "新手",
"levelingStage2": "學徒",
"levelingStage3": "學徒工",
"levelingStage4": "熟練",
"levelingStage5": "專家",
"levelingStage6": "大師",
"levelingStage7": "宗師",
"levelingStage8": "傳說",
"levelingStage9": "神話",
"levelingStage10": "不朽",
"levelingStage11": "神聖",
"levelingStage12": "超凡",
"uploadAttachment": "上傳附件",
"attachmentPreview": "附件預覽",
"selectPool": "選擇檔案池",
"choosePool": "選擇一個檔案池",
"errorLoadingPools": "加載池時出錯",
"quotaCostInfo": "這次上傳將消耗 {} 配額點",
"uploadConstraints": "上傳限制",
"fileSizeExceeded": "檔案大小超過了 {} 的最大限制",
"fileTypeNotAccepted": "該文件類型不被此池接受",
"files": "附件",
"confirmDeleteFile": "你確定要刪除這個文件嗎?",
"deleteFile": "刪除文件",
"failedToDeleteFile": "刪除文件失敗",
"drive": "雲盤",
"allPools": "全部的池",
"includeRecycled": "包含已回收文件",
"confirmDeleteRecycledFiles": "您確定要刪除所有回收的檔案嗎?",
"deleteRecycledFiles": "刪除已回收檔案",
"recycledFilesDeleted": "已回收檔案刪除成功",
"failedToDeleteRecycledFiles": "已回收檔案刪除失敗",
"upload": "上傳",
"postCompose": "撰寫帖子",
"postPublish": "發佈帖子"
} }

View File

@@ -1,5 +1,7 @@
PODS: PODS:
- Alamofire (5.10.2) - Alamofire (5.10.2)
- app_links (6.4.1):
- Flutter
- connectivity_plus (0.0.1): - connectivity_plus (0.0.1):
- Flutter - Flutter
- croppy (0.0.1): - croppy (0.0.1):
@@ -50,18 +52,18 @@ PODS:
- Firebase/Messaging (12.2.0): - Firebase/Messaging (12.2.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseMessaging (~> 12.2.0) - FirebaseMessaging (~> 12.2.0)
- firebase_analytics (12.0.1): - firebase_analytics (12.0.2):
- firebase_core - firebase_core
- FirebaseAnalytics (= 12.2.0) - FirebaseAnalytics (= 12.2.0)
- Flutter - Flutter
- firebase_core (4.1.0): - firebase_core (4.1.1):
- Firebase/CoreOnly (= 12.2.0) - Firebase/CoreOnly (= 12.2.0)
- Flutter - Flutter
- firebase_crashlytics (5.0.1): - firebase_crashlytics (5.0.2):
- Firebase/Crashlytics (= 12.2.0) - Firebase/Crashlytics (= 12.2.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_messaging (16.0.1): - firebase_messaging (16.0.2):
- Firebase/Messaging (= 12.2.0) - Firebase/Messaging (= 12.2.0)
- firebase_core - firebase_core
- Flutter - Flutter
@@ -215,7 +217,7 @@ PODS:
- Flutter - Flutter
- irondash_engine_context (0.0.1): - irondash_engine_context (0.0.1):
- Flutter - Flutter
- Kingfisher (8.5.0) - Kingfisher (8.6.0)
- livekit_client (2.5.0): - livekit_client (2.5.0):
- Flutter - Flutter
- flutter_webrtc - flutter_webrtc
@@ -252,9 +254,9 @@ PODS:
- record_ios (1.1.0): - record_ios (1.1.0):
- Flutter - Flutter
- SAMKeychain (1.5.3) - SAMKeychain (1.5.3)
- SDWebImage (5.21.2): - SDWebImage (5.21.3):
- SDWebImage/Core (= 5.21.2) - SDWebImage/Core (= 5.21.3)
- SDWebImage/Core (5.21.2) - SDWebImage/Core (5.21.3)
- share_plus (0.0.1): - share_plus (0.0.1):
- Flutter - Flutter
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
@@ -293,6 +295,8 @@ PODS:
- super_native_extensions (0.0.1): - super_native_extensions (0.0.1):
- Flutter - Flutter
- SwiftyGif (5.4.5) - SwiftyGif (5.4.5)
- syncfusion_flutter_pdfviewer (0.0.1):
- Flutter
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- volume_controller (0.0.1): - volume_controller (0.0.1):
@@ -303,6 +307,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- Alamofire - Alamofire
- app_links (from `.symlinks/plugins/app_links/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- croppy (from `.symlinks/plugins/croppy/ios`) - croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
@@ -344,6 +349,7 @@ DEPENDENCIES:
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
@@ -379,6 +385,8 @@ SPEC REPOS:
- WebRTC-SDK - WebRTC-SDK
EXTERNAL SOURCES: EXTERNAL SOURCES:
app_links:
:path: ".symlinks/plugins/app_links/ios"
connectivity_plus: connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios" :path: ".symlinks/plugins/connectivity_plus/ios"
croppy: croppy:
@@ -459,6 +467,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
super_native_extensions: super_native_extensions:
:path: ".symlinks/plugins/super_native_extensions/ios" :path: ".symlinks/plugins/super_native_extensions/ios"
syncfusion_flutter_pdfviewer:
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
volume_controller: volume_controller:
@@ -468,6 +478,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30 croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
@@ -476,10 +487,10 @@ SPEC CHECKSUMS:
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6 file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
Firebase: 26f6f8d460603af3df970ad505b16b15f5e2e9a1 Firebase: 26f6f8d460603af3df970ad505b16b15f5e2e9a1
firebase_analytics: 111ff65791a430356bd6c7e4d7339537fc6a15ae firebase_analytics: 8c78ce6224e0623152379d6cc7ef3d9098477b7e
firebase_core: 3ff52146406557dddd01d570e807e203ec7e1302 firebase_core: dfc4bd142bee4bc53a5d482397ca322c2dd3165d
firebase_crashlytics: 3637078b718a52dc9fb4d64e37c969e86b87ff6f firebase_crashlytics: e55dcf895eed0dd87c447dd5aff8db7f1bb8bbdb
firebase_messaging: 3dcc998dd98e1e54af75d0cccae8606eba43553c firebase_messaging: 38c66c1184695b0c87abe51d40fc590718abed1a
FirebaseAnalytics: e04e23bc070e3014aa5cf4980f9df7ce5cd79ec8 FirebaseAnalytics: e04e23bc070e3014aa5cf4980f9df7ce5cd79ec8
FirebaseCore: 311c48a147ad4a0ab7febbaed89e8025c67510cd FirebaseCore: 311c48a147ad4a0ab7febbaed89e8025c67510cd
FirebaseCoreExtension: 73af080c22a2f7b44cefa391dc08f7e4ee162cb5 FirebaseCoreExtension: 73af080c22a2f7b44cefa391dc08f7e4ee162cb5
@@ -507,7 +518,7 @@ SPEC CHECKSUMS:
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
Kingfisher: ff0d31a1f07bdff6a1ebb3ba08b8e6e567b6500c Kingfisher: 64278f126a815d0e2d391cdf71311b85882c4de0
livekit_client: a6f5fa86ac28ccd7ded53626a5379961db311ab4 livekit_client: a6f5fa86ac28ccd7ded53626a5379961db311ab4
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
@@ -524,7 +535,7 @@ SPEC CHECKSUMS:
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
@@ -533,6 +544,7 @@ SPEC CHECKSUMS:
sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556

View File

@@ -12,7 +12,7 @@ import UIKit
UNUserNotificationCenter.current().delegate = notifyDelegate UNUserNotificationCenter.current().delegate = notifyDelegate
let replyableMessageCategory = UNNotificationCategory( let replyableMessageCategory = UNNotificationCategory(
identifier: "REPLYABLE_MESSAGE", identifier: "CHAT_MESSAGE",
actions: [ actions: [
UNTextInputNotificationAction( UNTextInputNotificationAction(
identifier: "reply_action", identifier: "reply_action",

View File

@@ -36,6 +36,14 @@
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array> </array>
</dict> </dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLSchemes</key>
<array>
<string>solian</string>
</array>
</dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>

View File

@@ -47,7 +47,6 @@ class NotificationService: UNNotificationServiceExtension {
private func processNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws { private func processNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws {
switch content.userInfo["type"] as? String { switch content.userInfo["type"] as? String {
case "messages.new": case "messages.new":
content.categoryIdentifier = "REPLYABLE_MESSAGE"
try handleMessagingNotification(request: request, content: content) try handleMessagingNotification(request: request, content: content)
default: default:
try handleDefaultNotification(content: content) try handleDefaultNotification(content: content)
@@ -89,7 +88,12 @@ class NotificationService: UNNotificationServiceExtension {
let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body) let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body)
self.donateInteraction(for: intent) self.donateInteraction(for: intent)
let updatedContent = try? request.content.updating(from: intent) let updatedContent = try? request.content.updating(from: intent)
self.contentHandler?(updatedContent ?? content) content.categoryIdentifier = "CHAT_MESSAGE"
if let updatedContent = updatedContent {
self.contentHandler?(updatedContent)
} else {
self.contentHandler?(content)
}
}) })
} }

View File

@@ -1,6 +1,7 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/wasm.dart'; import 'package:drift/wasm.dart';
import 'package:island/database/drift_db.dart'; import 'package:island/database/drift_db.dart';
import 'package:island/talker.dart';
AppDatabase constructDb() { AppDatabase constructDb() {
return AppDatabase(connectOnWeb()); return AppDatabase(connectOnWeb());
@@ -9,12 +10,17 @@ AppDatabase constructDb() {
DatabaseConnection connectOnWeb() { DatabaseConnection connectOnWeb() {
return DatabaseConnection.delayed( return DatabaseConnection.delayed(
Future(() async { Future(() async {
try {
final result = await WasmDatabase.open( final result = await WasmDatabase.open(
databaseName: 'solar_network_data', databaseName: 'solar_network_data',
sqlite3Uri: Uri.parse('sqlite3.wasm'), sqlite3Uri: Uri.parse('sqlite3.wasm'),
driftWorkerUri: Uri.parse('drift_worker.dart.js'), driftWorkerUri: Uri.parse('drift_worker.dart.js'),
); );
return result.resolvedExecutor; return result.resolvedExecutor;
} catch (e, stackTrace) {
talker.error('Failed to open WASM database...', e, stackTrace);
rethrow;
}
}), }),
); );
} }

View File

@@ -33,17 +33,27 @@ class AppDatabase extends _$AppDatabase {
await _migrateToVersion6(m); await _migrateToVersion6(m);
} }
if (from < 7) { if (from < 7) {
// Add new columns from SnChatMessage // Add new columns from SnChatMessage, ignore if they already exist
await m.addColumn(chatMessages, chatMessages.updatedAt); final columnsToAdd = [
await m.addColumn(chatMessages, chatMessages.deletedAt); chatMessages.updatedAt,
await m.addColumn(chatMessages, chatMessages.type); chatMessages.deletedAt,
await m.addColumn(chatMessages, chatMessages.meta); chatMessages.type,
await m.addColumn(chatMessages, chatMessages.membersMentioned); chatMessages.meta,
await m.addColumn(chatMessages, chatMessages.editedAt); chatMessages.membersMentioned,
await m.addColumn(chatMessages, chatMessages.attachments); chatMessages.editedAt,
await m.addColumn(chatMessages, chatMessages.reactions); chatMessages.attachments,
await m.addColumn(chatMessages, chatMessages.repliedMessageId); chatMessages.reactions,
await m.addColumn(chatMessages, chatMessages.forwardedMessageId); chatMessages.repliedMessageId,
chatMessages.forwardedMessageId,
];
for (final column in columnsToAdd) {
try {
await m.addColumn(chatMessages, column);
} catch (e) {
// Column already exists, skip
}
}
} }
}, },
); );

View File

@@ -1,6 +1,4 @@
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:croppy/croppy.dart'; import 'package:croppy/croppy.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
@@ -8,16 +6,15 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker_android/image_picker_android.dart'; import 'package:image_picker_android/image_picker_android.dart';
import 'package:island/talker.dart';
import 'package:island/firebase_options.dart'; import 'package:island/firebase_options.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/theme.dart'; import 'package:island/pods/theme.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.dart'; import 'package:island/route.dart';
@@ -29,18 +26,21 @@ import 'package:relative_time/relative_time.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:talker_flutter/talker_flutter.dart';
import 'package:talker_riverpod_logger/talker_riverpod_logger.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:window_manager/window_manager.dart';
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
log('Handling a background message: ${message.messageId}'); talker.info('Handling a background message: ${message.messageId}');
} }
void main() async { void main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
log( talker.info(
"[SplashScreen] Keeping the flash screen to loading other resources...", "[SplashScreen] Keeping the flash screen to loading other resources...",
); );
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
@@ -73,23 +73,24 @@ void main() async {
} }
} }
log("[SplashScreen] Firebase is ready!"); talker.info("[SplashScreen] Firebase is ready!");
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
} }
try { try {
log("[SplashScreen] Loading timezone database..."); talker.info("[SplashScreen] Loading timezone database...");
await initializeTzdb(); await initializeTzdb();
log("[SplashScreen] Time zone database was loaded!"); talker.info("[SplashScreen] Time zone database was loaded!");
} catch (err) { } catch (err) {
log("[SplashScreen] Failed to load timezone database... $err"); talker.error("[SplashScreen] Failed to load timezone database... $err");
} }
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) { if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) {
doWhenWindowReady(() { await windowManager.ensureInitialized();
const defaultSize = Size(360, 640); const defaultSize = Size(360, 640);
// Get saved window size from preferences // Get saved window size from preferences
@@ -105,16 +106,26 @@ void main() async {
initialSize = Size(width, height); initialSize = Size(width, height);
} }
} catch (e) { } catch (e) {
log("[SplashScreen] Failed to parse saved window size: $e"); talker.error("[SplashScreen] Failed to parse saved window size: $e");
initialSize = defaultSize; initialSize = defaultSize;
} }
} }
appWindow.minSize = defaultSize; WindowOptions windowOptions = WindowOptions(
appWindow.size = initialSize; size: initialSize,
appWindow.alignment = Alignment.center; center: true,
appWindow.show(); backgroundColor: Colors.transparent,
log( skipTaskbar: false,
titleBarStyle: TitleBarStyle.hidden,
windowButtonVisibility: true,
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.setMinimumSize(defaultSize);
await windowManager.show();
await windowManager.focus();
final opacity = prefs.getDouble(kAppWindowOpacity) ?? 1.0;
await windowManager.setOpacity(opacity);
talker.info(
"[SplashScreen] Desktop window is ready with size: ${initialSize.width}x${initialSize.height}", "[SplashScreen] Desktop window is ready with size: ${initialSize.width}x${initialSize.height}",
); );
}); });
@@ -126,16 +137,27 @@ void main() async {
if (imagePickerImplementation is ImagePickerAndroid) { if (imagePickerImplementation is ImagePickerAndroid) {
imagePickerImplementation.useAndroidPhotoPicker = true; imagePickerImplementation.useAndroidPhotoPicker = true;
} }
log("[SplashScreen] Android image picker is ready!"); talker.info("[SplashScreen] Android image picker is ready!");
} }
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
FlutterNativeSplash.remove(); FlutterNativeSplash.remove();
log("[SplashScreen] Now hiding the splash screen..."); talker.info("[SplashScreen] Now hiding the splash screen...");
} }
runApp( runApp(
ProviderScope( ProviderScope(
observers: [
TalkerRiverpodObserver(
talker: talker,
settings: TalkerRiverpodLoggerSettings(
printProviderAdded: false,
printProviderDisposed: false,
printProviderUpdated: false,
printStateFullData: false,
),
),
],
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
child: Directionality( child: Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
@@ -144,6 +166,10 @@ void main() async {
Locale('en', 'US'), Locale('en', 'US'),
Locale('zh', 'CN'), Locale('zh', 'CN'),
Locale('zh', 'TW'), Locale('zh', 'TW'),
Locale('zh', 'OG'),
Locale('ja', 'JP'),
Locale('ko', 'KR'),
Locale('es', 'ES'),
], ],
path: 'assets/i18n', path: 'assets/i18n',
fallbackLocale: Locale('en', 'US'), fallbackLocale: Locale('en', 'US'),
@@ -165,6 +191,21 @@ class IslandApp extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(themeProvider); final theme = ref.watch(themeProvider);
final settings = ref.watch(appSettingsNotifierProvider);
// Convert string theme mode to ThemeMode enum
ThemeMode getThemeMode() {
final themeMode = settings.themeMode ?? 'system';
switch (themeMode) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
case 'system':
default:
return ThemeMode.system;
}
}
void handleMessage(RemoteMessage notification) { void handleMessage(RemoteMessage notification) {
if (notification.data['meta']?['action_uri'] != null) { if (notification.data['meta']?['action_uri'] != null) {
@@ -201,7 +242,9 @@ class IslandApp extends HookConsumerWidget {
final onMessageSubscription = FirebaseMessaging.onMessage.listen(( final onMessageSubscription = FirebaseMessaging.onMessage.listen((
message, message,
) { ) {
log('Foreground message received: ${message.messageId}'); talker.info(
'[Notification] foreground message received: ${message.messageId}',
);
handleMessage(message); handleMessage(message);
}); });
@@ -215,7 +258,7 @@ class IslandApp extends HookConsumerWidget {
// Load userinfo // Load userinfo
final userNotifier = ref.read(userInfoProvider.notifier); final userNotifier = ref.read(userInfoProvider.notifier);
ref.listen(websocketStateProvider, (_, state) { ref.listen(websocketStateProvider, (_, state) {
log('[WebSocket] $state'); talker.info('[WebSocket] $state');
}); });
Future(() { Future(() {
userNotifier.fetchUser().then((_) { userNotifier.fetchUser().then((_) {
@@ -235,9 +278,10 @@ class IslandApp extends HookConsumerWidget {
final router = ref.watch(routerProvider); final router = ref.watch(routerProvider);
return MaterialApp.router( return MaterialApp.router(
theme: theme?.light, color: Colors.transparent,
darkTheme: theme?.dark, theme: theme.light,
themeMode: ThemeMode.system, darkTheme: theme.dark,
themeMode: getThemeMode(),
routerConfig: router, routerConfig: router,
supportedLocales: context.supportedLocales, supportedLocales: context.supportedLocales,
scrollBehavior: AppScrollBehavior(), scrollBehavior: AppScrollBehavior(),
@@ -252,9 +296,15 @@ class IslandApp extends HookConsumerWidget {
key: globalOverlay, key: globalOverlay,
initialEntries: [ initialEntries: [
OverlayEntry( OverlayEntry(
builder: builder: (_) {
(_) => return TalkerWrapper(
WindowScaffold(child: child ?? const SizedBox.shrink()), talker: talker,
options: const TalkerWrapperOptions(enableErrorAlerts: true),
child: WindowScaffold(
child: child ?? const SizedBox.shrink(),
),
);
},
), ),
], ],
); );

View File

@@ -98,6 +98,7 @@ sealed class SnAccountStatus with _$SnAccountStatus {
required bool isNotDisturb, required bool isNotDisturb,
required bool isCustomized, required bool isCustomized,
@Default("") String label, @Default("") String label,
required Map<String, dynamic>? meta,
required DateTime? clearedAt, required DateTime? clearedAt,
required String accountId, required String accountId,
required DateTime createdAt, required DateTime createdAt,

View File

@@ -1053,7 +1053,7 @@ $SnVerificationMarkCopyWith<$Res>? get verification {
/// @nodoc /// @nodoc
mixin _$SnAccountStatus { mixin _$SnAccountStatus {
String get id; int get attitude; bool get isOnline; bool get isInvisible; bool get isNotDisturb; bool get isCustomized; String get label; DateTime? get clearedAt; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; int get attitude; bool get isOnline; bool get isInvisible; bool get isNotDisturb; bool get isCustomized; String get label; Map<String, dynamic>? get meta; DateTime? get clearedAt; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnAccountStatus /// Create a copy of SnAccountStatus
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -1066,16 +1066,16 @@ $SnAccountStatusCopyWith<SnAccountStatus> get copyWith => _$SnAccountStatusCopyW
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountStatus&&(identical(other.id, id) || other.id == id)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.isInvisible, isInvisible) || other.isInvisible == isInvisible)&&(identical(other.isNotDisturb, isNotDisturb) || other.isNotDisturb == isNotDisturb)&&(identical(other.isCustomized, isCustomized) || other.isCustomized == isCustomized)&&(identical(other.label, label) || other.label == label)&&(identical(other.clearedAt, clearedAt) || other.clearedAt == clearedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountStatus&&(identical(other.id, id) || other.id == id)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.isInvisible, isInvisible) || other.isInvisible == isInvisible)&&(identical(other.isNotDisturb, isNotDisturb) || other.isNotDisturb == isNotDisturb)&&(identical(other.isCustomized, isCustomized) || other.isCustomized == isCustomized)&&(identical(other.label, label) || other.label == label)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.clearedAt, clearedAt) || other.clearedAt == clearedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,id,attitude,isOnline,isInvisible,isNotDisturb,isCustomized,label,clearedAt,accountId,createdAt,updatedAt,deletedAt); int get hashCode => Object.hash(runtimeType,id,attitude,isOnline,isInvisible,isNotDisturb,isCustomized,label,const DeepCollectionEquality().hash(meta),clearedAt,accountId,createdAt,updatedAt,deletedAt);
@override @override
String toString() { String toString() {
return 'SnAccountStatus(id: $id, attitude: $attitude, isOnline: $isOnline, isInvisible: $isInvisible, isNotDisturb: $isNotDisturb, isCustomized: $isCustomized, label: $label, clearedAt: $clearedAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; return 'SnAccountStatus(id: $id, attitude: $attitude, isOnline: $isOnline, isInvisible: $isInvisible, isNotDisturb: $isNotDisturb, isCustomized: $isCustomized, label: $label, meta: $meta, clearedAt: $clearedAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
} }
@@ -1086,7 +1086,7 @@ abstract mixin class $SnAccountStatusCopyWith<$Res> {
factory $SnAccountStatusCopyWith(SnAccountStatus value, $Res Function(SnAccountStatus) _then) = _$SnAccountStatusCopyWithImpl; factory $SnAccountStatusCopyWith(SnAccountStatus value, $Res Function(SnAccountStatus) _then) = _$SnAccountStatusCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, Map<String, dynamic>? meta, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
}); });
@@ -1103,7 +1103,7 @@ class _$SnAccountStatusCopyWithImpl<$Res>
/// Create a copy of SnAccountStatus /// Create a copy of SnAccountStatus
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? attitude = null,Object? isOnline = null,Object? isInvisible = null,Object? isNotDisturb = null,Object? isCustomized = null,Object? label = null,Object? clearedAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? attitude = null,Object? isOnline = null,Object? isInvisible = null,Object? isNotDisturb = null,Object? isCustomized = null,Object? label = null,Object? meta = freezed,Object? clearedAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,attitude: null == attitude ? _self.attitude : attitude // ignore: cast_nullable_to_non_nullable as String,attitude: null == attitude ? _self.attitude : attitude // ignore: cast_nullable_to_non_nullable
@@ -1112,7 +1112,8 @@ as bool,isInvisible: null == isInvisible ? _self.isInvisible : isInvisible // ig
as bool,isNotDisturb: null == isNotDisturb ? _self.isNotDisturb : isNotDisturb // ignore: cast_nullable_to_non_nullable as bool,isNotDisturb: null == isNotDisturb ? _self.isNotDisturb : isNotDisturb // ignore: cast_nullable_to_non_nullable
as bool,isCustomized: null == isCustomized ? _self.isCustomized : isCustomized // ignore: cast_nullable_to_non_nullable as bool,isCustomized: null == isCustomized ? _self.isCustomized : isCustomized // ignore: cast_nullable_to_non_nullable
as bool,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable as bool,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as String,clearedAt: freezed == clearedAt ? _self.clearedAt : clearedAt // ignore: cast_nullable_to_non_nullable as String,meta: freezed == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,clearedAt: freezed == clearedAt ? _self.clearedAt : clearedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as String,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,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
@@ -1199,10 +1200,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, Map<String, dynamic>? meta, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _SnAccountStatus() when $default != null: case _SnAccountStatus() when $default != null:
return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.meta,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return orElse(); return orElse();
} }
@@ -1220,10 +1221,10 @@ return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.i
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, Map<String, dynamic>? meta, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _SnAccountStatus(): case _SnAccountStatus():
return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);} return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.meta,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);}
} }
/// A variant of `when` that fallback to returning `null` /// A variant of `when` that fallback to returning `null`
/// ///
@@ -1237,10 +1238,10 @@ return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.i
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, Map<String, dynamic>? meta, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _SnAccountStatus() when $default != null: case _SnAccountStatus() when $default != null:
return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.isNotDisturb,_that.isCustomized,_that.label,_that.meta,_that.clearedAt,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null; return null;
} }
@@ -1252,7 +1253,7 @@ return $default(_that.id,_that.attitude,_that.isOnline,_that.isInvisible,_that.i
@JsonSerializable() @JsonSerializable()
class _SnAccountStatus implements SnAccountStatus { class _SnAccountStatus implements SnAccountStatus {
const _SnAccountStatus({required this.id, required this.attitude, required this.isOnline, required this.isInvisible, required this.isNotDisturb, required this.isCustomized, this.label = "", required this.clearedAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}); const _SnAccountStatus({required this.id, required this.attitude, required this.isOnline, required this.isInvisible, required this.isNotDisturb, required this.isCustomized, this.label = "", required final Map<String, dynamic>? meta, required this.clearedAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta;
factory _SnAccountStatus.fromJson(Map<String, dynamic> json) => _$SnAccountStatusFromJson(json); factory _SnAccountStatus.fromJson(Map<String, dynamic> json) => _$SnAccountStatusFromJson(json);
@override final String id; @override final String id;
@@ -1262,6 +1263,15 @@ class _SnAccountStatus implements SnAccountStatus {
@override final bool isNotDisturb; @override final bool isNotDisturb;
@override final bool isCustomized; @override final bool isCustomized;
@override@JsonKey() final String label; @override@JsonKey() final String label;
final Map<String, dynamic>? _meta;
@override Map<String, dynamic>? get meta {
final value = _meta;
if (value == null) return null;
if (_meta is EqualUnmodifiableMapView) return _meta;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(value);
}
@override final DateTime? clearedAt; @override final DateTime? clearedAt;
@override final String accountId; @override final String accountId;
@override final DateTime createdAt; @override final DateTime createdAt;
@@ -1281,16 +1291,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountStatus&&(identical(other.id, id) || other.id == id)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.isInvisible, isInvisible) || other.isInvisible == isInvisible)&&(identical(other.isNotDisturb, isNotDisturb) || other.isNotDisturb == isNotDisturb)&&(identical(other.isCustomized, isCustomized) || other.isCustomized == isCustomized)&&(identical(other.label, label) || other.label == label)&&(identical(other.clearedAt, clearedAt) || other.clearedAt == clearedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountStatus&&(identical(other.id, id) || other.id == id)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.isInvisible, isInvisible) || other.isInvisible == isInvisible)&&(identical(other.isNotDisturb, isNotDisturb) || other.isNotDisturb == isNotDisturb)&&(identical(other.isCustomized, isCustomized) || other.isCustomized == isCustomized)&&(identical(other.label, label) || other.label == label)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.clearedAt, clearedAt) || other.clearedAt == clearedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,id,attitude,isOnline,isInvisible,isNotDisturb,isCustomized,label,clearedAt,accountId,createdAt,updatedAt,deletedAt); int get hashCode => Object.hash(runtimeType,id,attitude,isOnline,isInvisible,isNotDisturb,isCustomized,label,const DeepCollectionEquality().hash(_meta),clearedAt,accountId,createdAt,updatedAt,deletedAt);
@override @override
String toString() { String toString() {
return 'SnAccountStatus(id: $id, attitude: $attitude, isOnline: $isOnline, isInvisible: $isInvisible, isNotDisturb: $isNotDisturb, isCustomized: $isCustomized, label: $label, clearedAt: $clearedAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; return 'SnAccountStatus(id: $id, attitude: $attitude, isOnline: $isOnline, isInvisible: $isInvisible, isNotDisturb: $isNotDisturb, isCustomized: $isCustomized, label: $label, meta: $meta, clearedAt: $clearedAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
} }
@@ -1301,7 +1311,7 @@ abstract mixin class _$SnAccountStatusCopyWith<$Res> implements $SnAccountStatus
factory _$SnAccountStatusCopyWith(_SnAccountStatus value, $Res Function(_SnAccountStatus) _then) = __$SnAccountStatusCopyWithImpl; factory _$SnAccountStatusCopyWith(_SnAccountStatus value, $Res Function(_SnAccountStatus) _then) = __$SnAccountStatusCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, Map<String, dynamic>? meta, DateTime? clearedAt, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
}); });
@@ -1318,7 +1328,7 @@ class __$SnAccountStatusCopyWithImpl<$Res>
/// Create a copy of SnAccountStatus /// Create a copy of SnAccountStatus
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? attitude = null,Object? isOnline = null,Object? isInvisible = null,Object? isNotDisturb = null,Object? isCustomized = null,Object? label = null,Object? clearedAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? attitude = null,Object? isOnline = null,Object? isInvisible = null,Object? isNotDisturb = null,Object? isCustomized = null,Object? label = null,Object? meta = freezed,Object? clearedAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnAccountStatus( return _then(_SnAccountStatus(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,attitude: null == attitude ? _self.attitude : attitude // ignore: cast_nullable_to_non_nullable as String,attitude: null == attitude ? _self.attitude : attitude // ignore: cast_nullable_to_non_nullable
@@ -1327,7 +1337,8 @@ as bool,isInvisible: null == isInvisible ? _self.isInvisible : isInvisible // ig
as bool,isNotDisturb: null == isNotDisturb ? _self.isNotDisturb : isNotDisturb // ignore: cast_nullable_to_non_nullable as bool,isNotDisturb: null == isNotDisturb ? _self.isNotDisturb : isNotDisturb // ignore: cast_nullable_to_non_nullable
as bool,isCustomized: null == isCustomized ? _self.isCustomized : isCustomized // ignore: cast_nullable_to_non_nullable as bool,isCustomized: null == isCustomized ? _self.isCustomized : isCustomized // ignore: cast_nullable_to_non_nullable
as bool,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable as bool,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as String,clearedAt: freezed == clearedAt ? _self.clearedAt : clearedAt // ignore: cast_nullable_to_non_nullable as String,meta: freezed == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,clearedAt: freezed == clearedAt ? _self.clearedAt : clearedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as String,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,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable

View File

@@ -158,6 +158,7 @@ _SnAccountStatus _$SnAccountStatusFromJson(Map<String, dynamic> json) =>
isNotDisturb: json['is_not_disturb'] as bool, isNotDisturb: json['is_not_disturb'] as bool,
isCustomized: json['is_customized'] as bool, isCustomized: json['is_customized'] as bool,
label: json['label'] as String? ?? "", label: json['label'] as String? ?? "",
meta: json['meta'] as Map<String, dynamic>?,
clearedAt: clearedAt:
json['cleared_at'] == null json['cleared_at'] == null
? null ? null
@@ -180,6 +181,7 @@ Map<String, dynamic> _$SnAccountStatusToJson(_SnAccountStatus instance) =>
'is_not_disturb': instance.isNotDisturb, 'is_not_disturb': instance.isNotDisturb,
'is_customized': instance.isCustomized, 'is_customized': instance.isCustomized,
'label': instance.label, 'label': instance.label,
'meta': instance.meta,
'cleared_at': instance.clearedAt?.toIso8601String(), 'cleared_at': instance.clearedAt?.toIso8601String(),
'account_id': instance.accountId, 'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(), 'created_at': instance.createdAt.toIso8601String(),

View File

@@ -1,4 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/file_pool.dart';
part 'file.freezed.dart'; part 'file.freezed.dart';
part 'file.g.dart'; part 'file.g.dart';
@@ -13,6 +14,7 @@ sealed class UniversalFile with _$UniversalFile {
required dynamic data, required dynamic data,
required UniversalFileType type, required UniversalFileType type,
@Default(false) bool isLink, @Default(false) bool isLink,
String? displayName,
}) = _UniversalFile; }) = _UniversalFile;
factory UniversalFile.fromJson(Map<String, dynamic> json) => factory UniversalFile.fromJson(Map<String, dynamic> json) =>
@@ -30,6 +32,7 @@ sealed class UniversalFile with _$UniversalFile {
'video' => UniversalFileType.video, 'video' => UniversalFileType.video,
_ => UniversalFileType.file, _ => UniversalFileType.file,
}, },
displayName: attachment.name,
); );
} }
} }
@@ -42,6 +45,7 @@ sealed class SnCloudFile with _$SnCloudFile {
required String? description, required String? description,
required Map<String, dynamic>? fileMeta, required Map<String, dynamic>? fileMeta,
required Map<String, dynamic>? userMeta, required Map<String, dynamic>? userMeta,
required SnFilePool? pool,
@Default([]) List<int> sensitiveMarks, @Default([]) List<int> sensitiveMarks,
required String? mimeType, required String? mimeType,
required String? hash, required String? hash,

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$UniversalFile { mixin _$UniversalFile {
dynamic get data; UniversalFileType get type; bool get isLink; dynamic get data; UniversalFileType get type; bool get isLink; String? get displayName;
/// Create a copy of UniversalFile /// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,16 +28,16 @@ $UniversalFileCopyWith<UniversalFile> get copyWith => _$UniversalFileCopyWithImp
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)); return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)&&(identical(other.displayName, displayName) || other.displayName == displayName));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink); int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink,displayName);
@override @override
String toString() { String toString() {
return 'UniversalFile(data: $data, type: $type, isLink: $isLink)'; return 'UniversalFile(data: $data, type: $type, isLink: $isLink, displayName: $displayName)';
} }
@@ -48,7 +48,7 @@ abstract mixin class $UniversalFileCopyWith<$Res> {
factory $UniversalFileCopyWith(UniversalFile value, $Res Function(UniversalFile) _then) = _$UniversalFileCopyWithImpl; factory $UniversalFileCopyWith(UniversalFile value, $Res Function(UniversalFile) _then) = _$UniversalFileCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
dynamic data, UniversalFileType type, bool isLink dynamic data, UniversalFileType type, bool isLink, String? displayName
}); });
@@ -65,12 +65,13 @@ class _$UniversalFileCopyWithImpl<$Res>
/// Create a copy of UniversalFile /// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) { @pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,Object? displayName = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable
as bool, as bool,displayName: freezed == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String?,
)); ));
} }
@@ -152,10 +153,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type, bool isLink)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type, bool isLink, String? displayName)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _UniversalFile() when $default != null: case _UniversalFile() when $default != null:
return $default(_that.data,_that.type,_that.isLink);case _: return $default(_that.data,_that.type,_that.isLink,_that.displayName);case _:
return orElse(); return orElse();
} }
@@ -173,10 +174,10 @@ return $default(_that.data,_that.type,_that.isLink);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type, bool isLink) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type, bool isLink, String? displayName) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _UniversalFile(): case _UniversalFile():
return $default(_that.data,_that.type,_that.isLink);} return $default(_that.data,_that.type,_that.isLink,_that.displayName);}
} }
/// A variant of `when` that fallback to returning `null` /// A variant of `when` that fallback to returning `null`
/// ///
@@ -190,10 +191,10 @@ return $default(_that.data,_that.type,_that.isLink);}
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( dynamic data, UniversalFileType type, bool isLink)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( dynamic data, UniversalFileType type, bool isLink, String? displayName)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _UniversalFile() when $default != null: case _UniversalFile() when $default != null:
return $default(_that.data,_that.type,_that.isLink);case _: return $default(_that.data,_that.type,_that.isLink,_that.displayName);case _:
return null; return null;
} }
@@ -205,12 +206,13 @@ return $default(_that.data,_that.type,_that.isLink);case _:
@JsonSerializable() @JsonSerializable()
class _UniversalFile extends UniversalFile { class _UniversalFile extends UniversalFile {
const _UniversalFile({required this.data, required this.type, this.isLink = false}): super._(); const _UniversalFile({required this.data, required this.type, this.isLink = false, this.displayName}): super._();
factory _UniversalFile.fromJson(Map<String, dynamic> json) => _$UniversalFileFromJson(json); factory _UniversalFile.fromJson(Map<String, dynamic> json) => _$UniversalFileFromJson(json);
@override final dynamic data; @override final dynamic data;
@override final UniversalFileType type; @override final UniversalFileType type;
@override@JsonKey() final bool isLink; @override@JsonKey() final bool isLink;
@override final String? displayName;
/// Create a copy of UniversalFile /// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -225,16 +227,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)&&(identical(other.displayName, displayName) || other.displayName == displayName));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink); int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink,displayName);
@override @override
String toString() { String toString() {
return 'UniversalFile(data: $data, type: $type, isLink: $isLink)'; return 'UniversalFile(data: $data, type: $type, isLink: $isLink, displayName: $displayName)';
} }
@@ -245,7 +247,7 @@ abstract mixin class _$UniversalFileCopyWith<$Res> implements $UniversalFileCopy
factory _$UniversalFileCopyWith(_UniversalFile value, $Res Function(_UniversalFile) _then) = __$UniversalFileCopyWithImpl; factory _$UniversalFileCopyWith(_UniversalFile value, $Res Function(_UniversalFile) _then) = __$UniversalFileCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
dynamic data, UniversalFileType type, bool isLink dynamic data, UniversalFileType type, bool isLink, String? displayName
}); });
@@ -262,12 +264,13 @@ class __$UniversalFileCopyWithImpl<$Res>
/// Create a copy of UniversalFile /// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) { @override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,Object? displayName = freezed,}) {
return _then(_UniversalFile( return _then(_UniversalFile(
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable
as bool, as bool,displayName: freezed == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String?,
)); ));
} }
@@ -278,7 +281,7 @@ as bool,
/// @nodoc /// @nodoc
mixin _$SnCloudFile { mixin _$SnCloudFile {
String get id; String get name; String? get description; Map<String, dynamic>? get fileMeta; Map<String, dynamic>? get userMeta; List<int> get sensitiveMarks; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String get name; String? get description; Map<String, dynamic>? get fileMeta; Map<String, dynamic>? get userMeta; SnFilePool? get pool; List<int> get sensitiveMarks; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnCloudFile /// Create a copy of SnCloudFile
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -291,16 +294,16 @@ $SnCloudFileCopyWith<SnCloudFile> get copyWith => _$SnCloudFileCopyWithImpl<SnCl
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.fileMeta, fileMeta)&&const DeepCollectionEquality().equals(other.userMeta, userMeta)&&const DeepCollectionEquality().equals(other.sensitiveMarks, sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.fileMeta, fileMeta)&&const DeepCollectionEquality().equals(other.userMeta, userMeta)&&(identical(other.pool, pool) || other.pool == pool)&&const DeepCollectionEquality().equals(other.sensitiveMarks, sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),const DeepCollectionEquality().hash(sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),pool,const DeepCollectionEquality().hash(sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt);
@override @override
String toString() { String toString() {
return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, pool: $pool, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
} }
@@ -311,11 +314,11 @@ abstract mixin class $SnCloudFileCopyWith<$Res> {
factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl; factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
}); });
$SnFilePoolCopyWith<$Res>? get pool;
} }
/// @nodoc /// @nodoc
@@ -328,14 +331,15 @@ class _$SnCloudFileCopyWithImpl<$Res>
/// Create a copy of SnCloudFile /// Create a copy of SnCloudFile
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? pool = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,fileMeta: freezed == fileMeta ? _self.fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable as String?,fileMeta: freezed == fileMeta ? _self.fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self.userMeta : userMeta // ignore: cast_nullable_to_non_nullable as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self.userMeta : userMeta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,sensitiveMarks: null == sensitiveMarks ? _self.sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable as Map<String, dynamic>?,pool: freezed == pool ? _self.pool : pool // ignore: cast_nullable_to_non_nullable
as SnFilePool?,sensitiveMarks: null == sensitiveMarks ? _self.sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable
as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable
as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable
as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable
@@ -347,7 +351,19 @@ as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ign
as DateTime?, as DateTime?,
)); ));
} }
/// Create a copy of SnCloudFile
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnFilePoolCopyWith<$Res>? get pool {
if (_self.pool == null) {
return null;
}
return $SnFilePoolCopyWith<$Res>(_self.pool!, (value) {
return _then(_self.copyWith(pool: value));
});
}
} }
@@ -426,10 +442,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _SnCloudFile() when $default != null: case _SnCloudFile() when $default != null:
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.pool,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return orElse(); return orElse();
} }
@@ -447,10 +463,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _SnCloudFile(): case _SnCloudFile():
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);} return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.pool,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);}
} }
/// A variant of `when` that fallback to returning `null` /// A variant of `when` that fallback to returning `null`
/// ///
@@ -464,10 +480,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _SnCloudFile() when $default != null: case _SnCloudFile() when $default != null:
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _: return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.pool,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null; return null;
} }
@@ -479,7 +495,7 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM
@JsonSerializable() @JsonSerializable()
class _SnCloudFile implements SnCloudFile { class _SnCloudFile implements SnCloudFile {
const _SnCloudFile({required this.id, required this.name, required this.description, required final Map<String, dynamic>? fileMeta, required final Map<String, dynamic>? userMeta, final List<int> sensitiveMarks = const [], required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.uploadedTo, required this.createdAt, required this.updatedAt, required this.deletedAt}): _fileMeta = fileMeta,_userMeta = userMeta,_sensitiveMarks = sensitiveMarks; const _SnCloudFile({required this.id, required this.name, required this.description, required final Map<String, dynamic>? fileMeta, required final Map<String, dynamic>? userMeta, required this.pool, final List<int> sensitiveMarks = const [], required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.uploadedTo, required this.createdAt, required this.updatedAt, required this.deletedAt}): _fileMeta = fileMeta,_userMeta = userMeta,_sensitiveMarks = sensitiveMarks;
factory _SnCloudFile.fromJson(Map<String, dynamic> json) => _$SnCloudFileFromJson(json); factory _SnCloudFile.fromJson(Map<String, dynamic> json) => _$SnCloudFileFromJson(json);
@override final String id; @override final String id;
@@ -503,6 +519,7 @@ class _SnCloudFile implements SnCloudFile {
return EqualUnmodifiableMapView(value); return EqualUnmodifiableMapView(value);
} }
@override final SnFilePool? pool;
final List<int> _sensitiveMarks; final List<int> _sensitiveMarks;
@override@JsonKey() List<int> get sensitiveMarks { @override@JsonKey() List<int> get sensitiveMarks {
if (_sensitiveMarks is EqualUnmodifiableListView) return _sensitiveMarks; if (_sensitiveMarks is EqualUnmodifiableListView) return _sensitiveMarks;
@@ -532,16 +549,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&const DeepCollectionEquality().equals(other._sensitiveMarks, _sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&(identical(other.pool, pool) || other.pool == pool)&&const DeepCollectionEquality().equals(other._sensitiveMarks, _sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),const DeepCollectionEquality().hash(_sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt); int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),pool,const DeepCollectionEquality().hash(_sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt);
@override @override
String toString() { String toString() {
return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, pool: $pool, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
} }
@@ -552,11 +569,11 @@ abstract mixin class _$SnCloudFileCopyWith<$Res> implements $SnCloudFileCopyWith
factory _$SnCloudFileCopyWith(_SnCloudFile value, $Res Function(_SnCloudFile) _then) = __$SnCloudFileCopyWithImpl; factory _$SnCloudFileCopyWith(_SnCloudFile value, $Res Function(_SnCloudFile) _then) = __$SnCloudFileCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
}); });
@override $SnFilePoolCopyWith<$Res>? get pool;
} }
/// @nodoc /// @nodoc
@@ -569,14 +586,15 @@ class __$SnCloudFileCopyWithImpl<$Res>
/// Create a copy of SnCloudFile /// Create a copy of SnCloudFile
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? pool = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnCloudFile( return _then(_SnCloudFile(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,fileMeta: freezed == fileMeta ? _self._fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable as String?,fileMeta: freezed == fileMeta ? _self._fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self._userMeta : userMeta // ignore: cast_nullable_to_non_nullable as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self._userMeta : userMeta // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,sensitiveMarks: null == sensitiveMarks ? _self._sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable as Map<String, dynamic>?,pool: freezed == pool ? _self.pool : pool // ignore: cast_nullable_to_non_nullable
as SnFilePool?,sensitiveMarks: null == sensitiveMarks ? _self._sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable
as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable
as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable
as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable
@@ -589,7 +607,19 @@ as DateTime?,
)); ));
} }
/// Create a copy of SnCloudFile
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnFilePoolCopyWith<$Res>? get pool {
if (_self.pool == null) {
return null;
}
return $SnFilePoolCopyWith<$Res>(_self.pool!, (value) {
return _then(_self.copyWith(pool: value));
});
}
} }
// dart format on // dart format on

View File

@@ -11,6 +11,7 @@ _UniversalFile _$UniversalFileFromJson(Map<String, dynamic> json) =>
data: json['data'], data: json['data'],
type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']), type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']),
isLink: json['is_link'] as bool? ?? false, isLink: json['is_link'] as bool? ?? false,
displayName: json['display_name'] as String?,
); );
Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) => Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) =>
@@ -18,6 +19,7 @@ Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) =>
'data': instance.data, 'data': instance.data,
'type': _$UniversalFileTypeEnumMap[instance.type]!, 'type': _$UniversalFileTypeEnumMap[instance.type]!,
'is_link': instance.isLink, 'is_link': instance.isLink,
'display_name': instance.displayName,
}; };
const _$UniversalFileTypeEnumMap = { const _$UniversalFileTypeEnumMap = {
@@ -33,6 +35,10 @@ _SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile(
description: json['description'] as String?, description: json['description'] as String?,
fileMeta: json['file_meta'] as Map<String, dynamic>?, fileMeta: json['file_meta'] as Map<String, dynamic>?,
userMeta: json['user_meta'] as Map<String, dynamic>?, userMeta: json['user_meta'] as Map<String, dynamic>?,
pool:
json['pool'] == null
? null
: SnFilePool.fromJson(json['pool'] as Map<String, dynamic>),
sensitiveMarks: sensitiveMarks:
(json['sensitive_marks'] as List<dynamic>?) (json['sensitive_marks'] as List<dynamic>?)
?.map((e) => (e as num).toInt()) ?.map((e) => (e as num).toInt())
@@ -61,6 +67,7 @@ Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) =>
'description': instance.description, 'description': instance.description,
'file_meta': instance.fileMeta, 'file_meta': instance.fileMeta,
'user_meta': instance.userMeta, 'user_meta': instance.userMeta,
'pool': instance.pool?.toJson(),
'sensitive_marks': instance.sensitiveMarks, 'sensitive_marks': instance.sensitiveMarks,
'mime_type': instance.mimeType, 'mime_type': instance.mimeType,
'hash': instance.hash, 'hash': instance.hash,

View File

@@ -23,31 +23,3 @@ sealed class SnFilePool with _$SnFilePool {
factory SnFilePool.fromJson(Map<String, dynamic> json) => factory SnFilePool.fromJson(Map<String, dynamic> json) =>
_$SnFilePoolFromJson(json); _$SnFilePoolFromJson(json);
} }
extension SnFilePoolList on List<SnFilePool> {
static List<SnFilePool> listFromResponse(dynamic data) {
if (data is List) {
return data
.whereType<Map<String, dynamic>>()
.map(SnFilePool.fromJson)
.toList();
}
throw ArgumentError('Unexpected response format: $data');
}
List<SnFilePool> filterValid() {
return where((p) {
final accept = p.policyConfig?['accept_types'];
if (accept is List) {
final acceptsOnlyMedia = accept.every((t) =>
t is String &&
(t.startsWith('image/') ||
t.startsWith('video/') ||
t.startsWith('audio/')));
if (acceptsOnlyMedia) return false;
}
return true;
}).toList();
}
}

View File

@@ -20,6 +20,24 @@ sealed class SnWallet with _$SnWallet {
_$SnWalletFromJson(json); _$SnWalletFromJson(json);
} }
@freezed
sealed class SnWalletStats with _$SnWalletStats {
const factory SnWalletStats({
required DateTime periodBegin,
required DateTime periodEnd,
required int totalTransactions,
required int totalOrders,
required double totalIncome,
required double totalOutgoing,
required double sum,
@Default({}) Map<String, double> incomeCategories,
@Default({}) Map<String, double> outgoingCategories,
}) = _SnWalletStats;
factory SnWalletStats.fromJson(Map<String, dynamic> json) =>
_$SnWalletStatsFromJson(json);
}
@freezed @freezed
sealed class SnWalletPocket with _$SnWalletPocket { sealed class SnWalletPocket with _$SnWalletPocket {
const factory SnWalletPocket({ const factory SnWalletPocket({
@@ -124,3 +142,72 @@ sealed class SnWalletOrder with _$SnWalletOrder {
factory SnWalletOrder.fromJson(Map<String, dynamic> json) => factory SnWalletOrder.fromJson(Map<String, dynamic> json) =>
_$SnWalletOrderFromJson(json); _$SnWalletOrderFromJson(json);
} }
@freezed
sealed class SnWalletGift with _$SnWalletGift {
const factory SnWalletGift({
required String id,
required String giftCode,
required String subscriptionIdentifier,
required String? recipientId,
required SnAccount? recipient,
required String gifterId,
required SnAccount? gifter,
required String? redeemerId,
required SnAccount? redeemer,
required String? message,
required int status,
required DateTime? redeemedAt,
required DateTime? expiredAt,
required String? subscriptionId,
required SnWalletSubscription? subscription,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnWalletGift;
factory SnWalletGift.fromJson(Map<String, dynamic> json) =>
_$SnWalletGiftFromJson(json);
}
@freezed
sealed class SnWalletFund with _$SnWalletFund {
const factory SnWalletFund({
required String id,
required String currency,
required double totalAmount,
required int splitType, // 0: even, 1: random
required int
status, // 0: created, 1: partially claimed, 2: fully claimed, 3: expired
required String? message,
required String creatorAccountId,
required SnAccount? creatorAccount,
required DateTime expiredAt,
required List<SnWalletFundRecipient> recipients,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnWalletFund;
factory SnWalletFund.fromJson(Map<String, dynamic> json) =>
_$SnWalletFundFromJson(json);
}
@freezed
sealed class SnWalletFundRecipient with _$SnWalletFundRecipient {
const factory SnWalletFundRecipient({
required String id,
required String fundId,
required String recipientAccountId,
required SnAccount? recipientAccount,
required double amount,
required bool isReceived,
required DateTime? receivedAt,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnWalletFundRecipient;
factory SnWalletFundRecipient.fromJson(Map<String, dynamic> json) =>
_$SnWalletFundRecipientFromJson(json);
}

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,40 @@ Map<String, dynamic> _$SnWalletToJson(_SnWallet instance) => <String, dynamic>{
'deleted_at': instance.deletedAt?.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(),
}; };
_SnWalletStats _$SnWalletStatsFromJson(Map<String, dynamic> json) =>
_SnWalletStats(
periodBegin: DateTime.parse(json['period_begin'] as String),
periodEnd: DateTime.parse(json['period_end'] as String),
totalTransactions: (json['total_transactions'] as num).toInt(),
totalOrders: (json['total_orders'] as num).toInt(),
totalIncome: (json['total_income'] as num).toDouble(),
totalOutgoing: (json['total_outgoing'] as num).toDouble(),
sum: (json['sum'] as num).toDouble(),
incomeCategories:
(json['income_categories'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toDouble()),
) ??
const {},
outgoingCategories:
(json['outgoing_categories'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toDouble()),
) ??
const {},
);
Map<String, dynamic> _$SnWalletStatsToJson(_SnWalletStats instance) =>
<String, dynamic>{
'period_begin': instance.periodBegin.toIso8601String(),
'period_end': instance.periodEnd.toIso8601String(),
'total_transactions': instance.totalTransactions,
'total_orders': instance.totalOrders,
'total_income': instance.totalIncome,
'total_outgoing': instance.totalOutgoing,
'sum': instance.sum,
'income_categories': instance.incomeCategories,
'outgoing_categories': instance.outgoingCategories,
};
_SnWalletPocket _$SnWalletPocketFromJson(Map<String, dynamic> json) => _SnWalletPocket _$SnWalletPocketFromJson(Map<String, dynamic> json) =>
_SnWalletPocket( _SnWalletPocket(
id: json['id'] as String, id: json['id'] as String,
@@ -228,3 +262,155 @@ Map<String, dynamic> _$SnWalletOrderToJson(_SnWalletOrder instance) =>
'updated_at': instance.updatedAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(),
}; };
_SnWalletGift _$SnWalletGiftFromJson(Map<String, dynamic> json) =>
_SnWalletGift(
id: json['id'] as String,
giftCode: json['gift_code'] as String,
subscriptionIdentifier: json['subscription_identifier'] as String,
recipientId: json['recipient_id'] as String?,
recipient:
json['recipient'] == null
? null
: SnAccount.fromJson(json['recipient'] as Map<String, dynamic>),
gifterId: json['gifter_id'] as String,
gifter:
json['gifter'] == null
? null
: SnAccount.fromJson(json['gifter'] as Map<String, dynamic>),
redeemerId: json['redeemer_id'] as String?,
redeemer:
json['redeemer'] == null
? null
: SnAccount.fromJson(json['redeemer'] as Map<String, dynamic>),
message: json['message'] as String?,
status: (json['status'] as num).toInt(),
redeemedAt:
json['redeemed_at'] == null
? null
: DateTime.parse(json['redeemed_at'] as String),
expiredAt:
json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
subscriptionId: json['subscription_id'] as String?,
subscription:
json['subscription'] == null
? null
: SnWalletSubscription.fromJson(
json['subscription'] as Map<String, dynamic>,
),
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),
);
Map<String, dynamic> _$SnWalletGiftToJson(_SnWalletGift instance) =>
<String, dynamic>{
'id': instance.id,
'gift_code': instance.giftCode,
'subscription_identifier': instance.subscriptionIdentifier,
'recipient_id': instance.recipientId,
'recipient': instance.recipient?.toJson(),
'gifter_id': instance.gifterId,
'gifter': instance.gifter?.toJson(),
'redeemer_id': instance.redeemerId,
'redeemer': instance.redeemer?.toJson(),
'message': instance.message,
'status': instance.status,
'redeemed_at': instance.redeemedAt?.toIso8601String(),
'expired_at': instance.expiredAt?.toIso8601String(),
'subscription_id': instance.subscriptionId,
'subscription': instance.subscription?.toJson(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnWalletFund _$SnWalletFundFromJson(
Map<String, dynamic> json,
) => _SnWalletFund(
id: json['id'] as String,
currency: json['currency'] as String,
totalAmount: (json['total_amount'] as num).toDouble(),
splitType: (json['split_type'] as num).toInt(),
status: (json['status'] as num).toInt(),
message: json['message'] as String?,
creatorAccountId: json['creator_account_id'] as String,
creatorAccount:
json['creator_account'] == null
? null
: SnAccount.fromJson(json['creator_account'] as Map<String, dynamic>),
expiredAt: DateTime.parse(json['expired_at'] as String),
recipients:
(json['recipients'] as List<dynamic>)
.map((e) => SnWalletFundRecipient.fromJson(e as Map<String, dynamic>))
.toList(),
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),
);
Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
<String, dynamic>{
'id': instance.id,
'currency': instance.currency,
'total_amount': instance.totalAmount,
'split_type': instance.splitType,
'status': instance.status,
'message': instance.message,
'creator_account_id': instance.creatorAccountId,
'creator_account': instance.creatorAccount?.toJson(),
'expired_at': instance.expiredAt.toIso8601String(),
'recipients': instance.recipients.map((e) => e.toJson()).toList(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnWalletFundRecipient _$SnWalletFundRecipientFromJson(
Map<String, dynamic> json,
) => _SnWalletFundRecipient(
id: json['id'] as String,
fundId: json['fund_id'] as String,
recipientAccountId: json['recipient_account_id'] as String,
recipientAccount:
json['recipient_account'] == null
? null
: SnAccount.fromJson(
json['recipient_account'] as Map<String, dynamic>,
),
amount: (json['amount'] as num).toDouble(),
isReceived: json['is_received'] as bool,
receivedAt:
json['received_at'] == null
? null
: DateTime.parse(json['received_at'] as String),
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),
);
Map<String, dynamic> _$SnWalletFundRecipientToJson(
_SnWalletFundRecipient instance,
) => <String, dynamic>{
'id': instance.id,
'fund_id': instance.fundId,
'recipient_account_id': instance.recipientAccountId,
'recipient_account': instance.recipientAccount?.toJson(),
'amount': instance.amount,
'is_received': instance.isReceived,
'received_at': instance.receivedAt?.toIso8601String(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};

View File

@@ -1,10 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/account/status.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_web_socket/shelf_web_socket.dart'; import 'package:shelf_web_socket/shelf_web_socket.dart';
@@ -67,13 +69,13 @@ class ActivityRpcServer {
// Start WebSocket server // Start WebSocket server
while (port <= portRange[1]) { while (port <= portRange[1]) {
developer.log('Trying port $port', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] Trying port $port');
try { try {
_httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, port); _httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
developer.log('Listening on $port', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] Listening on $port');
shelf_io.serveRequests(_httpServer!, (Request request) async { shelf_io.serveRequests(_httpServer!, (Request request) async {
developer.log('New request', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] New request');
if (request.headers['upgrade']?.toLowerCase() == 'websocket') { if (request.headers['upgrade']?.toLowerCase() == 'websocket') {
final handler = webSocketHandler((WebSocketChannel channel, _) { final handler = webSocketHandler((WebSocketChannel channel, _) {
_wsSockets.add(channel); _wsSockets.add(channel);
@@ -81,19 +83,16 @@ class ActivityRpcServer {
}); });
return handler(request); return handler(request);
} }
developer.log( talker.log('New request disposed due to not websocket');
'New request disposed due to not websocket',
name: kRpcLogPrefix,
);
return Response.notFound('Not a WebSocket request'); return Response.notFound('Not a WebSocket request');
}); });
wsSuccess = true; wsSuccess = true;
break; break;
} catch (e) { } catch (e) {
if (e is SocketException && e.osError?.errorCode == 98) { if (e is SocketException && e.osError?.errorCode == 98) {
developer.log('$port in use!', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] $port in use!');
} else { } else {
developer.log('HTTP error: $e', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] HTTP error: $e');
} }
port++; port++;
await Future.delayed(Duration(milliseconds: 100)); // Add delay await Future.delayed(Duration(milliseconds: 100)); // Add delay
@@ -119,13 +118,10 @@ class ActivityRpcServer {
await _ipcServer!.start(); await _ipcServer!.start();
} catch (e) { } catch (e) {
developer.log('IPC server error: $e', name: kRpcIpcLogPrefix); talker.log('[$kRpcLogPrefix] IPC server error: $e');
} }
} else { } else {
developer.log( talker.log('IPC server disabled on macOS or web in production mode');
'IPC server disabled on macOS or web in production mode',
name: kRpcIpcLogPrefix,
);
} }
} }
@@ -136,7 +132,7 @@ class ActivityRpcServer {
try { try {
await socket.sink.close(); await socket.sink.close();
} catch (e) { } catch (e) {
developer.log('Error closing WebSocket: $e', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] Error closing WebSocket: $e');
} }
} }
_wsSockets.clear(); _wsSockets.clear();
@@ -145,7 +141,7 @@ class ActivityRpcServer {
// Stop IPC server // Stop IPC server
await _ipcServer?.stop(); await _ipcServer?.stop();
developer.log('Servers stopped', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] Servers stopped');
} }
// Handle new WebSocket connection // Handle new WebSocket connection
@@ -157,10 +153,7 @@ class ActivityRpcServer {
final clientId = params['client_id'] ?? ''; final clientId = params['client_id'] ?? '';
final origin = request.headers['origin'] ?? ''; final origin = request.headers['origin'] ?? '';
developer.log( talker.log('New WS connection! origin: $origin, params: $params');
'New WS connection! origin: $origin, params: $params',
name: kRpcLogPrefix,
);
if (origin.isNotEmpty && if (origin.isNotEmpty &&
![ ![
@@ -168,22 +161,19 @@ class ActivityRpcServer {
'https://ptb.discord.com', 'https://ptb.discord.com',
'https://canary.discord.com', 'https://canary.discord.com',
].contains(origin)) { ].contains(origin)) {
developer.log('Disallowed origin: $origin', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] Disallowed origin: $origin');
socket.sink.close(); socket.sink.close();
return; return;
} }
if (encoding != 'json') { if (encoding != 'json') {
developer.log( talker.log('Unsupported encoding requested: $encoding');
'Unsupported encoding requested: $encoding',
name: kRpcLogPrefix,
);
socket.sink.close(); socket.sink.close();
return; return;
} }
if (ver != 1) { if (ver != 1) {
developer.log('Unsupported version requested: $ver', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] Unsupported version requested: $ver');
socket.sink.close(); socket.sink.close();
return; return;
} }
@@ -193,10 +183,10 @@ class ActivityRpcServer {
socket.stream.listen( socket.stream.listen(
(data) => _onWsMessage(socketWithMeta, data), (data) => _onWsMessage(socketWithMeta, data),
onError: (e) { onError: (e) {
developer.log('WS socket error: $e', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] WS socket error: $e');
}, },
onDone: () { onDone: () {
developer.log('WS socket closed', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] WS socket closed');
handlers['close']?.call(socketWithMeta); handlers['close']?.call(socketWithMeta);
_wsSockets.remove(socket); _wsSockets.remove(socket);
}, },
@@ -208,25 +198,19 @@ class ActivityRpcServer {
// Handle incoming WebSocket message // Handle incoming WebSocket message
Future<void> _onWsMessage(_WsSocketWrapper socket, dynamic data) async { Future<void> _onWsMessage(_WsSocketWrapper socket, dynamic data) async {
if (data is! String) { if (data is! String) {
developer.log( talker.log('Invalid WebSocket message: not a string');
'Invalid WebSocket message: not a string',
name: kRpcLogPrefix,
);
return; return;
} }
try { try {
final jsonData = await compute(jsonDecode, data); final jsonData = await compute(jsonDecode, data);
if (jsonData is! Map<String, dynamic>) { if (jsonData is! Map<String, dynamic>) {
developer.log( talker.log('Invalid WebSocket message: not a JSON object');
'Invalid WebSocket message: not a JSON object',
name: kRpcLogPrefix,
);
return; return;
} }
developer.log('WS message: $jsonData', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] WS message: $jsonData');
handlers['message']?.call(socket, jsonData); handlers['message']?.call(socket, jsonData);
} catch (e) { } catch (e) {
developer.log('WS message parse error: $e', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] WS message parse error: $e');
} }
} }
@@ -234,12 +218,12 @@ class ActivityRpcServer {
void _handleIpcPacket(IpcSocketWrapper socket, IpcPacket packet) { void _handleIpcPacket(IpcSocketWrapper socket, IpcPacket packet) {
switch (packet.type) { switch (packet.type) {
case IpcTypes.ping: case IpcTypes.ping:
developer.log('IPC ping received', name: kRpcIpcLogPrefix); talker.log('[$kRpcLogPrefix] IPC ping received');
socket.sendPong(packet.data); socket.sendPong(packet.data);
break; break;
case IpcTypes.pong: case IpcTypes.pong:
developer.log('IPC pong received', name: kRpcIpcLogPrefix); talker.log('[$kRpcLogPrefix] IPC pong received');
break; break;
case IpcTypes.handshake: case IpcTypes.handshake:
@@ -254,7 +238,7 @@ class ActivityRpcServer {
if (!socket.handshook) { if (!socket.handshook) {
throw Exception('Need to handshake first'); throw Exception('Need to handshake first');
} }
developer.log('IPC frame: ${packet.data}', name: kRpcIpcLogPrefix); talker.log('[$kRpcLogPrefix] IPC frame: ${packet.data}');
handlers['message']?.call(socket, packet.data); handlers['message']?.call(socket, packet.data);
break; break;
@@ -269,22 +253,19 @@ class ActivityRpcServer {
// Handle IPC handshake // Handle IPC handshake
void _onIpcHandshake(IpcSocketWrapper socket, Map<String, dynamic> params) { void _onIpcHandshake(IpcSocketWrapper socket, Map<String, dynamic> params) {
developer.log('IPC handshake: $params', name: kRpcIpcLogPrefix); talker.log('[$kRpcLogPrefix] IPC handshake: $params');
final ver = int.tryParse(params['v']?.toString() ?? '1') ?? 1; final ver = int.tryParse(params['v']?.toString() ?? '1') ?? 1;
final clientId = params['client_id']?.toString() ?? ''; final clientId = params['client_id']?.toString() ?? '';
if (ver != 1) { if (ver != 1) {
developer.log( talker.log('IPC unsupported version requested: $ver');
'IPC unsupported version requested: $ver',
name: kRpcIpcLogPrefix,
);
socket.closeWithCode(IpcErrorCodes.invalidVersion); socket.closeWithCode(IpcErrorCodes.invalidVersion);
return; return;
} }
if (clientId.isEmpty) { if (clientId.isEmpty) {
developer.log('IPC client ID required', name: kRpcIpcLogPrefix); talker.log('[$kRpcLogPrefix] IPC client ID required');
socket.closeWithCode(IpcErrorCodes.invalidClientId); socket.closeWithCode(IpcErrorCodes.invalidClientId);
return; return;
} }
@@ -304,7 +285,7 @@ class _WsSocketWrapper {
_WsSocketWrapper(this.channel, this.clientId, this.encoding); _WsSocketWrapper(this.channel, this.clientId, this.encoding);
void send(Map<String, dynamic> msg) { void send(Map<String, dynamic> msg) {
developer.log('WS sending: $msg', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] WS sending: $msg');
channel.sink.add(jsonEncode(msg)); channel.sink.add(jsonEncode(msg));
} }
} }
@@ -331,7 +312,7 @@ class ServerStateNotifier extends StateNotifier<ServerState> {
: super(ServerState(status: 'Server not started')); : super(ServerState(status: 'Server not started'));
Future<void> start() async { Future<void> start() async {
if (!Platform.isAndroid && !Platform.isIOS && !kIsWeb) { if (!kIsWeb && !Platform.isAndroid && !Platform.isIOS) {
try { try {
await server.start(); await server.start();
state = state.copyWith(status: 'Server running'); state = state.copyWith(status: 'Server running');
@@ -390,22 +371,32 @@ final rpcServerStateProvider =
'message': (socket, dynamic data) async { 'message': (socket, dynamic data) async {
if (data['cmd'] == 'SET_ACTIVITY') { if (data['cmd'] == 'SET_ACTIVITY') {
notifier.addActivity( notifier.addActivity(
'Activity: ${data['args']['activity']['details'] ?? 'Unknown'}', 'Activity: ${data['args']['activity']['details'] ?? ''}',
); );
final label = data['args']['activity']['details'] ?? 'Unknown'; final label = data['args']['activity']['details'] ?? '';
final appId = socket.clientId; final appId = socket.clientId;
final meta = data['args']['activity'];
try { try {
await setRemoteActivityStatus( await setRemoteActivityStatus(ref, label, appId, meta);
ref, final now = DateTime.now();
label, final status = SnAccountStatus(
appId, id: 'local_$appId',
data['args']['activity'], attitude: 0,
isOnline: true,
isInvisible: false,
isNotDisturb: false,
isCustomized: true,
label: label,
meta: meta,
clearedAt: null,
accountId: 'me',
createdAt: now,
updatedAt: now,
deletedAt: null,
); );
ref.read(currentAccountStatusProvider.notifier).setStatus(status);
} catch (e) { } catch (e) {
developer.log( talker.log('Failed to set remote activity status: $e');
'Failed to set remote activity status: $e',
name: kRpcLogPrefix,
);
} }
socket.send({ socket.send({
'cmd': 'SET_ACTIVITY', 'cmd': 'SET_ACTIVITY',
@@ -420,11 +411,9 @@ final rpcServerStateProvider =
final appId = socket.clientId; final appId = socket.clientId;
try { try {
await unsetRemoteActivityStatus(ref, appId); await unsetRemoteActivityStatus(ref, appId);
ref.read(currentAccountStatusProvider.notifier).clearStatus();
} catch (e) { } catch (e) {
developer.log( talker.log('Failed to unset remote activity status: $e');
'Failed to unset remote activity status: $e',
name: kRpcLogPrefix,
);
} }
}, },
}); });

View File

@@ -1,9 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dart_ipc/dart_ipc.dart'; import 'package:dart_ipc/dart_ipc.dart';
import 'package:island/talker.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
const String kRpcIpcLogPrefix = 'arRPC.ipc'; const String kRpcIpcLogPrefix = 'arRPC.ipc';
@@ -128,23 +128,18 @@ class MultiPlatformIpcServer extends IpcServer {
@override @override
Future<void> start() async { Future<void> start() async {
try { try {
final ipcPath = Platform.isWindows final ipcPath =
Platform.isWindows
? r'\\.\pipe\discord-ipc-0' ? r'\\.\pipe\discord-ipc-0'
: await _findAvailableUnixIpcPath(); : await _findAvailableUnixIpcPath();
final serverSocket = await bind(ipcPath); final serverSocket = await bind(ipcPath);
developer.log( talker.log('IPC listening at $ipcPath');
'IPC listening at $ipcPath',
name: kRpcIpcLogPrefix,
);
_serverSubscription = serverSocket.listen((socket) { _serverSubscription = serverSocket.listen((socket) {
final socketWrapper = MultiPlatformIpcSocketWrapper(socket); final socketWrapper = MultiPlatformIpcSocketWrapper(socket);
addSocket(socketWrapper); addSocket(socketWrapper);
developer.log( talker.log('New IPC connection!');
'New IPC connection!',
name: kRpcIpcLogPrefix,
);
_handleIpcData(socketWrapper); _handleIpcData(socketWrapper);
}); });
} catch (e) { } catch (e) {
@@ -158,7 +153,7 @@ class MultiPlatformIpcServer extends IpcServer {
try { try {
socket.close(); socket.close();
} catch (e) { } catch (e) {
developer.log('Error closing IPC socket: $e', name: kRpcIpcLogPrefix); talker.log('Error closing IPC socket: $e');
} }
} }
sockets.clear(); sockets.clear();
@@ -168,31 +163,30 @@ class MultiPlatformIpcServer extends IpcServer {
// Handle incoming IPC data // Handle incoming IPC data
void _handleIpcData(MultiPlatformIpcSocketWrapper socket) { void _handleIpcData(MultiPlatformIpcSocketWrapper socket) {
final startTime = DateTime.now(); final startTime = DateTime.now();
socket.socket.listen((data) { socket.socket.listen(
(data) {
final readStart = DateTime.now(); final readStart = DateTime.now();
socket.addData(data); socket.addData(data);
final readDuration = DateTime.now().difference(readStart).inMicroseconds; final readDuration =
developer.log( DateTime.now().difference(readStart).inMicroseconds;
'Read data took $readDuration microseconds', talker.log('Read data took $readDuration microseconds');
name: kRpcIpcLogPrefix,
);
final packets = socket.readPackets(); final packets = socket.readPackets();
for (final packet in packets) { for (final packet in packets) {
handlePacket?.call(socket, packet, {}); handlePacket?.call(socket, packet, {});
} }
}, onDone: () { },
developer.log('IPC connection closed', name: kRpcIpcLogPrefix); onDone: () {
talker.log('IPC connection closed');
socket.close(); socket.close();
}, onError: (e) { },
developer.log('IPC data error: $e', name: kRpcIpcLogPrefix); onError: (e) {
talker.log('IPC data error: $e');
socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString()); socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString());
}); },
final totalDuration = DateTime.now().difference(startTime).inMicroseconds;
developer.log(
'_handleIpcData took $totalDuration microseconds',
name: kRpcIpcLogPrefix,
); );
final totalDuration = DateTime.now().difference(startTime).inMicroseconds;
talker.log('_handleIpcData took $totalDuration microseconds');
} }
Future<String> _getMacOsSystemTmpDir() async { Future<String> _getMacOsSystemTmpDir() async {
@@ -212,10 +206,7 @@ class MultiPlatformIpcServer extends IpcServer {
baseDirs.add(macTempDir); baseDirs.add(macTempDir);
} }
} catch (e) { } catch (e) {
developer.log( talker.log('Failed to get macOS system temp dir: $e');
'Failed to get macOS system temp dir: $e',
name: kRpcIpcLogPrefix,
);
} }
} }
@@ -241,17 +232,11 @@ class MultiPlatformIpcServer extends IpcServer {
try { try {
await File(socketPath).delete(); await File(socketPath).delete();
} catch (_) {} } catch (_) {}
developer.log( talker.log('IPC socket will be created at: $socketPath');
'IPC socket will be created at: $socketPath',
name: kRpcIpcLogPrefix,
);
return socketPath; return socketPath;
} catch (e) { } catch (e) {
if (i == 0) { if (i == 0) {
developer.log( talker.log('IPC path $socketPath not available: $e');
'IPC path $socketPath not available: $e',
name: kRpcIpcLogPrefix,
);
} }
continue; continue;
} }
@@ -271,7 +256,7 @@ class MultiPlatformIpcSocketWrapper extends IpcSocketWrapper {
@override @override
void send(Map<String, dynamic> msg) { void send(Map<String, dynamic> msg) {
developer.log('IPC sending: $msg', name: kRpcIpcLogPrefix); talker.log('IPC sending: $msg');
final packet = IpcServer.encodeIpcPacket(IpcTypes.frame, msg); final packet = IpcServer.encodeIpcPacket(IpcTypes.frame, msg);
socket.add(packet); socket.add(packet);
} }

View File

@@ -1,15 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/chat/call_button.dart'; import 'package:island/widgets/chat/call_button.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:island/talker.dart';
part 'call.g.dart'; part 'call.g.dart';
part 'call.freezed.dart'; part 'call.freezed.dart';
@@ -41,7 +43,7 @@ sealed class CallParticipantLive with _$CallParticipantLive {
const factory CallParticipantLive({ const factory CallParticipantLive({
required CallParticipant participant, required CallParticipant participant,
required Participant remoteParticipant, required lk.Participant remoteParticipant,
}) = _CallParticipantLive; }) = _CallParticipantLive;
bool get isSpeaking => remoteParticipant.isSpeaking; bool get isSpeaking => remoteParticipant.isSpeaking;
@@ -57,21 +59,21 @@ sealed class CallParticipantLive with _$CallParticipantLive {
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class CallNotifier extends _$CallNotifier { class CallNotifier extends _$CallNotifier {
Room? _room; lk.Room? _room;
LocalParticipant? _localParticipant; lk.LocalParticipant? _localParticipant;
List<CallParticipantLive> _participants = []; List<CallParticipantLive> _participants = [];
final Map<String, CallParticipant> _participantInfoByIdentity = {}; final Map<String, CallParticipant> _participantInfoByIdentity = {};
EventsListener? _roomListener; lk.EventsListener? _roomListener;
List<CallParticipantLive> get participants => List<CallParticipantLive> get participants =>
List.unmodifiable(_participants); List.unmodifiable(_participants);
LocalParticipant? get localParticipant => _localParticipant; lk.LocalParticipant? get localParticipant => _localParticipant;
Map<String, double> participantsVolumes = {}; Map<String, double> participantsVolumes = {};
Timer? _durationTimer; Timer? _durationTimer;
Room? get room => _room; lk.Room? get room => _room;
@override @override
CallState build() { CallState build() {
@@ -91,10 +93,10 @@ class CallNotifier extends _$CallNotifier {
_roomListener = _room!.createListener(); _roomListener = _room!.createListener();
_room!.addListener(_onRoomChange); _room!.addListener(_onRoomChange);
_roomListener! _roomListener!
..on<ParticipantConnectedEvent>((e) { ..on<lk.ParticipantConnectedEvent>((e) {
_refreshLiveParticipants(); _refreshLiveParticipants();
}) })
..on<RoomDisconnectedEvent>((e) { ..on<lk.RoomDisconnectedEvent>((e) {
_participants = []; _participants = [];
state = state.copyWith(); state = state.copyWith();
}); });
@@ -188,7 +190,7 @@ class CallNotifier extends _$CallNotifier {
// Add remote participants // Add remote participants
_participants.addAll( _participants.addAll(
participants.map((p) { participants.map((p) {
RemoteParticipant? remote; lk.RemoteParticipant? remote;
for (final r in remotes) { for (final r in remotes) {
if (r.identity == p.identity) { if (r.identity == p.identity) {
remote = r; remote = r;
@@ -212,11 +214,11 @@ class CallNotifier extends _$CallNotifier {
Future<void> joinRoom(String roomId) async { Future<void> joinRoom(String roomId) async {
if (_roomId == roomId && _room != null) { if (_roomId == roomId && _room != null) {
log('[Call] Call skipped. Already has data'); talker.info('[Call] Call skipped. Already has data');
return; return;
} else if (_room != null) { } else if (_room != null) {
if (!_room!.isDisposed && if (!_room!.isDisposed &&
_room!.connectionState != ConnectionState.disconnected) { _room!.connectionState != lk.ConnectionState.disconnected) {
throw Exception('Call already connected'); throw Exception('Call already connected');
} }
} }
@@ -256,15 +258,15 @@ class CallNotifier extends _$CallNotifier {
}); });
// Connect to LiveKit // Connect to LiveKit
_room = Room(); _room = lk.Room();
await _room!.connect( await _room!.connect(
endpoint, endpoint,
token, token,
connectOptions: ConnectOptions(autoSubscribe: true), connectOptions: lk.ConnectOptions(autoSubscribe: true),
roomOptions: RoomOptions(adaptiveStream: true, dynacast: true), roomOptions: lk.RoomOptions(adaptiveStream: true, dynacast: true),
fastConnectOptions: FastConnectOptions( fastConnectOptions: lk.FastConnectOptions(
microphone: TrackOption(enabled: true), microphone: lk.TrackOption(enabled: true),
), ),
); );
_localParticipant = _room!.localParticipant; _localParticipant = _room!.localParticipant;
@@ -273,14 +275,14 @@ class CallNotifier extends _$CallNotifier {
_updateLiveParticipants(participants); _updateLiveParticipants(participants);
if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) { if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) {
Hardware.instance.setSpeakerphoneOn(true); lk.Hardware.instance.setSpeakerphoneOn(true);
} }
// Listen for connection updates // Listen for connection updates
_room!.addListener(() { _room!.addListener(() {
final wasConnected = state.isConnected; final wasConnected = state.isConnected;
final isNowConnected = final isNowConnected =
_room!.connectionState == ConnectionState.connected; _room!.connectionState == lk.ConnectionState.connected;
state = state.copyWith( state = state.copyWith(
isConnected: isNowConnected, isConnected: isNowConnected,
isMicrophoneEnabled: _localParticipant!.isMicrophoneEnabled(), isMicrophoneEnabled: _localParticipant!.isMicrophoneEnabled(),
@@ -334,18 +336,43 @@ class CallNotifier extends _$CallNotifier {
} }
} }
Future<void> toggleScreenShare() async { Future<void> toggleScreenShare(BuildContext context) async {
if (_localParticipant != null) { if (_localParticipant != null) {
final target = !_localParticipant!.isScreenShareEnabled(); final target = !_localParticipant!.isScreenShareEnabled();
state = state.copyWith(isScreenSharing: target); state = state.copyWith(isScreenSharing: target);
if (target && lk.lkPlatformIsDesktop()) {
try {
final source = await showDialog<DesktopCapturerSource>(
context: context,
builder: (context) => lk.ScreenSelectDialog(),
);
if (source == null) {
return;
}
var track = await lk.LocalVideoTrack.createScreenShareTrack(
lk.ScreenShareCaptureOptions(
sourceId: source.id,
maxFrameRate: 30.0,
captureScreenAudio: true,
),
);
await _localParticipant!.publishVideoTrack(track);
} catch (err) {
showErrorAlert(err);
}
return;
} else {
await _localParticipant!.setScreenShareEnabled(target); await _localParticipant!.setScreenShareEnabled(target);
}
state = state.copyWith(); state = state.copyWith();
} }
} }
Future<void> toggleSpeakerphone() async { Future<void> toggleSpeakerphone() async {
state = state.copyWith(isSpeakerphone: !state.isSpeakerphone); state = state.copyWith(isSpeakerphone: !state.isSpeakerphone);
await Hardware.instance.setSpeakerphoneOn(state.isSpeakerphone); await lk.Hardware.instance.setSpeakerphoneOn(state.isSpeakerphone);
state = state.copyWith(); state = state.copyWith();
} }

View File

@@ -295,7 +295,7 @@ as String?,
/// @nodoc /// @nodoc
mixin _$CallParticipantLive implements DiagnosticableTreeMixin { mixin _$CallParticipantLive implements DiagnosticableTreeMixin {
CallParticipant get participant; Participant get remoteParticipant; CallParticipant get participant; lk.Participant get remoteParticipant;
/// Create a copy of CallParticipantLive /// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -332,7 +332,7 @@ abstract mixin class $CallParticipantLiveCopyWith<$Res> {
factory $CallParticipantLiveCopyWith(CallParticipantLive value, $Res Function(CallParticipantLive) _then) = _$CallParticipantLiveCopyWithImpl; factory $CallParticipantLiveCopyWith(CallParticipantLive value, $Res Function(CallParticipantLive) _then) = _$CallParticipantLiveCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
CallParticipant participant, Participant remoteParticipant CallParticipant participant, lk.Participant remoteParticipant
}); });
@@ -353,7 +353,7 @@ class _$CallParticipantLiveCopyWithImpl<$Res>
return _then(_self.copyWith( return _then(_self.copyWith(
participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable
as CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable as CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable
as Participant, as lk.Participant,
)); ));
} }
/// Create a copy of CallParticipantLive /// Create a copy of CallParticipantLive
@@ -444,7 +444,7 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( CallParticipant participant, Participant remoteParticipant)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( CallParticipant participant, lk.Participant remoteParticipant)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _CallParticipantLive() when $default != null: case _CallParticipantLive() when $default != null:
return $default(_that.participant,_that.remoteParticipant);case _: return $default(_that.participant,_that.remoteParticipant);case _:
@@ -465,7 +465,7 @@ return $default(_that.participant,_that.remoteParticipant);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( CallParticipant participant, Participant remoteParticipant) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( CallParticipant participant, lk.Participant remoteParticipant) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _CallParticipantLive(): case _CallParticipantLive():
return $default(_that.participant,_that.remoteParticipant);} return $default(_that.participant,_that.remoteParticipant);}
@@ -482,7 +482,7 @@ return $default(_that.participant,_that.remoteParticipant);}
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( CallParticipant participant, Participant remoteParticipant)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( CallParticipant participant, lk.Participant remoteParticipant)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _CallParticipantLive() when $default != null: case _CallParticipantLive() when $default != null:
return $default(_that.participant,_that.remoteParticipant);case _: return $default(_that.participant,_that.remoteParticipant);case _:
@@ -501,7 +501,7 @@ class _CallParticipantLive extends CallParticipantLive with DiagnosticableTreeMi
@override final CallParticipant participant; @override final CallParticipant participant;
@override final Participant remoteParticipant; @override final lk.Participant remoteParticipant;
/// Create a copy of CallParticipantLive /// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -539,7 +539,7 @@ abstract mixin class _$CallParticipantLiveCopyWith<$Res> implements $CallPartici
factory _$CallParticipantLiveCopyWith(_CallParticipantLive value, $Res Function(_CallParticipantLive) _then) = __$CallParticipantLiveCopyWithImpl; factory _$CallParticipantLiveCopyWith(_CallParticipantLive value, $Res Function(_CallParticipantLive) _then) = __$CallParticipantLiveCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
CallParticipant participant, Participant remoteParticipant CallParticipant participant, lk.Participant remoteParticipant
}); });
@@ -560,7 +560,7 @@ class __$CallParticipantLiveCopyWithImpl<$Res>
return _then(_CallParticipantLive( return _then(_CallParticipantLive(
participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable
as CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable as CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable
as Participant, as lk.Participant,
)); ));
} }

View File

@@ -6,7 +6,7 @@ part of 'call.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$callNotifierHash() => r'eb9bd41b97e9b5e9d54007c8327edb6567458846'; String _$callNotifierHash() => r'a8ca3f625c0db3ad9992033ae70864ce15efc281';
/// See also [CallNotifier]. /// See also [CallNotifier].
@ProviderFor(CallNotifier) @ProviderFor(CallNotifier)

View File

@@ -0,0 +1,50 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/models/account.dart';
part 'chat_online_count.g.dart';
@riverpod
class ChatOnlineCountNotifier extends _$ChatOnlineCountNotifier {
@override
Future<int> build(String chatroomId) async {
final apiClient = ref.watch(apiClientProvider);
final ws = ref.watch(websocketProvider);
// Fetch initial online count
final response = await apiClient.get(
'/sphere/chat/$chatroomId/members/online',
);
final initialCount = response.data as int;
// Listen for websocket status updates
final subscription = ws.dataStream.listen((WebSocketPacket packet) {
if (packet.type == 'accounts.status.update') {
final data = packet.data;
if (data != null && data['chat_room_id'] == chatroomId) {
final status = SnAccountStatus.fromJson(data['status']);
var delta = status.isOnline ? 1 : -1;
if (status.clearedAt != null &&
status.clearedAt!.isBefore(DateTime.now())) {
if (status.isInvisible) delta = 1;
}
// Update count based on online status
state.whenData((currentCount) {
final newCount = currentCount + delta;
state = AsyncData(
newCount.clamp(0, double.infinity).toInt(),
); // Ensure non-negative
});
}
}
});
ref.onDispose(() {
subscription.cancel();
});
return initialCount;
}
}

View File

@@ -0,0 +1,168 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_online_count.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatOnlineCountNotifierHash() =>
r'19af8fd0e9f62c65e12a68215406776085235fa3';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$ChatOnlineCountNotifier
extends BuildlessAutoDisposeAsyncNotifier<int> {
late final String chatroomId;
FutureOr<int> build(String chatroomId);
}
/// See also [ChatOnlineCountNotifier].
@ProviderFor(ChatOnlineCountNotifier)
const chatOnlineCountNotifierProvider = ChatOnlineCountNotifierFamily();
/// See also [ChatOnlineCountNotifier].
class ChatOnlineCountNotifierFamily extends Family<AsyncValue<int>> {
/// See also [ChatOnlineCountNotifier].
const ChatOnlineCountNotifierFamily();
/// See also [ChatOnlineCountNotifier].
ChatOnlineCountNotifierProvider call(String chatroomId) {
return ChatOnlineCountNotifierProvider(chatroomId);
}
@override
ChatOnlineCountNotifierProvider getProviderOverride(
covariant ChatOnlineCountNotifierProvider provider,
) {
return call(provider.chatroomId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'chatOnlineCountNotifierProvider';
}
/// See also [ChatOnlineCountNotifier].
class ChatOnlineCountNotifierProvider
extends AutoDisposeAsyncNotifierProviderImpl<ChatOnlineCountNotifier, int> {
/// See also [ChatOnlineCountNotifier].
ChatOnlineCountNotifierProvider(String chatroomId)
: this._internal(
() => ChatOnlineCountNotifier()..chatroomId = chatroomId,
from: chatOnlineCountNotifierProvider,
name: r'chatOnlineCountNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$chatOnlineCountNotifierHash,
dependencies: ChatOnlineCountNotifierFamily._dependencies,
allTransitiveDependencies:
ChatOnlineCountNotifierFamily._allTransitiveDependencies,
chatroomId: chatroomId,
);
ChatOnlineCountNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.chatroomId,
}) : super.internal();
final String chatroomId;
@override
FutureOr<int> runNotifierBuild(covariant ChatOnlineCountNotifier notifier) {
return notifier.build(chatroomId);
}
@override
Override overrideWith(ChatOnlineCountNotifier Function() create) {
return ProviderOverride(
origin: this,
override: ChatOnlineCountNotifierProvider._internal(
() => create()..chatroomId = chatroomId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
chatroomId: chatroomId,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<ChatOnlineCountNotifier, int>
createElement() {
return _ChatOnlineCountNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ChatOnlineCountNotifierProvider &&
other.chatroomId == chatroomId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, chatroomId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ChatOnlineCountNotifierRef on AutoDisposeAsyncNotifierProviderRef<int> {
/// The parameter `chatroomId` of this provider.
String get chatroomId;
}
class _ChatOnlineCountNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<ChatOnlineCountNotifier, int>
with ChatOnlineCountNotifierRef {
_ChatOnlineCountNotifierProviderElement(super.provider);
@override
String get chatroomId =>
(origin as ChatOnlineCountNotifierProvider).chatroomId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,5 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
final flashingMessagesProvider = StateProvider<Set<String>>((ref) => {});

View File

@@ -0,0 +1,229 @@
import "dart:async";
import "dart:convert";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:island/models/chat.dart";
import "package:island/pods/lifecycle.dart";
import "package:island/pods/chat/messages_notifier.dart";
import "package:island/pods/websocket.dart";
import "package:island/screens/chat/chat.dart";
import "package:island/widgets/chat/call_button.dart";
import "package:riverpod_annotation/riverpod_annotation.dart";
part 'chat_subscribe.g.dart';
final currentSubscribedChatIdProvider = StateProvider<String?>((ref) => null);
@riverpod
class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
late final String _roomId;
late final SnChatRoom _chatRoom;
late final SnChatMember _chatIdentity;
late final MessagesNotifier _messagesNotifier;
final List<SnChatMember> _typingStatuses = [];
Timer? _typingCleanupTimer;
Timer? _typingCooldownTimer;
Timer? _periodicSubscribeTimer;
StreamSubscription? _wsSubscription;
@override
List<SnChatMember> build(String roomId) {
_roomId = roomId;
final ws = ref.watch(websocketProvider);
final chatRoomAsync = ref.watch(chatroomProvider(roomId));
final chatIdentityAsync = ref.watch(chatroomIdentityProvider(roomId));
_messagesNotifier = ref.watch(messagesNotifierProvider(roomId).notifier);
if (chatRoomAsync.isLoading || chatIdentityAsync.isLoading) {
return [];
}
if (chatRoomAsync.value == null || chatIdentityAsync.value == null) {
return [];
}
_chatRoom = chatRoomAsync.value!;
_chatIdentity = chatIdentityAsync.value!;
// Subscribe to messages
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': roomId},
endpoint: 'sphere',
),
),
);
Future.microtask(
() => ref.read(currentSubscribedChatIdProvider.notifier).state = roomId,
);
// Send initial read receipt
sendReadReceipt();
// Set up WebSocket listener
_wsSubscription = ws.dataStream.listen(onMessage);
// Set up typing status cleanup timer
_typingCleanupTimer = Timer.periodic(const Duration(seconds: 5), (_) {
if (_typingStatuses.isNotEmpty) {
// Remove typing statuses older than 5 seconds
final now = DateTime.now();
_typingStatuses.removeWhere((member) {
final lastTyped =
member.lastTyped ??
DateTime.now().subtract(const Duration(milliseconds: 1350));
return now.difference(lastTyped).inSeconds > 5;
});
state = List.of(_typingStatuses);
}
});
// Set up periodic subscribe timer (every 5 minutes)
_periodicSubscribeTimer = Timer.periodic(const Duration(minutes: 5), (_) {
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': roomId},
endpoint: 'sphere',
),
),
);
});
// Listen to app lifecycle changes
ref.listen(appLifecycleStateProvider, (previous, next) {
final lifecycleState = next.value;
if (lifecycleState == AppLifecycleState.paused ||
lifecycleState == AppLifecycleState.inactive) {
// Unsubscribe when app goes to background
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.unsubscribe',
data: {'chat_room_id': roomId},
endpoint: 'sphere',
),
),
);
} else if (lifecycleState == AppLifecycleState.resumed) {
// Resubscribe when app comes back to foreground
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': roomId},
endpoint: 'sphere',
),
),
);
}
});
// Cleanup on dispose
ref.onDispose(() {
ref.read(currentSubscribedChatIdProvider.notifier).state = null;
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.unsubscribe',
data: {'chat_room_id': roomId},
endpoint: 'sphere',
),
),
);
_wsSubscription?.cancel();
_typingCleanupTimer?.cancel();
_typingCooldownTimer?.cancel();
_periodicSubscribeTimer?.cancel();
});
return _typingStatuses;
}
void onMessage(WebSocketPacket pkt) {
if (!pkt.type.startsWith('messages')) return;
if (['messages.read'].contains(pkt.type)) return;
if (pkt.type == 'messages.typing' && pkt.data?['sender'] != null) {
if (pkt.data?['room_id'] != _chatRoom.id) return;
if (pkt.data?['sender_id'] == _chatIdentity.id) return;
final sender = SnChatMember.fromJson(
pkt.data?['sender'],
).copyWith(lastTyped: DateTime.now());
// Check if the sender is already in the typing list
final existingIndex = _typingStatuses.indexWhere(
(member) => member.id == sender.id,
);
if (existingIndex >= 0) {
// Update the existing entry with new timestamp
_typingStatuses[existingIndex] = sender;
} else {
// Add new typing status
_typingStatuses.add(sender);
}
state = List.of(_typingStatuses);
return;
}
final message = SnChatMessage.fromJson(pkt.data!);
if (message.chatRoomId != _chatRoom.id) return;
switch (pkt.type) {
case 'messages.new':
case 'messages.update':
case 'messages.delete':
if (message.type.startsWith('call')) {
// Handle the ongoing call.
ref.invalidate(ongoingCallProvider(message.chatRoomId));
}
_messagesNotifier.receiveMessage(message);
// Send read receipt for new message
sendReadReceipt();
}
}
void sendReadReceipt() {
// Send websocket packet
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.read',
data: {'chat_room_id': _roomId},
endpoint: 'sphere',
),
),
);
}
void sendTypingStatus() {
// Don't send if we're already in a cooldown period
if (_typingCooldownTimer != null) return;
// Send typing status immediately
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.typing',
data: {'chat_room_id': _roomId},
endpoint: 'sphere',
),
),
);
_typingCooldownTimer = Timer(const Duration(milliseconds: 850), () {
_typingCooldownTimer = null;
});
}
}

View File

@@ -0,0 +1,176 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_subscribe.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatSubscribeNotifierHash() =>
r'c605e0c9c45df64e5ba7b65f8de9b47bde8e2b3b';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$ChatSubscribeNotifier
extends BuildlessAutoDisposeNotifier<List<SnChatMember>> {
late final String roomId;
List<SnChatMember> build(String roomId);
}
/// See also [ChatSubscribeNotifier].
@ProviderFor(ChatSubscribeNotifier)
const chatSubscribeNotifierProvider = ChatSubscribeNotifierFamily();
/// See also [ChatSubscribeNotifier].
class ChatSubscribeNotifierFamily extends Family<List<SnChatMember>> {
/// See also [ChatSubscribeNotifier].
const ChatSubscribeNotifierFamily();
/// See also [ChatSubscribeNotifier].
ChatSubscribeNotifierProvider call(String roomId) {
return ChatSubscribeNotifierProvider(roomId);
}
@override
ChatSubscribeNotifierProvider getProviderOverride(
covariant ChatSubscribeNotifierProvider provider,
) {
return call(provider.roomId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'chatSubscribeNotifierProvider';
}
/// See also [ChatSubscribeNotifier].
class ChatSubscribeNotifierProvider
extends
AutoDisposeNotifierProviderImpl<
ChatSubscribeNotifier,
List<SnChatMember>
> {
/// See also [ChatSubscribeNotifier].
ChatSubscribeNotifierProvider(String roomId)
: this._internal(
() => ChatSubscribeNotifier()..roomId = roomId,
from: chatSubscribeNotifierProvider,
name: r'chatSubscribeNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$chatSubscribeNotifierHash,
dependencies: ChatSubscribeNotifierFamily._dependencies,
allTransitiveDependencies:
ChatSubscribeNotifierFamily._allTransitiveDependencies,
roomId: roomId,
);
ChatSubscribeNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.roomId,
}) : super.internal();
final String roomId;
@override
List<SnChatMember> runNotifierBuild(
covariant ChatSubscribeNotifier notifier,
) {
return notifier.build(roomId);
}
@override
Override overrideWith(ChatSubscribeNotifier Function() create) {
return ProviderOverride(
origin: this,
override: ChatSubscribeNotifierProvider._internal(
() => create()..roomId = roomId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
roomId: roomId,
),
);
}
@override
AutoDisposeNotifierProviderElement<ChatSubscribeNotifier, List<SnChatMember>>
createElement() {
return _ChatSubscribeNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ChatSubscribeNotifierProvider && other.roomId == roomId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, roomId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ChatSubscribeNotifierRef
on AutoDisposeNotifierProviderRef<List<SnChatMember>> {
/// The parameter `roomId` of this provider.
String get roomId;
}
class _ChatSubscribeNotifierProviderElement
extends
AutoDisposeNotifierProviderElement<
ChatSubscribeNotifier,
List<SnChatMember>
>
with ChatSubscribeNotifierRef {
_ChatSubscribeNotifierProviderElement(super.provider);
@override
String get roomId => (origin as ChatSubscribeNotifierProvider).roomId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,6 +1,8 @@
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/pods/chat/chat_subscribe.dart';
part 'chat_summary.g.dart'; part 'chat_summary.g.dart';
@@ -12,9 +14,27 @@ class ChatSummary extends _$ChatSummary {
final resp = await client.get('/sphere/chat/summary'); final resp = await client.get('/sphere/chat/summary');
final Map<String, dynamic> data = resp.data; final Map<String, dynamic> data = resp.data;
return data.map( final summaries = data.map(
(key, value) => MapEntry(key, SnChatSummary.fromJson(value)), (key, value) => MapEntry(key, SnChatSummary.fromJson(value)),
); );
final ws = ref.watch(websocketProvider);
final subscription = ws.dataStream.listen((WebSocketPacket pkt) {
if (!pkt.type.startsWith('messages')) return;
if (pkt.type == 'messages.new') {
final message = SnChatMessage.fromJson(pkt.data!);
updateLastMessage(message.chatRoomId, message);
} else if (pkt.type == 'messages.update') {
final message = SnChatMessage.fromJson(pkt.data!);
updateMessageContent(message.chatRoomId, message);
}
});
ref.onDispose(() {
subscription.cancel();
});
return summaries;
} }
Future<void> clearUnreadCount(String chatId) async { Future<void> clearUnreadCount(String chatId) async {
@@ -36,10 +56,12 @@ class ChatSummary extends _$ChatSummary {
state.whenData((summaries) { state.whenData((summaries) {
final summary = summaries[chatId]; final summary = summaries[chatId];
if (summary != null) { if (summary != null) {
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
final increment = (chatId != currentSubscribed) ? 1 : 0;
state = AsyncData({ state = AsyncData({
...summaries, ...summaries,
chatId: SnChatSummary( chatId: SnChatSummary(
unreadCount: summary.unreadCount + 1, unreadCount: summary.unreadCount + increment,
lastMessage: message, lastMessage: message,
), ),
}); });
@@ -61,4 +83,19 @@ class ChatSummary extends _$ChatSummary {
} }
}); });
} }
void updateMessageContent(String chatId, SnChatMessage message) {
state.whenData((summaries) {
final summary = summaries[chatId];
if (summary != null && summary.lastMessage?.id == message.id) {
state = AsyncData({
...summaries,
chatId: SnChatSummary(
unreadCount: summary.unreadCount,
lastMessage: message,
),
});
}
});
}
} }

View File

@@ -6,7 +6,7 @@ part of 'chat_summary.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$chatSummaryHash() => r'87a10e4cefa37dc5fa8eadb175ef1b2bed6070bf'; String _$chatSummaryHash() => r'33815a3bd81d20902b7063e8194fe336930df9b4';
/// See also [ChatSummary]. /// See also [ChatSummary].
@ProviderFor(ChatSummary) @ProviderFor(ChatSummary)

View File

@@ -1,5 +1,4 @@
import "dart:async"; import "dart:async";
import "dart:developer" as developer;
import "package:dio/dio.dart"; import "package:dio/dio.dart";
import "package:drift/drift.dart" show Variable; import "package:drift/drift.dart" show Variable;
import "package:easy_localization/easy_localization.dart"; import "package:easy_localization/easy_localization.dart";
@@ -8,15 +7,16 @@ import "package:island/database/drift_db.dart";
import "package:island/database/message.dart"; import "package:island/database/message.dart";
import "package:island/models/chat.dart"; import "package:island/models/chat.dart";
import "package:island/models/file.dart"; import "package:island/models/file.dart";
import "package:island/pods/config.dart";
import "package:island/pods/database.dart"; import "package:island/pods/database.dart";
import "package:island/pods/lifecycle.dart";
import "package:island/pods/network.dart"; import "package:island/pods/network.dart";
import "package:island/services/file.dart"; import "package:island/services/file_uploader.dart";
import "package:island/talker.dart";
import "package:island/widgets/alert.dart"; import "package:island/widgets/alert.dart";
import "package:riverpod_annotation/riverpod_annotation.dart"; import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:uuid/uuid.dart"; import "package:uuid/uuid.dart";
import "package:island/screens/chat/chat.dart"; import "package:island/screens/chat/chat.dart";
import "package:island/pods/chat_rooms.dart"; import "package:island/pods/chat/chat_rooms.dart";
part 'messages_notifier.g.dart'; part 'messages_notifier.g.dart';
@@ -39,6 +39,8 @@ class MessagesNotifier extends _$MessagesNotifier {
bool _hasMore = true; bool _hasMore = true;
bool _isSyncing = false; bool _isSyncing = false;
bool _isJumping = false; bool _isJumping = false;
bool _isUpdatingState = false;
DateTime? _lastPauseTime;
@override @override
FutureOr<List<LocalChatMessage>> build(String roomId) async { FutureOr<List<LocalChatMessage>> build(String roomId) async {
@@ -58,21 +60,27 @@ class MessagesNotifier extends _$MessagesNotifier {
_identity = identity; _identity = identity;
} }
developer.log( talker.log('MessagesNotifier built for room $roomId');
'MessagesNotifier built for room $roomId',
name: 'MessagesNotifier',
);
// Only setup sync and lifecycle listeners if user is a member // Only setup sync and lifecycle listeners if user is a member
if (identity != null) { if (identity != null) {
ref.listen(appLifecycleStateProvider, (_, next) { ref.listen(appLifecycleStateProvider, (_, next) {
if (next.hasValue && next.value == AppLifecycleState.resumed) { next.whenData((state) {
developer.log( if (state == AppLifecycleState.paused) {
'App resumed, syncing messages', _lastPauseTime = DateTime.now();
name: 'MessagesNotifier', talker.log('App paused, recording time');
); } else if (state == AppLifecycleState.resumed) {
if (_lastPauseTime != null) {
final diff = DateTime.now().difference(_lastPauseTime!);
if (diff > const Duration(minutes: 1)) {
talker.log('App resumed after >1 min, syncing messages');
syncMessages(); syncMessages();
} else {
talker.log('App resumed within 1 min, skipping sync');
} }
}
}
});
}); });
} }
@@ -85,20 +93,43 @@ class MessagesNotifier extends _$MessagesNotifier {
return messages; return messages;
} }
Future<void> _updateStateSafely(List<LocalChatMessage> messages) async {
if (_isUpdatingState) {
talker.log('State update already in progress, skipping');
return;
}
_isUpdatingState = true;
try {
// Ensure messages are properly sorted and deduplicated
final sortedMessages = _sortMessages(messages);
final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{};
for (final message in sortedMessages) {
if (seenIds.add(message.id)) {
uniqueMessages.add(message);
}
}
state = AsyncValue.data(uniqueMessages);
} finally {
_isUpdatingState = false;
}
}
Future<List<LocalChatMessage>> _getCachedMessages({ Future<List<LocalChatMessage>> _getCachedMessages({
int offset = 0, int offset = 0,
int take = 20, int take = 20,
String? searchQuery,
bool? withLinks,
bool? withAttachments,
}) async { }) async {
developer.log( talker.log('Getting cached messages from offset $offset, take $take');
'Getting cached messages from offset $offset, take $take',
name: 'MessagesNotifier',
);
final List<LocalChatMessage> dbMessages; final List<LocalChatMessage> dbMessages;
if (_searchQuery != null && _searchQuery!.isNotEmpty) {
if (searchQuery != null && searchQuery.isNotEmpty) {
dbMessages = await _database.searchMessages( dbMessages = await _database.searchMessages(
_roomId, _roomId,
_searchQuery ?? '', searchQuery,
withAttachments: _withAttachments, withAttachments: withAttachments,
); );
} else { } else {
final chatMessagesFromDb = await _database.getMessagesForRoom( final chatMessagesFromDb = await _database.getMessagesForRoom(
@@ -112,11 +143,18 @@ class MessagesNotifier extends _$MessagesNotifier {
List<LocalChatMessage> filteredMessages = dbMessages; List<LocalChatMessage> filteredMessages = dbMessages;
if (_withLinks == true) { if (withLinks == true) {
filteredMessages = filteredMessages =
filteredMessages.where((msg) => _hasLink(msg)).toList(); filteredMessages.where((msg) => _hasLink(msg)).toList();
} }
if (withAttachments == true) {
filteredMessages =
filteredMessages
.where((msg) => msg.toRemoteMessage().attachments.isNotEmpty)
.toList();
}
final dbLocalMessages = filteredMessages; final dbLocalMessages = filteredMessages;
// Always ensure unique messages to prevent duplicate keys // Always ensure unique messages to prevent duplicate keys
@@ -150,14 +188,56 @@ class MessagesNotifier extends _$MessagesNotifier {
return uniqueMessages; return uniqueMessages;
} }
/// Get all messages without search filters for jump operations
Future<List<LocalChatMessage>> _getAllMessagesForJump({
int offset = 0,
int take = 20,
}) async {
talker.log('Getting all messages for jump from offset $offset, take $take');
final chatMessagesFromDb = await _database.getMessagesForRoom(
_roomId,
offset: offset,
limit: take,
);
final dbMessages =
chatMessagesFromDb.map(_database.companionToMessage).toList();
// Always ensure unique messages to prevent duplicate keys
final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{};
for (final message in dbMessages) {
if (seenIds.add(message.id)) {
uniqueMessages.add(message);
}
}
if (offset == 0) {
final pendingForRoom =
_pendingMessages.values
.where((msg) => msg.roomId == _roomId)
.toList();
final allMessages = [...pendingForRoom, ...uniqueMessages];
_sortMessages(allMessages);
final finalUniqueMessages = <LocalChatMessage>[];
final finalSeenIds = <String>{};
for (final message in allMessages) {
if (finalSeenIds.add(message.id)) {
finalUniqueMessages.add(message);
}
}
return finalUniqueMessages;
}
return uniqueMessages;
}
Future<List<LocalChatMessage>> _fetchAndCacheMessages({ Future<List<LocalChatMessage>> _fetchAndCacheMessages({
int offset = 0, int offset = 0,
int take = 20, int take = 20,
}) async { }) async {
developer.log( talker.log('Fetching messages from API, offset $offset, take $take');
'Fetching messages from API, offset $offset, take $take',
name: 'MessagesNotifier',
);
if (_totalCount == null) { if (_totalCount == null) {
final response = await _apiClient.get( final response = await _apiClient.get(
'/sphere/chat/$_roomId/messages', '/sphere/chat/$_roomId/messages',
@@ -201,15 +281,12 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> syncMessages() async { Future<void> syncMessages() async {
if (_isSyncing) { if (_isSyncing) {
developer.log( talker.log('Sync already in progress, skipping.');
'Sync already in progress, skipping.',
name: 'MessagesNotifier',
);
return; return;
} }
_isSyncing = true; _isSyncing = true;
developer.log('Starting message sync', name: 'MessagesNotifier'); talker.log('Starting message sync');
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true); Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
try { try {
final dbMessages = await _database.getMessagesForRoom( final dbMessages = await _database.getMessagesForRoom(
@@ -223,10 +300,7 @@ class MessagesNotifier extends _$MessagesNotifier {
: _database.companionToMessage(dbMessages.first); : _database.companionToMessage(dbMessages.first);
if (lastMessage == null) { if (lastMessage == null) {
developer.log( talker.log('No local messages, fetching from network');
'No local messages, fetching from network',
name: 'MessagesNotifier',
);
final newMessages = await _fetchAndCacheMessages( final newMessages = await _fetchAndCacheMessages(
offset: 0, offset: 0,
take: _pageSize, take: _pageSize,
@@ -244,10 +318,7 @@ class MessagesNotifier extends _$MessagesNotifier {
); );
final response = MessageSyncResponse.fromJson(resp.data); final response = MessageSyncResponse.fromJson(resp.data);
developer.log( talker.log('Sync response: ${response.messages.length} changes');
'Sync response: ${response.messages.length} changes',
name: 'MessagesNotifier',
);
for (final message in response.messages) { for (final message in response.messages) {
switch (message.type) { switch (message.type) {
case "messages.update": case "messages.update":
@@ -262,15 +333,14 @@ class MessagesNotifier extends _$MessagesNotifier {
await receiveMessage(message); await receiveMessage(message);
} }
} catch (err, stackTrace) { } catch (err, stackTrace) {
developer.log( talker.log(
'Error syncing messages', 'Error syncing messages',
name: 'MessagesNotifier', exception: err,
error: err,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
showErrorAlert(err); showErrorAlert(err);
} finally { } finally {
developer.log('Finished message sync', name: 'MessagesNotifier'); talker.log('Finished message sync');
Future.microtask( Future.microtask(
() => ref.read(isSyncingProvider.notifier).state = false, () => ref.read(isSyncingProvider.notifier).state = false,
); );
@@ -295,6 +365,9 @@ class MessagesNotifier extends _$MessagesNotifier {
final localMessages = await _getCachedMessages( final localMessages = await _getCachedMessages(
offset: offset, offset: offset,
take: take, take: take,
searchQuery: _searchQuery,
withLinks: _withLinks,
withAttachments: _withAttachments,
); );
if (localMessages.isNotEmpty) { if (localMessages.isNotEmpty) {
@@ -310,6 +383,9 @@ class MessagesNotifier extends _$MessagesNotifier {
final localMessages = await _getCachedMessages( final localMessages = await _getCachedMessages(
offset: offset, offset: offset,
take: take, take: take,
searchQuery: _searchQuery,
withLinks: _withLinks,
withAttachments: _withAttachments,
); );
if (localMessages.isNotEmpty) { if (localMessages.isNotEmpty) {
@@ -320,12 +396,18 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<void> loadInitial() async { Future<void> loadInitial() async {
developer.log('Loading initial messages', name: 'MessagesNotifier'); talker.log('Loading initial messages');
if (_searchQuery == null || _searchQuery!.isEmpty) { if (_searchQuery == null || _searchQuery!.isEmpty) {
syncMessages(); syncMessages();
} }
final messages = await _getCachedMessages(offset: 0, take: _pageSize); final messages = await _getCachedMessages(
offset: 0,
take: _pageSize,
searchQuery: _searchQuery,
withLinks: _withLinks,
withAttachments: _withAttachments,
);
_hasMore = messages.length == _pageSize; _hasMore = messages.length == _pageSize;
@@ -334,7 +416,7 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> loadMore() async { Future<void> loadMore() async {
if (!_hasMore || state is AsyncLoading) return; if (!_hasMore || state is AsyncLoading) return;
developer.log('Loading more messages', name: 'MessagesNotifier'); talker.log('Loading more messages');
try { try {
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
@@ -350,10 +432,10 @@ class MessagesNotifier extends _$MessagesNotifier {
_sortMessages([...currentMessages, ...newMessages]), _sortMessages([...currentMessages, ...newMessages]),
); );
} catch (err, stackTrace) { } catch (err, stackTrace) {
developer.log( talker.log(
'Error loading more messages', 'Error loading more messages',
name: 'MessagesNotifier',
error: err, exception: err,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
showErrorAlert(err); showErrorAlert(err);
@@ -369,13 +451,7 @@ class MessagesNotifier extends _$MessagesNotifier {
Function(String, Map<int, double>)? onProgress, Function(String, Map<int, double>)? onProgress,
}) async { }) async {
final nonce = const Uuid().v4(); final nonce = const Uuid().v4();
developer.log( talker.log('Sending message with nonce $nonce');
'Sending message with nonce $nonce',
name: 'MessagesNotifier',
);
final baseUrl = ref.read(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Access token is null');
final mockMessage = SnChatMessage( final mockMessage = SnChatMessage(
id: 'pending_$nonce', id: 'pending_$nonce',
@@ -404,19 +480,9 @@ class MessagesNotifier extends _$MessagesNotifier {
var cloudAttachments = List.empty(growable: true); var cloudAttachments = List.empty(growable: true);
for (var idx = 0; idx < attachments.length; idx++) { for (var idx = 0; idx < attachments.length; idx++) {
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
fileData: attachments[idx], fileData: attachments[idx],
atk: token, client: ref.read(apiClientProvider),
baseUrl: baseUrl,
filename: attachments[idx].data.name ?? 'Post media',
mimetype:
attachments[idx].data.mimeType ??
switch (attachments[idx].type) {
UniversalFileType.image => 'image/unknown',
UniversalFileType.video => 'video/unknown',
UniversalFileType.audio => 'audio/unknown',
UniversalFileType.file => 'application/octet-stream',
},
onProgress: (progress, _) { onProgress: (progress, _) {
_fileUploadProgress[localMessage.id]?[idx] = progress; _fileUploadProgress[localMessage.id]?[idx] = progress;
onProgress?.call( onProgress?.call(
@@ -476,15 +542,12 @@ class MessagesNotifier extends _$MessagesNotifier {
}).toList(); }).toList();
state = AsyncValue.data(newMessages); state = AsyncValue.data(newMessages);
} }
developer.log( talker.log('Message with nonce $nonce sent successfully');
'Message with nonce $nonce sent successfully',
name: 'MessagesNotifier',
);
} catch (e, stackTrace) { } catch (e, stackTrace) {
developer.log( talker.log(
'Failed to send message with nonce $nonce', 'Failed to send message with nonce $nonce',
name: 'MessagesNotifier',
error: e, exception: e,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
localMessage.status = MessageStatus.failed; localMessage.status = MessageStatus.failed;
@@ -506,10 +569,7 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<void> retryMessage(String pendingMessageId) async { Future<void> retryMessage(String pendingMessageId) async {
developer.log( talker.log('Retrying message $pendingMessageId');
'Retrying message $pendingMessageId',
name: 'MessagesNotifier',
);
final message = await fetchMessageById(pendingMessageId); final message = await fetchMessageById(pendingMessageId);
if (message == null) { if (message == null) {
throw Exception('Message not found'); throw Exception('Message not found');
@@ -553,10 +613,10 @@ class MessagesNotifier extends _$MessagesNotifier {
}).toList(); }).toList();
state = AsyncValue.data(newMessages); state = AsyncValue.data(newMessages);
} catch (e, stackTrace) { } catch (e, stackTrace) {
developer.log( talker.log(
'Failed to retry message $pendingMessageId', 'Failed to retry message $pendingMessageId',
name: 'MessagesNotifier',
error: e, exception: e,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
message.status = MessageStatus.failed; message.status = MessageStatus.failed;
@@ -579,10 +639,14 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> receiveMessage(SnChatMessage remoteMessage) async { Future<void> receiveMessage(SnChatMessage remoteMessage) async {
if (remoteMessage.chatRoomId != _roomId) return; if (remoteMessage.chatRoomId != _roomId) return;
developer.log(
'Received new message ${remoteMessage.id}', // Block message receiving during jumps to prevent list resets
name: 'MessagesNotifier', if (_isJumping) {
); talker.log('Blocking message receive during jump operation');
return;
}
talker.log('Received new message ${remoteMessage.id}');
final localMessage = LocalChatMessage.fromRemoteMessage( final localMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage, remoteMessage,
@@ -613,17 +677,35 @@ class MessagesNotifier extends _$MessagesNotifier {
_sortMessages([localMessage, ...currentMessages]), _sortMessages([localMessage, ...currentMessages]),
); );
} }
switch (remoteMessage.type) {
case "messages.delete":
await receiveMessageDeletion(
remoteMessage.meta['message_id'] ?? remoteMessage.id,
);
case "messages.update":
case "messages.update.links":
await receiveMessageUpdate(remoteMessage);
}
} }
Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async { Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
if (remoteMessage.chatRoomId != _roomId) return; if (remoteMessage.chatRoomId != _roomId) return;
developer.log(
'Received message update ${remoteMessage.id}',
name: 'MessagesNotifier',
);
// Block message updates during jumps to prevent list resets
if (_isJumping) {
talker.log('Blocking message update during jump operation');
return;
}
talker.log('Received message update ${remoteMessage.id}');
final targetId = remoteMessage.meta['message_id'] ?? remoteMessage.id;
final updatedMessage = LocalChatMessage.fromRemoteMessage( final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage, remoteMessage.copyWith(
id: targetId,
meta: Map.of(remoteMessage.meta)..remove('message_id'),
),
MessageStatus.sent, MessageStatus.sent,
); );
await _database.updateMessage(_database.messageToCompanion(updatedMessage)); await _database.updateMessage(_database.messageToCompanion(updatedMessage));
@@ -639,10 +721,13 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<void> receiveMessageDeletion(String messageId) async { Future<void> receiveMessageDeletion(String messageId) async {
developer.log( // Block message deletions during jumps to prevent list resets
'Received message deletion $messageId', if (_isJumping) {
name: 'MessagesNotifier', talker.log('Blocking message deletion during jump operation');
); return;
}
talker.log('Received message deletion $messageId');
_pendingMessages.remove(messageId); _pendingMessages.remove(messageId);
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
@@ -679,26 +764,114 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<void> deleteMessage(String messageId) async { Future<void> deleteMessage(String messageId) async {
developer.log('Deleting message $messageId', name: 'MessagesNotifier'); talker.log('Deleting message $messageId');
// Fetch message to check its status before attempting server delete
final message = await fetchMessageById(messageId);
if (message == null) {
talker.log('Message $messageId not found for deletion');
return;
}
// Skip server delete for failed messages (never successfully sent)
if (message.status == MessageStatus.failed) {
talker.log('Skipping server delete for failed message $messageId');
// For failed messages, remove them completely from the active list
_pendingMessages.remove(messageId);
await _database.deleteMessage(messageId);
final currentMessages = state.value ?? [];
final newMessages =
currentMessages.where((m) => m.id != messageId).toList();
state = AsyncValue.data(newMessages);
return;
}
try { try {
await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId'); await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId');
await receiveMessageDeletion(messageId); await receiveMessageDeletion(messageId);
} catch (err, stackTrace) { } catch (err, stackTrace) {
developer.log( talker.log(
'Error deleting message $messageId', 'Error deleting message $messageId',
name: 'MessagesNotifier', exception: err,
error: err,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
showErrorAlert(err); showErrorAlert(err);
} }
} }
void searchMessages(String query, {bool? withLinks, bool? withAttachments}) { /// Get search results without updating shared state
Future<List<LocalChatMessage>> getSearchResults(
String query, {
bool? withLinks,
bool? withAttachments,
}) async {
final trimmedQuery = query.trim();
final hasFilters = [withLinks, withAttachments].any((e) => e == true);
if (trimmedQuery.isEmpty && !hasFilters) {
return [];
}
talker.log(
'Getting search results for query: $trimmedQuery, filters: links=$withLinks, attachments=$withAttachments',
);
try {
// When filtering without query, get more messages to ensure we find all matches
final take = (trimmedQuery.isEmpty && hasFilters) ? 1000 : 50;
final messages = await _getCachedMessages(
offset: 0,
take: take,
searchQuery: trimmedQuery.isNotEmpty ? trimmedQuery : null,
withLinks: withLinks,
withAttachments: withAttachments,
); // Limit initial search results
return messages;
} catch (e, stackTrace) {
talker.log(
'Error getting search results',
exception: e,
stackTrace: stackTrace,
);
rethrow;
}
}
Future<void> searchMessages(
String query, {
bool? withLinks,
bool? withAttachments,
}) async {
_searchQuery = query.trim(); _searchQuery = query.trim();
_withLinks = withLinks; _withLinks = withLinks;
_withAttachments = withAttachments; _withAttachments = withAttachments;
loadInitial();
if (_searchQuery!.isEmpty) {
state = AsyncValue.data([]);
return;
}
talker.log('Searching messages with query: $_searchQuery');
state = const AsyncValue.loading();
try {
final messages = await _getCachedMessages(
offset: 0,
take: 50,
searchQuery: _searchQuery,
withLinks: _withLinks,
withAttachments: _withAttachments,
); // Limit initial search results
state = AsyncValue.data(messages);
} catch (e, stackTrace) {
talker.log(
'Error searching messages',
exception: e,
stackTrace: stackTrace,
);
state = AsyncValue.error(e, stackTrace);
}
} }
void clearSearch() { void clearSearch() {
@@ -709,10 +882,7 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<LocalChatMessage?> fetchMessageById(String messageId) async { Future<LocalChatMessage?> fetchMessageById(String messageId) async {
developer.log( talker.log('Fetching message by id $messageId');
'Fetching message by id $messageId',
name: 'MessagesNotifier',
);
try { try {
final localMessage = final localMessage =
await (_database.select(_database.chatMessages) await (_database.select(_database.chatMessages)
@@ -739,24 +909,21 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<int> jumpToMessage(String messageId) async { Future<int> jumpToMessage(String messageId) async {
developer.log( talker.log('Starting jump to message $messageId');
'Starting jump to message $messageId',
name: 'MessagesNotifier',
);
if (_isJumping) { if (_isJumping) {
developer.log( talker.log('Jump already in progress, skipping');
'Jump already in progress, skipping',
name: 'MessagesNotifier',
);
return -1; return -1;
} }
_isJumping = true; _isJumping = true;
// Clear flashing messages when starting a new jump
ref.read(flashingMessagesProvider.notifier).state = {};
try { try {
developer.log('Fetching message $messageId', name: 'MessagesNotifier'); talker.log('Fetching message $messageId');
final message = await fetchMessageById(messageId); final message = await fetchMessageById(messageId);
if (message == null) { if (message == null) {
developer.log('Message $messageId not found', name: 'MessagesNotifier'); talker.log('Message $messageId not found');
showSnackBar('messageNotFound'.tr()); showSnackBar('messageNotFound'.tr());
return -1; return -1;
} }
@@ -767,19 +934,18 @@ class MessagesNotifier extends _$MessagesNotifier {
(m) => m.id == messageId, (m) => m.id == messageId,
); );
if (existingIndex >= 0) { if (existingIndex >= 0) {
developer.log( talker.log(
'Message $messageId already in current state at index $existingIndex, jumping directly', 'Message $messageId already in current state at index $existingIndex, jumping directly',
name: 'MessagesNotifier',
); );
return existingIndex; return existingIndex;
} }
developer.log( talker.log(
'Message $messageId not in current state, loading messages around it', 'Message $messageId not in current state, calculating position and loading messages around it',
name: 'MessagesNotifier',
); );
// Count messages newer than this one // Count messages newer than the target message to calculate optimal offset
// Use full message list (not filtered by search) for accurate position calculation
final query = _database.customSelect( final query = _database.customSelect(
'SELECT COUNT(*) as count FROM chat_messages WHERE room_id = ? AND created_at > ?', 'SELECT COUNT(*) as count FROM chat_messages WHERE room_id = ? AND created_at > ?',
variables: [ variables: [
@@ -791,29 +957,29 @@ class MessagesNotifier extends _$MessagesNotifier {
final result = await query.getSingle(); final result = await query.getSingle();
final newerCount = result.read<int>('count'); final newerCount = result.read<int>('count');
// Load messages around this position // Calculate offset to position target message in the middle of the loaded chunk
const chunkSize = 100; // Load 100 messages around the target
final offset = final offset =
(newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt(); (newerCount - chunkSize ~/ 2).clamp(0, double.infinity).toInt();
developer.log( talker.log(
'Loading messages with offset $offset, take $_pageSize', 'Calculated offset $offset for target message (newer: $newerCount, chunk: $chunkSize)',
name: 'MessagesNotifier',
); );
final loadedMessages = await _getCachedMessages( // Use full message list (not filtered by search) for jump operations
final loadedMessages = await _getAllMessagesForJump(
offset: offset, offset: offset,
take: _pageSize, take: chunkSize,
); );
// Check if loaded messages are already in current state // Check if loaded messages are already in current state
final currentIds = currentMessages.map((m) => m.id).toSet(); final currentIds = currentMessages.map((m) => m.id).toSet();
final newMessages = final newMessages =
loadedMessages.where((m) => !currentIds.contains(m.id)).toList(); loadedMessages.where((m) => !currentIds.contains(m.id)).toList();
developer.log( talker.log(
'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new', 'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new',
name: 'MessagesNotifier',
); );
if (newMessages.isNotEmpty) { if (newMessages.isNotEmpty) {
// Merge with current messages // Merge with current messages more safely
final allMessages = [...currentMessages, ...newMessages]; final allMessages = [...currentMessages, ...newMessages];
final uniqueMessages = <LocalChatMessage>[]; final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{}; final seenIds = <String>{};
@@ -822,21 +988,37 @@ class MessagesNotifier extends _$MessagesNotifier {
uniqueMessages.add(message); uniqueMessages.add(message);
} }
} }
_sortMessages(uniqueMessages); await _updateStateSafely(uniqueMessages);
state = AsyncValue.data(uniqueMessages); talker.log(
developer.log(
'Updated state with ${uniqueMessages.length} total messages', 'Updated state with ${uniqueMessages.length} total messages',
name: 'MessagesNotifier',
); );
} }
// Wait a bit for the UI to rebuild with new messages
await Future.delayed(const Duration(milliseconds: 100));
final finalIndex = (state.value ?? []).indexWhere( final finalIndex = (state.value ?? []).indexWhere(
(m) => m.id == messageId, (m) => m.id == messageId,
); );
developer.log( talker.log('Final index for message $messageId is $finalIndex');
'Final index for message $messageId is $finalIndex',
name: 'MessagesNotifier', // Verify the message is actually in the list before returning
if (finalIndex == -1) {
talker.log(
'Message $messageId still not found after loading, trying direct fetch',
); );
// Try to fetch and add the specific message if it's still not found
final directMessage = await fetchMessageById(messageId);
if (directMessage != null) {
final currentList = state.value ?? [];
final updatedList = [...currentList, directMessage];
await _updateStateSafely(updatedList);
final newIndex = updatedList.indexWhere((m) => m.id == messageId);
talker.log('Added message directly, new index: $newIndex');
return newIndex;
}
}
return finalIndex; return finalIndex;
} finally { } finally {
_isJumping = false; _isJumping = false;

View File

@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$messagesNotifierHash() => r'37bab723531a5c5248471ef810b74cf57b3dc237'; String _$messagesNotifierHash() => r'639286fd8e4e0cfdef5be6cf5d80eea769007cb3';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@@ -1,9 +1,11 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/pods/theme.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart';
part 'config.freezed.dart'; part 'config.freezed.dart';
part 'config.g.dart'; part 'config.g.dart';
@@ -17,6 +19,7 @@ const kAppbarTransparentStoreKey = 'app_bar_transparent';
const kAppBackgroundStoreKey = 'app_has_background'; const kAppBackgroundStoreKey = 'app_has_background';
const kAppShowBackgroundImage = 'app_show_background_image'; const kAppShowBackgroundImage = 'app_show_background_image';
const kAppColorSchemeStoreKey = 'app_color_scheme'; const kAppColorSchemeStoreKey = 'app_color_scheme';
const kAppCustomColorsStoreKey = 'app_custom_colors';
const kAppNotifyWithHaptic = 'app_notify_with_haptic'; const kAppNotifyWithHaptic = 'app_notify_with_haptic';
const kAppCustomFonts = 'app_custom_fonts'; const kAppCustomFonts = 'app_custom_fonts';
const kAppAutoTranslate = 'app_auto_translate'; const kAppAutoTranslate = 'app_auto_translate';
@@ -24,9 +27,13 @@ const kAppDataSavingMode = 'app_data_saving_mode';
const kAppSoundEffects = 'app_sound_effects'; const kAppSoundEffects = 'app_sound_effects';
const kAppAprilFoolFeatures = 'app_april_fool_features'; const kAppAprilFoolFeatures = 'app_april_fool_features';
const kAppWindowSize = 'app_window_size'; const kAppWindowSize = 'app_window_size';
const kAppWindowOpacity = 'app_window_opacity';
const kAppCardTransparent = 'app_card_transparent';
const kAppEnterToSend = 'app_enter_to_send'; const kAppEnterToSend = 'app_enter_to_send';
const kAppDefaultPoolId = 'app_default_pool_id'; const kAppDefaultPoolId = 'app_default_pool_id';
const kAppMessageDisplayStyle = 'app_message_display_style'; const kAppMessageDisplayStyle = 'app_message_display_style';
const kAppThemeMode = 'app_theme_mode';
const kMaterialYouToggleStoreKey = 'app_theme_material_you';
const kFeaturedPostsCollapsedId = const kFeaturedPostsCollapsedId =
'featured_posts_collapsed_id'; // Key for storing the ID of the collapsed featured post 'featured_posts_collapsed_id'; // Key for storing the ID of the collapsed featured post
@@ -54,6 +61,21 @@ final serverUrlProvider = Provider<String>((ref) {
return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault; return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
}); });
@freezed
sealed class ThemeColors with _$ThemeColors {
factory ThemeColors({
int? primary,
int? secondary,
int? tertiary,
int? surface,
int? background,
int? error,
}) = _ThemeColors;
factory ThemeColors.fromJson(Map<String, dynamic> json) =>
_$ThemeColorsFromJson(json);
}
@freezed @freezed
sealed class AppSettings with _$AppSettings { sealed class AppSettings with _$AppSettings {
const factory AppSettings({ const factory AppSettings({
@@ -66,9 +88,14 @@ sealed class AppSettings with _$AppSettings {
required bool showBackgroundImage, required bool showBackgroundImage,
required String? customFonts, required String? customFonts,
required int? appColorScheme, // The color stored via the int type required int? appColorScheme, // The color stored via the int type
required ThemeColors? customColors,
required Size? windowSize, // The window size for desktop platforms required Size? windowSize, // The window size for desktop platforms
required double windowOpacity, // The window opacity for desktop platforms
required double cardTransparency, // The card background opacity
required String? defaultPoolId, required String? defaultPoolId,
required String messageDisplayStyle, required String messageDisplayStyle,
required String? themeMode,
required bool useMaterial3,
}) = _AppSettings; }) = _AppSettings;
} }
@@ -87,9 +114,14 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
showBackgroundImage: prefs.getBool(kAppShowBackgroundImage) ?? true, showBackgroundImage: prefs.getBool(kAppShowBackgroundImage) ?? true,
customFonts: prefs.getString(kAppCustomFonts), customFonts: prefs.getString(kAppCustomFonts),
appColorScheme: prefs.getInt(kAppColorSchemeStoreKey), appColorScheme: prefs.getInt(kAppColorSchemeStoreKey),
customColors: _getThemeColorsFromPrefs(prefs),
windowSize: _getWindowSizeFromPrefs(prefs), windowSize: _getWindowSizeFromPrefs(prefs),
windowOpacity: prefs.getDouble(kAppWindowOpacity) ?? 1.0,
cardTransparency: prefs.getDouble(kAppCardTransparent) ?? 1.0,
defaultPoolId: prefs.getString(kAppDefaultPoolId), defaultPoolId: prefs.getString(kAppDefaultPoolId),
messageDisplayStyle: prefs.getString(kAppMessageDisplayStyle) ?? 'bubble', messageDisplayStyle: prefs.getString(kAppMessageDisplayStyle) ?? 'bubble',
themeMode: prefs.getString(kAppThemeMode) ?? 'system',
useMaterial3: prefs.getBool(kMaterialYouToggleStoreKey) ?? true,
); );
} }
@@ -110,6 +142,18 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
return null; return null;
} }
ThemeColors? _getThemeColorsFromPrefs(SharedPreferences prefs) {
final jsonString = prefs.getString(kAppCustomColorsStoreKey);
if (jsonString == null) return null;
try {
final json = jsonDecode(jsonString);
return ThemeColors.fromJson(json);
} catch (e) {
return null;
}
}
void setDefaultPoolId(String? value) { void setDefaultPoolId(String? value) {
final prefs = ref.read(sharedPreferencesProvider); final prefs = ref.read(sharedPreferencesProvider);
if (value != null) { if (value != null) {
@@ -154,7 +198,6 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
final prefs = ref.read(sharedPreferencesProvider); final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kAppbarTransparentStoreKey, value); prefs.setBool(kAppbarTransparentStoreKey, value);
state = state.copyWith(appBarTransparent: value); state = state.copyWith(appBarTransparent: value);
ref.read(themeProvider.notifier).reloadTheme();
} }
void setShowBackgroundImage(bool value) { void setShowBackgroundImage(bool value) {
@@ -167,14 +210,12 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
final prefs = ref.read(sharedPreferencesProvider); final prefs = ref.read(sharedPreferencesProvider);
prefs.setString(kAppCustomFonts, value ?? ''); prefs.setString(kAppCustomFonts, value ?? '');
state = state.copyWith(customFonts: value); state = state.copyWith(customFonts: value);
ref.read(themeProvider.notifier).reloadTheme();
} }
void setAppColorScheme(int? value) { void setAppColorScheme(int? value) {
final prefs = ref.read(sharedPreferencesProvider); final prefs = ref.read(sharedPreferencesProvider);
prefs.setInt(kAppColorSchemeStoreKey, value ?? 0); prefs.setInt(kAppColorSchemeStoreKey, value ?? 0);
state = state.copyWith(appColorScheme: value); state = state.copyWith(appColorScheme: value);
ref.read(themeProvider.notifier).reloadTheme();
} }
void setWindowSize(Size? size) { void setWindowSize(Size? size) {
@@ -196,6 +237,42 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
prefs.setString(kAppMessageDisplayStyle, value); prefs.setString(kAppMessageDisplayStyle, value);
state = state.copyWith(messageDisplayStyle: value); state = state.copyWith(messageDisplayStyle: value);
} }
void setWindowOpacity(double value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setDouble(kAppWindowOpacity, value);
state = state.copyWith(windowOpacity: value);
Future(() => windowManager.setOpacity(value));
}
void setThemeMode(String value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setString(kAppThemeMode, value);
state = state.copyWith(themeMode: value);
}
void setAppTransparentBackground(double value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setDouble(kAppCardTransparent, value);
state = state.copyWith(cardTransparency: value);
}
void setUseMaterial3(bool value) {
final prefs = ref.read(sharedPreferencesProvider);
prefs.setBool(kMaterialYouToggleStoreKey, value);
state = state.copyWith(useMaterial3: value);
}
void setCustomColors(ThemeColors? value) {
final prefs = ref.read(sharedPreferencesProvider);
if (value != null) {
final json = jsonEncode(value.toJson());
prefs.setString(kAppCustomColorsStoreKey, json);
} else {
prefs.remove(kAppCustomColorsStoreKey);
}
state = state.copyWith(customColors: value);
}
} }
final updateInfoProvider = final updateInfoProvider =

View File

@@ -11,12 +11,286 @@ part of 'config.dart';
// dart format off // dart format off
T _$identity<T>(T value) => value; T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ThemeColors {
int? get primary; int? get secondary; int? get tertiary; int? get surface; int? get background; int? get error;
/// Create a copy of ThemeColors
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ThemeColorsCopyWith<ThemeColors> get copyWith => _$ThemeColorsCopyWithImpl<ThemeColors>(this as ThemeColors, _$identity);
/// Serializes this ThemeColors to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ThemeColors&&(identical(other.primary, primary) || other.primary == primary)&&(identical(other.secondary, secondary) || other.secondary == secondary)&&(identical(other.tertiary, tertiary) || other.tertiary == tertiary)&&(identical(other.surface, surface) || other.surface == surface)&&(identical(other.background, background) || other.background == background)&&(identical(other.error, error) || other.error == error));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,primary,secondary,tertiary,surface,background,error);
@override
String toString() {
return 'ThemeColors(primary: $primary, secondary: $secondary, tertiary: $tertiary, surface: $surface, background: $background, error: $error)';
}
}
/// @nodoc
abstract mixin class $ThemeColorsCopyWith<$Res> {
factory $ThemeColorsCopyWith(ThemeColors value, $Res Function(ThemeColors) _then) = _$ThemeColorsCopyWithImpl;
@useResult
$Res call({
int? primary, int? secondary, int? tertiary, int? surface, int? background, int? error
});
}
/// @nodoc
class _$ThemeColorsCopyWithImpl<$Res>
implements $ThemeColorsCopyWith<$Res> {
_$ThemeColorsCopyWithImpl(this._self, this._then);
final ThemeColors _self;
final $Res Function(ThemeColors) _then;
/// Create a copy of ThemeColors
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? primary = freezed,Object? secondary = freezed,Object? tertiary = freezed,Object? surface = freezed,Object? background = freezed,Object? error = freezed,}) {
return _then(_self.copyWith(
primary: freezed == primary ? _self.primary : primary // ignore: cast_nullable_to_non_nullable
as int?,secondary: freezed == secondary ? _self.secondary : secondary // ignore: cast_nullable_to_non_nullable
as int?,tertiary: freezed == tertiary ? _self.tertiary : tertiary // ignore: cast_nullable_to_non_nullable
as int?,surface: freezed == surface ? _self.surface : surface // ignore: cast_nullable_to_non_nullable
as int?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
as int?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
/// Adds pattern-matching-related methods to [ThemeColors].
extension ThemeColorsPatterns on ThemeColors {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ThemeColors value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ThemeColors() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ThemeColors value) $default,){
final _that = this;
switch (_that) {
case _ThemeColors():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ThemeColors value)? $default,){
final _that = this;
switch (_that) {
case _ThemeColors() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( int? primary, int? secondary, int? tertiary, int? surface, int? background, int? error)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ThemeColors() when $default != null:
return $default(_that.primary,_that.secondary,_that.tertiary,_that.surface,_that.background,_that.error);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( int? primary, int? secondary, int? tertiary, int? surface, int? background, int? error) $default,) {final _that = this;
switch (_that) {
case _ThemeColors():
return $default(_that.primary,_that.secondary,_that.tertiary,_that.surface,_that.background,_that.error);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( int? primary, int? secondary, int? tertiary, int? surface, int? background, int? error)? $default,) {final _that = this;
switch (_that) {
case _ThemeColors() when $default != null:
return $default(_that.primary,_that.secondary,_that.tertiary,_that.surface,_that.background,_that.error);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ThemeColors implements ThemeColors {
_ThemeColors({this.primary, this.secondary, this.tertiary, this.surface, this.background, this.error});
factory _ThemeColors.fromJson(Map<String, dynamic> json) => _$ThemeColorsFromJson(json);
@override final int? primary;
@override final int? secondary;
@override final int? tertiary;
@override final int? surface;
@override final int? background;
@override final int? error;
/// Create a copy of ThemeColors
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ThemeColorsCopyWith<_ThemeColors> get copyWith => __$ThemeColorsCopyWithImpl<_ThemeColors>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ThemeColorsToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ThemeColors&&(identical(other.primary, primary) || other.primary == primary)&&(identical(other.secondary, secondary) || other.secondary == secondary)&&(identical(other.tertiary, tertiary) || other.tertiary == tertiary)&&(identical(other.surface, surface) || other.surface == surface)&&(identical(other.background, background) || other.background == background)&&(identical(other.error, error) || other.error == error));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,primary,secondary,tertiary,surface,background,error);
@override
String toString() {
return 'ThemeColors(primary: $primary, secondary: $secondary, tertiary: $tertiary, surface: $surface, background: $background, error: $error)';
}
}
/// @nodoc
abstract mixin class _$ThemeColorsCopyWith<$Res> implements $ThemeColorsCopyWith<$Res> {
factory _$ThemeColorsCopyWith(_ThemeColors value, $Res Function(_ThemeColors) _then) = __$ThemeColorsCopyWithImpl;
@override @useResult
$Res call({
int? primary, int? secondary, int? tertiary, int? surface, int? background, int? error
});
}
/// @nodoc
class __$ThemeColorsCopyWithImpl<$Res>
implements _$ThemeColorsCopyWith<$Res> {
__$ThemeColorsCopyWithImpl(this._self, this._then);
final _ThemeColors _self;
final $Res Function(_ThemeColors) _then;
/// Create a copy of ThemeColors
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? primary = freezed,Object? secondary = freezed,Object? tertiary = freezed,Object? surface = freezed,Object? background = freezed,Object? error = freezed,}) {
return _then(_ThemeColors(
primary: freezed == primary ? _self.primary : primary // ignore: cast_nullable_to_non_nullable
as int?,secondary: freezed == secondary ? _self.secondary : secondary // ignore: cast_nullable_to_non_nullable
as int?,tertiary: freezed == tertiary ? _self.tertiary : tertiary // ignore: cast_nullable_to_non_nullable
as int?,surface: freezed == surface ? _self.surface : surface // ignore: cast_nullable_to_non_nullable
as int?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
as int?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
/// @nodoc /// @nodoc
mixin _$AppSettings { mixin _$AppSettings {
bool get autoTranslate; bool get dataSavingMode; bool get soundEffects; bool get aprilFoolFeatures; bool get enterToSend; bool get appBarTransparent; bool get showBackgroundImage; String? get customFonts; int? get appColorScheme;// The color stored via the int type bool get autoTranslate; bool get dataSavingMode; bool get soundEffects; bool get aprilFoolFeatures; bool get enterToSend; bool get appBarTransparent; bool get showBackgroundImage; String? get customFonts; int? get appColorScheme;// The color stored via the int type
Size? get windowSize;// The window size for desktop platforms ThemeColors? get customColors; Size? get windowSize;// The window size for desktop platforms
String? get defaultPoolId; String get messageDisplayStyle; double get windowOpacity;// The window opacity for desktop platforms
double get cardTransparency;// The card background opacity
String? get defaultPoolId; String get messageDisplayStyle; String? get themeMode; bool get useMaterial3;
/// Create a copy of AppSettings /// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -27,16 +301,16 @@ $AppSettingsCopyWith<AppSettings> get copyWith => _$AppSettingsCopyWithImpl<AppS
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)); return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.customColors, customColors) || other.customColors == customColors)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.windowOpacity, windowOpacity) || other.windowOpacity == windowOpacity)&&(identical(other.cardTransparency, cardTransparency) || other.cardTransparency == cardTransparency)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode)&&(identical(other.useMaterial3, useMaterial3) || other.useMaterial3 == useMaterial3));
} }
@override @override
int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,windowSize,defaultPoolId,messageDisplayStyle); int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,customColors,windowSize,windowOpacity,cardTransparency,defaultPoolId,messageDisplayStyle,themeMode,useMaterial3);
@override @override
String toString() { String toString() {
return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, windowSize: $windowSize, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle)'; return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, customColors: $customColors, windowSize: $windowSize, windowOpacity: $windowOpacity, cardTransparency: $cardTransparency, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle, themeMode: $themeMode, useMaterial3: $useMaterial3)';
} }
@@ -47,11 +321,11 @@ abstract mixin class $AppSettingsCopyWith<$Res> {
factory $AppSettingsCopyWith(AppSettings value, $Res Function(AppSettings) _then) = _$AppSettingsCopyWithImpl; factory $AppSettingsCopyWith(AppSettings value, $Res Function(AppSettings) _then) = _$AppSettingsCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId, String messageDisplayStyle bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3
}); });
$ThemeColorsCopyWith<$Res>? get customColors;
} }
/// @nodoc /// @nodoc
@@ -64,7 +338,7 @@ class _$AppSettingsCopyWithImpl<$Res>
/// Create a copy of AppSettings /// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? windowSize = freezed,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,}) { @pragma('vm:prefer-inline') @override $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? customColors = freezed,Object? windowSize = freezed,Object? windowOpacity = null,Object? cardTransparency = null,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,Object? themeMode = freezed,Object? useMaterial3 = null,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable
as bool,dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable as bool,dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable
@@ -75,13 +349,30 @@ as bool,appBarTransparent: null == appBarTransparent ? _self.appBarTransparent :
as bool,showBackgroundImage: null == showBackgroundImage ? _self.showBackgroundImage : showBackgroundImage // ignore: cast_nullable_to_non_nullable as bool,showBackgroundImage: null == showBackgroundImage ? _self.showBackgroundImage : showBackgroundImage // ignore: cast_nullable_to_non_nullable
as bool,customFonts: freezed == customFonts ? _self.customFonts : customFonts // ignore: cast_nullable_to_non_nullable as bool,customFonts: freezed == customFonts ? _self.customFonts : customFonts // ignore: cast_nullable_to_non_nullable
as String?,appColorScheme: freezed == appColorScheme ? _self.appColorScheme : appColorScheme // ignore: cast_nullable_to_non_nullable as String?,appColorScheme: freezed == appColorScheme ? _self.appColorScheme : appColorScheme // ignore: cast_nullable_to_non_nullable
as int?,windowSize: freezed == windowSize ? _self.windowSize : windowSize // ignore: cast_nullable_to_non_nullable as int?,customColors: freezed == customColors ? _self.customColors : customColors // ignore: cast_nullable_to_non_nullable
as Size?,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable as ThemeColors?,windowSize: freezed == windowSize ? _self.windowSize : windowSize // ignore: cast_nullable_to_non_nullable
as Size?,windowOpacity: null == windowOpacity ? _self.windowOpacity : windowOpacity // ignore: cast_nullable_to_non_nullable
as double,cardTransparency: null == cardTransparency ? _self.cardTransparency : cardTransparency // ignore: cast_nullable_to_non_nullable
as double,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable
as String?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable as String?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable
as String, as String,themeMode: freezed == themeMode ? _self.themeMode : themeMode // ignore: cast_nullable_to_non_nullable
as String?,useMaterial3: null == useMaterial3 ? _self.useMaterial3 : useMaterial3 // ignore: cast_nullable_to_non_nullable
as bool,
)); ));
} }
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ThemeColorsCopyWith<$Res>? get customColors {
if (_self.customColors == null) {
return null;
}
return $ThemeColorsCopyWith<$Res>(_self.customColors!, (value) {
return _then(_self.copyWith(customColors: value));
});
}
} }
@@ -160,10 +451,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId, String messageDisplayStyle)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _AppSettings() when $default != null: case _AppSettings() when $default != null:
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.defaultPoolId,_that.messageDisplayStyle);case _: return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.useMaterial3);case _:
return orElse(); return orElse();
} }
@@ -181,10 +472,10 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId, String messageDisplayStyle) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _AppSettings(): case _AppSettings():
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.defaultPoolId,_that.messageDisplayStyle);} return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.useMaterial3);}
} }
/// A variant of `when` that fallback to returning `null` /// A variant of `when` that fallback to returning `null`
/// ///
@@ -198,10 +489,10 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId, String messageDisplayStyle)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _AppSettings() when $default != null: case _AppSettings() when $default != null:
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.defaultPoolId,_that.messageDisplayStyle);case _: return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.useMaterial3);case _:
return null; return null;
} }
@@ -213,7 +504,7 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha
class _AppSettings implements AppSettings { class _AppSettings implements AppSettings {
const _AppSettings({required this.autoTranslate, required this.dataSavingMode, required this.soundEffects, required this.aprilFoolFeatures, required this.enterToSend, required this.appBarTransparent, required this.showBackgroundImage, required this.customFonts, required this.appColorScheme, required this.windowSize, required this.defaultPoolId, required this.messageDisplayStyle}); const _AppSettings({required this.autoTranslate, required this.dataSavingMode, required this.soundEffects, required this.aprilFoolFeatures, required this.enterToSend, required this.appBarTransparent, required this.showBackgroundImage, required this.customFonts, required this.appColorScheme, required this.customColors, required this.windowSize, required this.windowOpacity, required this.cardTransparency, required this.defaultPoolId, required this.messageDisplayStyle, required this.themeMode, required this.useMaterial3});
@override final bool autoTranslate; @override final bool autoTranslate;
@@ -226,10 +517,17 @@ class _AppSettings implements AppSettings {
@override final String? customFonts; @override final String? customFonts;
@override final int? appColorScheme; @override final int? appColorScheme;
// The color stored via the int type // The color stored via the int type
@override final ThemeColors? customColors;
@override final Size? windowSize; @override final Size? windowSize;
// The window size for desktop platforms // The window size for desktop platforms
@override final double windowOpacity;
// The window opacity for desktop platforms
@override final double cardTransparency;
// The card background opacity
@override final String? defaultPoolId; @override final String? defaultPoolId;
@override final String messageDisplayStyle; @override final String messageDisplayStyle;
@override final String? themeMode;
@override final bool useMaterial3;
/// Create a copy of AppSettings /// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -241,16 +539,16 @@ _$AppSettingsCopyWith<_AppSettings> get copyWith => __$AppSettingsCopyWithImpl<_
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.customColors, customColors) || other.customColors == customColors)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.windowOpacity, windowOpacity) || other.windowOpacity == windowOpacity)&&(identical(other.cardTransparency, cardTransparency) || other.cardTransparency == cardTransparency)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode)&&(identical(other.useMaterial3, useMaterial3) || other.useMaterial3 == useMaterial3));
} }
@override @override
int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,windowSize,defaultPoolId,messageDisplayStyle); int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,customColors,windowSize,windowOpacity,cardTransparency,defaultPoolId,messageDisplayStyle,themeMode,useMaterial3);
@override @override
String toString() { String toString() {
return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, windowSize: $windowSize, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle)'; return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, customColors: $customColors, windowSize: $windowSize, windowOpacity: $windowOpacity, cardTransparency: $cardTransparency, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle, themeMode: $themeMode, useMaterial3: $useMaterial3)';
} }
@@ -261,11 +559,11 @@ abstract mixin class _$AppSettingsCopyWith<$Res> implements $AppSettingsCopyWith
factory _$AppSettingsCopyWith(_AppSettings value, $Res Function(_AppSettings) _then) = __$AppSettingsCopyWithImpl; factory _$AppSettingsCopyWith(_AppSettings value, $Res Function(_AppSettings) _then) = __$AppSettingsCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId, String messageDisplayStyle bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3
}); });
@override $ThemeColorsCopyWith<$Res>? get customColors;
} }
/// @nodoc /// @nodoc
@@ -278,7 +576,7 @@ class __$AppSettingsCopyWithImpl<$Res>
/// Create a copy of AppSettings /// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? windowSize = freezed,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,}) { @override @pragma('vm:prefer-inline') $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? customColors = freezed,Object? windowSize = freezed,Object? windowOpacity = null,Object? cardTransparency = null,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,Object? themeMode = freezed,Object? useMaterial3 = null,}) {
return _then(_AppSettings( return _then(_AppSettings(
autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable
as bool,dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable as bool,dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable
@@ -289,14 +587,31 @@ as bool,appBarTransparent: null == appBarTransparent ? _self.appBarTransparent :
as bool,showBackgroundImage: null == showBackgroundImage ? _self.showBackgroundImage : showBackgroundImage // ignore: cast_nullable_to_non_nullable as bool,showBackgroundImage: null == showBackgroundImage ? _self.showBackgroundImage : showBackgroundImage // ignore: cast_nullable_to_non_nullable
as bool,customFonts: freezed == customFonts ? _self.customFonts : customFonts // ignore: cast_nullable_to_non_nullable as bool,customFonts: freezed == customFonts ? _self.customFonts : customFonts // ignore: cast_nullable_to_non_nullable
as String?,appColorScheme: freezed == appColorScheme ? _self.appColorScheme : appColorScheme // ignore: cast_nullable_to_non_nullable as String?,appColorScheme: freezed == appColorScheme ? _self.appColorScheme : appColorScheme // ignore: cast_nullable_to_non_nullable
as int?,windowSize: freezed == windowSize ? _self.windowSize : windowSize // ignore: cast_nullable_to_non_nullable as int?,customColors: freezed == customColors ? _self.customColors : customColors // ignore: cast_nullable_to_non_nullable
as Size?,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable as ThemeColors?,windowSize: freezed == windowSize ? _self.windowSize : windowSize // ignore: cast_nullable_to_non_nullable
as Size?,windowOpacity: null == windowOpacity ? _self.windowOpacity : windowOpacity // ignore: cast_nullable_to_non_nullable
as double,cardTransparency: null == cardTransparency ? _self.cardTransparency : cardTransparency // ignore: cast_nullable_to_non_nullable
as double,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable
as String?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable as String?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable
as String, as String,themeMode: freezed == themeMode ? _self.themeMode : themeMode // ignore: cast_nullable_to_non_nullable
as String?,useMaterial3: null == useMaterial3 ? _self.useMaterial3 : useMaterial3 // ignore: cast_nullable_to_non_nullable
as bool,
)); ));
} }
/// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ThemeColorsCopyWith<$Res>? get customColors {
if (_self.customColors == null) {
return null;
}
return $ThemeColorsCopyWith<$Res>(_self.customColors!, (value) {
return _then(_self.copyWith(customColors: value));
});
}
} }
// dart format on // dart format on

View File

@@ -2,12 +2,35 @@
part of 'config.dart'; part of 'config.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ThemeColors _$ThemeColorsFromJson(Map<String, dynamic> json) => _ThemeColors(
primary: (json['primary'] as num?)?.toInt(),
secondary: (json['secondary'] as num?)?.toInt(),
tertiary: (json['tertiary'] as num?)?.toInt(),
surface: (json['surface'] as num?)?.toInt(),
background: (json['background'] as num?)?.toInt(),
error: (json['error'] as num?)?.toInt(),
);
Map<String, dynamic> _$ThemeColorsToJson(_ThemeColors instance) =>
<String, dynamic>{
'primary': instance.primary,
'secondary': instance.secondary,
'tertiary': instance.tertiary,
'surface': instance.surface,
'background': instance.background,
'error': instance.error,
};
// ************************************************************************** // **************************************************************************
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$appSettingsNotifierHash() => String _$appSettingsNotifierHash() =>
r'9f0979f18b107e61185391e7c39bd81ac4b8ca50'; r'3ba2cdce76f3c4fed84f4108341c88a0a971bf3a';
/// See also [AppSettingsNotifier]. /// See also [AppSettingsNotifier].
@ProviderFor(AppSettingsNotifier) @ProviderFor(AppSettingsNotifier)

View File

@@ -6,23 +6,19 @@ import 'package:island/pods/network.dart';
final poolsProvider = FutureProvider<List<SnFilePool>>((ref) async { final poolsProvider = FutureProvider<List<SnFilePool>>((ref) async {
final dio = ref.watch(apiClientProvider); final dio = ref.watch(apiClientProvider);
final response = await dio.get('/drive/pools'); final response = await dio.get('/drive/pools');
final pools = SnFilePoolList.listFromResponse(response.data); return response.data
return pools.filterValid(); .map((e) => SnFilePool.fromJson(e))
.cast<SnFilePool>()
.toList();
}); });
String resolveDefaultPoolId(WidgetRef ref, List<SnFilePool> pools) { String? resolveDefaultPoolId(WidgetRef ref, List<SnFilePool> pools) {
final settings = ref.watch(appSettingsNotifierProvider); final settings = ref.watch(appSettingsNotifierProvider);
final validPools = pools.filterValid();
final configuredId = settings.defaultPoolId; final configuredId = settings.defaultPoolId;
if (configuredId != null && validPools.any((p) => p.id == configuredId)) { if (configuredId != null && pools.any((p) => p.id == configuredId)) {
return configuredId; return configuredId;
} }
if (validPools.isNotEmpty) { return pools.firstOrNull?.id;
return validPools.first.id; }
}
// DEFAULT: Solar Network Driver
return '500e5ed8-bd44-4359-bc0a-ec85e2adf447'; }

View File

@@ -2,10 +2,6 @@ import "dart:async";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
final flashingMessagesProvider = StateProvider<Set<String>>((ref) => {});
final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) { final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) {
final controller = StreamController<AppLifecycleState>(); final controller = StreamController<AppLifecycleState>();

View File

@@ -10,17 +10,19 @@ Future<void> resetDatabase(WidgetRef ref) async {
if (kIsWeb) return; if (kIsWeb) return;
final db = ref.read(databaseProvider); final db = ref.read(databaseProvider);
final basepath = await getApplicationSupportDirectory();
final file = File(join(basepath.path, 'solar_network_data.sqlite'));
// Close current database connection // Close current database connection
db.close(); await db.close();
// Delete database file // Get the correct database file path
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(join(dbFolder.path, 'solar_network_data.sqlite'));
// Delete database file if it exists
if (await file.exists()) { if (await file.exists()) {
await file.delete(); await file.delete();
} }
// Force refresh the database provider // Force refresh the database provider to create a new instance
ref.invalidate(databaseProvider); ref.invalidate(databaseProvider);
} }

View File

@@ -10,6 +10,8 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:island/models/auth.dart'; import 'package:island/models/auth.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:talker_dio_logger/talker_dio_logger.dart';
import 'package:island/talker.dart';
import 'config.dart'; import 'config.dart';
@@ -75,7 +77,7 @@ final apiClientProvider = Provider<Dio>((ref) {
), ),
); );
dio.interceptors.add( dio.interceptors.addAll([
InterceptorsWrapper( InterceptorsWrapper(
onRequest: ( onRequest: (
RequestOptions options, RequestOptions options,
@@ -97,7 +99,17 @@ final apiClientProvider = Provider<Dio>((ref) {
return handler.next(options); return handler.next(options);
}, },
), ),
); TalkerDioLogger(
talker: talker,
settings: const TalkerDioLoggerSettings(
printRequestHeaders: false,
printResponseHeaders: false,
printResponseMessage: false,
printRequestData: false,
printResponseData: false,
),
),
]);
return dio; return dio;
}); });

View File

@@ -1,38 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
final themeProvider = StateNotifierProvider<ThemeNotifier, ThemeSet?>((ref) { part 'theme.g.dart';
return ThemeNotifier();
});
class ThemeNotifier extends StateNotifier<ThemeSet?> { @riverpod
ThemeNotifier() : super(null) { ThemeSet theme(Ref ref) {
_loadTheme(); final settings = ref.watch(appSettingsNotifierProvider);
} return createAppThemeSet(settings);
Future<void> _loadTheme() async {
final theme = await createAppThemeSet();
state = theme;
}
void reloadTheme({
Color? seedColorOverride,
bool? useMaterial3,
String? customFonts,
}) async {
final theme = await createAppThemeSet(
seedColorOverride: seedColorOverride,
useMaterial3: useMaterial3,
customFonts: customFonts,
);
state = theme;
}
} }
const kMaterialYouToggleStoreKey = 'app_theme_material_you';
class ThemeSet { class ThemeSet {
ThemeData light; ThemeData light;
ThemeData dark; ThemeData dark;
@@ -40,52 +18,50 @@ class ThemeSet {
ThemeSet({required this.light, required this.dark}); ThemeSet({required this.light, required this.dark});
} }
Future<ThemeSet> createAppThemeSet({ ThemeSet createAppThemeSet(AppSettings settings) {
Color? seedColorOverride,
bool? useMaterial3,
String? customFonts,
}) async {
return ThemeSet( return ThemeSet(
light: await createAppTheme( light: createAppTheme(Brightness.light, settings),
Brightness.light, dark: createAppTheme(Brightness.dark, settings),
useMaterial3: useMaterial3,
customFonts: customFonts,
),
dark: await createAppTheme(
Brightness.dark,
useMaterial3: useMaterial3,
customFonts: customFonts,
),
); );
} }
Future<ThemeData> createAppTheme( ThemeData createAppTheme(Brightness brightness, AppSettings settings) {
Brightness brightness, {
Color? seedColorOverride,
bool? useMaterial3,
String? customFonts,
}) async {
final prefs = await SharedPreferences.getInstance();
final seedColorString = prefs.getInt(kAppColorSchemeStoreKey);
final seedColor = final seedColor =
seedColorString != null ? Color(seedColorString) : Colors.indigo; settings.appColorScheme != null
? Color(settings.appColorScheme!)
: Colors.indigo;
final colorScheme = ColorScheme.fromSeed( var colorScheme = ColorScheme.fromSeed(
seedColor: seedColorOverride ?? seedColor, seedColor: seedColor,
brightness: brightness, brightness: brightness,
); );
final hasAppBarTransparent = final customColors = settings.customColors;
prefs.getBool(kAppbarTransparentStoreKey) ?? false; if (customColors != null) {
final useM3 = colorScheme = colorScheme.copyWith(
useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true); primary:
customColors.primary != null ? Color(customColors.primary!) : null,
secondary:
customColors.secondary != null
? Color(customColors.secondary!)
: null,
tertiary:
customColors.tertiary != null ? Color(customColors.tertiary!) : null,
surface:
customColors.surface != null ? Color(customColors.surface!) : null,
background:
customColors.background != null
? Color(customColors.background!)
: null,
error: customColors.error != null ? Color(customColors.error!) : null,
);
}
final hasAppBarTransparent = settings.appBarTransparent;
final useM3 = settings.useMaterial3;
final inUseFonts = final inUseFonts =
(customFonts ?? prefs.getString(kAppCustomFonts)) settings.customFonts?.split(',').map((ele) => ele.trim()).toList() ??
?.split(',')
.map((ele) => ele.trim())
.toList() ??
['Nunito']; ['Nunito'];
return ThemeData( return ThemeData(
@@ -100,10 +76,6 @@ Future<ThemeData> createAppTheme(
opticalSize: 20, opticalSize: 20,
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
snackBarTheme: SnackBarThemeData(
behavior: useM3 ? SnackBarBehavior.floating : SnackBarBehavior.fixed,
width: 480,
),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
centerTitle: true, centerTitle: true,
elevation: hasAppBarTransparent ? 0 : null, elevation: hasAppBarTransparent ? 0 : null,
@@ -112,6 +84,11 @@ Future<ThemeData> createAppTheme(
foregroundColor: foregroundColor:
hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary, hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary,
), ),
cardTheme: CardThemeData(
color: colorScheme.surfaceContainer.withOpacity(
settings.cardTransparency,
),
),
pageTransitionsTheme: PageTransitionsTheme( pageTransitionsTheme: PageTransitionsTheme(
builders: { builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(), TargetPlatform.android: ZoomPageTransitionsBuilder(),

26
lib/pods/theme.g.dart Normal file
View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'theme.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$themeHash() => r'a12dbf8b83d75713b7ae4c68e9cdd1a1cc3a35f0';
/// See also [theme].
@ProviderFor(theme)
final themeProvider = AutoDisposeProvider<ThemeSet>.internal(
theme,
name: r'themeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$themeHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ThemeRef = AutoDisposeProviderRef<ThemeSet>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,5 +1,4 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@@ -12,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
final Ref _ref; final Ref _ref;
@@ -21,7 +21,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
Future<void> fetchUser() async { Future<void> fetchUser() async {
final token = _ref.watch(tokenProvider); final token = _ref.watch(tokenProvider);
if (token == null) { if (token == null) {
log('[UserInfo] No token found, not going to fetch...'); talker.info('[UserInfo] No token found, not going to fetch...');
return; return;
} }
try { try {
@@ -75,11 +75,10 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
} }
}); });
} }
log( talker.error(
"[UserInfo] Failed to fetch user info...", "[UserInfo] Failed to fetch user info...",
name: 'UserInfoNotifier', error,
error: error, stackTrace,
stackTrace: stackTrace,
); );
state = AsyncValue.data(null); state = AsyncValue.data(null);
} }

View File

@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
@@ -8,6 +7,7 @@ import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:island/talker.dart';
part 'websocket.freezed.dart'; part 'websocket.freezed.dart';
part 'websocket.g.dart'; part 'websocket.g.dart';
@@ -64,7 +64,7 @@ class WebSocketService {
final url = '$baseUrl/ws'.replaceFirst('http', 'ws'); final url = '$baseUrl/ws'.replaceFirst('http', 'ws');
log('[WebSocket] Trying connecting to $url'); talker.info('[WebSocket] Trying connecting to $url');
try { try {
if (kIsWeb) { if (kIsWeb) {
_channel = WebSocketChannel.connect(Uri.parse('$url?tk=$token')); _channel = WebSocketChannel.connect(Uri.parse('$url?tk=$token'));
@@ -88,24 +88,24 @@ class WebSocketService {
return; return;
} }
_streamController.sink.add(packet); _streamController.sink.add(packet);
log( talker.info(
"[WebSocket] Received packet: ${packet.type} ${packet.errorMessage}", "[WebSocket] Received packet: ${packet.type} ${packet.errorMessage}",
); );
if (packet.type == 'pong' && _heartbeatAt != null) { if (packet.type == 'pong' && _heartbeatAt != null) {
var now = DateTime.now(); var now = DateTime.now();
heartbeatDelay = now.difference(_heartbeatAt!); heartbeatDelay = now.difference(_heartbeatAt!);
log( talker.info(
"[WebSocket] Server respond last heartbeat for ${heartbeatDelay!.inMilliseconds} ms", "[WebSocket] Server respond last heartbeat for ${heartbeatDelay!.inMilliseconds} ms",
); );
} }
}, },
onDone: () { onDone: () {
log('[WebSocket] Connection closed, attempting to reconnect...'); talker.info('[WebSocket] Connection closed, attempting to reconnect...');
_scheduleReconnect(); _scheduleReconnect();
_statusStreamController.sink.add(WebSocketState.disconnected()); _statusStreamController.sink.add(WebSocketState.disconnected());
}, },
onError: (error) { onError: (error) {
log('[WebSocket] Error occurred: $error, attempting to reconnect...'); talker.error('[WebSocket] Error occurred: $error, attempting to reconnect...');
_scheduleReconnect(); _scheduleReconnect();
_statusStreamController.sink.add( _statusStreamController.sink.add(
WebSocketState.error(error.toString()), WebSocketState.error(error.toString()),
@@ -113,7 +113,7 @@ class WebSocketService {
}, },
); );
} catch (err) { } catch (err) {
log('[WebSocket] Failed to connect: $err'); talker.error('[WebSocket] Failed to connect: $err');
_scheduleReconnect(); _scheduleReconnect();
} }
} }
@@ -135,7 +135,7 @@ class WebSocketService {
void _beatTheHeart() { void _beatTheHeart() {
_heartbeatAt = DateTime.now(); _heartbeatAt = DateTime.now();
log('[WebSocket] We\'re beating the heart! $_heartbeatAt'); talker.info('[WebSocket] We\'re beating the heart! $_heartbeatAt');
sendMessage(jsonEncode(WebSocketPacket(type: 'ping', data: null))); sendMessage(jsonEncode(WebSocketPacket(type: 'ping', data: null)));
} }

View File

@@ -10,13 +10,11 @@ import 'package:island/screens/developers/app_detail.dart';
import 'package:island/screens/developers/bot_detail.dart'; import 'package:island/screens/developers/bot_detail.dart';
import 'package:island/screens/developers/edit_app.dart'; import 'package:island/screens/developers/edit_app.dart';
import 'package:island/screens/developers/edit_bot.dart'; import 'package:island/screens/developers/edit_bot.dart';
import 'package:island/screens/developers/new_app.dart';
import 'package:island/screens/developers/hub.dart'; import 'package:island/screens/developers/hub.dart';
import 'package:island/screens/developers/new_app.dart';
import 'package:island/screens/developers/new_bot.dart'; import 'package:island/screens/developers/new_bot.dart';
import 'package:island/screens/developers/projects.dart';
import 'package:island/screens/developers/edit_project.dart'; import 'package:island/screens/developers/edit_project.dart';
import 'package:island/screens/developers/new_project.dart'; import 'package:island/screens/developers/new_project.dart';
import 'package:island/screens/developers/project_detail.dart';
import 'package:island/screens/discovery/articles.dart'; import 'package:island/screens/discovery/articles.dart';
import 'package:island/screens/files/file_list.dart'; import 'package:island/screens/files/file_list.dart';
import 'package:island/screens/posts/post_categories_list.dart'; import 'package:island/screens/posts/post_categories_list.dart';
@@ -35,6 +33,7 @@ import 'package:island/screens/account/me/profile_update.dart';
import 'package:island/screens/account/leveling.dart'; import 'package:island/screens/account/leveling.dart';
import 'package:island/screens/account/me/account_settings.dart'; import 'package:island/screens/account/me/account_settings.dart';
import 'package:island/screens/chat/chat.dart'; import 'package:island/screens/chat/chat.dart';
import 'package:island/screens/chat/chat_form.dart';
import 'package:island/screens/chat/room.dart'; import 'package:island/screens/chat/room.dart';
import 'package:island/screens/chat/room_detail.dart'; import 'package:island/screens/chat/room_detail.dart';
import 'package:island/screens/chat/call.dart'; import 'package:island/screens/chat/call.dart';
@@ -42,15 +41,13 @@ import 'package:island/screens/chat/search_messages.dart';
import 'package:island/screens/creators/hub.dart'; import 'package:island/screens/creators/hub.dart';
import 'package:island/screens/creators/posts/post_manage_list.dart'; import 'package:island/screens/creators/posts/post_manage_list.dart';
import 'package:island/screens/creators/stickers/stickers.dart'; import 'package:island/screens/creators/stickers/stickers.dart';
import 'package:island/screens/creators/stickers/pack_detail.dart';
import 'package:island/screens/stickers/sticker_marketplace.dart'; import 'package:island/screens/stickers/sticker_marketplace.dart';
import 'package:island/screens/stickers/pack_detail.dart'; import 'package:island/screens/stickers/pack_detail.dart';
import 'package:island/screens/discovery/feeds/feed_marketplace.dart'; import 'package:island/screens/discovery/feeds/feed_marketplace.dart';
import 'package:island/screens/discovery/feeds/feed_detail.dart'; import 'package:island/screens/discovery/feeds/feed_detail.dart';
import 'package:island/screens/creators/poll/poll_list.dart'; import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/screens/creators/publishers.dart'; import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/creators/webfeed/webfeed_list.dart'; import 'package:island/screens/creators/webfeed/webfeed_list.dart';
import 'package:island/screens/creators/webfeed/webfeed_edit.dart';
import 'package:island/screens/poll/poll_editor.dart'; import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/post_detail.dart'; import 'package:island/screens/posts/post_detail.dart';
@@ -59,12 +56,15 @@ import 'package:island/screens/auth/login.dart';
import 'package:island/screens/auth/create_account.dart'; import 'package:island/screens/auth/create_account.dart';
import 'package:island/screens/settings.dart'; import 'package:island/screens/settings.dart';
import 'package:island/screens/realm/realms.dart'; import 'package:island/screens/realm/realms.dart';
import 'package:island/screens/realm/realm_form.dart';
import 'package:island/screens/realm/realm_detail.dart'; import 'package:island/screens/realm/realm_detail.dart';
import 'package:island/screens/account/event_calendar.dart'; import 'package:island/screens/account/event_calendar.dart';
import 'package:island/screens/discovery/realms.dart'; import 'package:island/screens/discovery/realms.dart';
import 'package:island/screens/reports/report_detail.dart'; import 'package:island/screens/reports/report_detail.dart';
import 'package:island/screens/reports/report_list.dart'; import 'package:island/screens/reports/report_list.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/post/post_shuffle.dart'; import 'package:island/widgets/post/post_shuffle.dart';
import 'package:talker_flutter/talker_flutter.dart';
// Shell route keys for nested navigation // Shell route keys for nested navigation
final rootNavigatorKey = GlobalKey<NavigatorState>(); final rootNavigatorKey = GlobalKey<NavigatorState>();
@@ -96,6 +96,7 @@ final routerProvider = Provider<GoRouter>((ref) {
observers: [ observers: [
if (_supportsAnalytics) if (_supportsAnalytics)
FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance), FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance),
TalkerRouteObserver(talker),
], ],
routes: [ routes: [
ShellRoute( ShellRoute(
@@ -132,6 +133,11 @@ final routerProvider = Provider<GoRouter>((ref) {
return CallScreen(roomId: id); return CallScreen(roomId: id);
}, },
), ),
GoRoute(
name: 'logs',
path: '/logs',
builder: (context, state) => TalkerScreen(talker: talker),
),
GoRoute( GoRoute(
name: 'accountCalendar', name: 'accountCalendar',
path: '/account/:name/calendar', path: '/account/:name/calendar',
@@ -140,48 +146,23 @@ final routerProvider = Provider<GoRouter>((ref) {
return EventCalanderScreen(name: name); return EventCalanderScreen(name: name);
}, },
), ),
ShellRoute(
builder:
(context, state, child) => CreatorHubShellScreen(child: child),
routes: [
GoRoute( GoRoute(
name: 'creatorHub', name: 'creatorHub',
path: '/creators', path: '/creators',
builder: (context, state) => const CreatorHubScreen(), builder: (context, state) => const CreatorHubScreen(),
), routes: [
// Web Feed Routes // Web Feed Routes
GoRoute( GoRoute(
name: 'creatorFeeds', name: 'creatorFeeds',
path: '/creators/:name/feeds', path: ':name/feeds',
builder: (context, state) { builder: (context, state) {
final name = state.pathParameters['name']!; final name = state.pathParameters['name']!;
return WebFeedListScreen(pubName: name); return WebFeedListScreen(pubName: name);
}, },
routes: [
GoRoute(
name: 'creatorFeedNew',
path: 'new',
builder: (context, state) {
return WebFeedNewScreen(
pubName: state.pathParameters['name']!,
);
},
),
GoRoute(
name: 'creatorFeedEdit',
path: ':feedId',
builder: (context, state) {
return WebFeedEditScreen(
pubName: state.pathParameters['name']!,
feedId: state.pathParameters['feedId'],
);
},
),
],
), ),
GoRoute( GoRoute(
name: 'creatorPosts', name: 'creatorPosts',
path: '/creators/:name/posts', path: ':name/posts',
builder: (context, state) { builder: (context, state) {
final name = state.pathParameters['name']!; final name = state.pathParameters['name']!;
return CreatorPostListScreen(pubName: name); return CreatorPostListScreen(pubName: name);
@@ -190,7 +171,7 @@ final routerProvider = Provider<GoRouter>((ref) {
// Poll list route // Poll list route
GoRoute( GoRoute(
name: 'creatorPolls', name: 'creatorPolls',
path: '/creators/:name/polls', path: ':name/polls',
builder: (context, state) { builder: (context, state) {
final name = state.pathParameters['name']!; final name = state.pathParameters['name']!;
return CreatorPollListScreen(pubName: name); return CreatorPollListScreen(pubName: name);
@@ -199,7 +180,7 @@ final routerProvider = Provider<GoRouter>((ref) {
// Poll routes // Poll routes
GoRoute( GoRoute(
name: 'creatorPollNew', name: 'creatorPollNew',
path: '/creators/:name/polls/new', path: ':name/polls/new',
builder: (context, state) { builder: (context, state) {
final name = state.pathParameters['name']!; final name = state.pathParameters['name']!;
// initialPollId left null for create; initialPublisher prefilled // initialPollId left null for create; initialPublisher prefilled
@@ -208,7 +189,7 @@ final routerProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
name: 'creatorPollEdit', name: 'creatorPollEdit',
path: '/creators/:name/polls/:id/edit', path: ':name/polls/:id/edit',
builder: (context, state) { builder: (context, state) {
final name = state.pathParameters['name']!; final name = state.pathParameters['name']!;
final id = state.pathParameters['id']!; final id = state.pathParameters['id']!;
@@ -220,63 +201,20 @@ final routerProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
name: 'creatorStickers', name: 'creatorStickers',
path: '/creators/:name/stickers', path: ':name/stickers',
builder: (context, state) { builder: (context, state) {
final name = state.pathParameters['name']!; final name = state.pathParameters['name']!;
return StickersScreen(pubName: name); return StickersScreen(pubName: name);
}, },
), ),
GoRoute(
name: 'creatorStickerPackNew',
path: '/creators/:name/stickers/new',
builder: (context, state) {
final name = state.pathParameters['name']!;
return NewStickerPacksScreen(pubName: name);
},
),
GoRoute(
name: 'creatorStickerPackEdit',
path: '/creators/:name/stickers/:packId/edit',
builder: (context, state) {
final name = state.pathParameters['name']!;
final packId = state.pathParameters['packId']!;
return EditStickerPacksScreen(pubName: name, packId: packId);
},
),
GoRoute(
name: 'creatorStickerPackDetail',
path: '/creators/:name/stickers/:packId',
builder: (context, state) {
final name = state.pathParameters['name']!;
final packId = state.pathParameters['packId']!;
return StickerPackDetailScreen(pubName: name, id: packId);
},
),
GoRoute(
name: 'creatorStickerNew',
path: '/creators/:name/stickers/:packId/new',
builder: (context, state) {
final packId = state.pathParameters['packId']!;
return NewStickersScreen(packId: packId);
},
),
GoRoute(
name: 'creatorStickerEdit',
path: '/creators/:name/stickers/:packId/:id/edit',
builder: (context, state) {
final packId = state.pathParameters['packId']!;
final id = state.pathParameters['id']!;
return EditStickersScreen(id: id, packId: packId);
},
),
GoRoute( GoRoute(
name: 'creatorNew', name: 'creatorNew',
path: '/creators/new', path: 'new',
builder: (context, state) => const NewPublisherScreen(), builder: (context, state) => const NewPublisherScreen(),
), ),
GoRoute( GoRoute(
name: 'creatorEdit', name: 'creatorEdit',
path: '/creators/:name/edit', path: ':name/edit',
builder: (context, state) { builder: (context, state) {
final name = state.pathParameters['name']!; final name = state.pathParameters['name']!;
return EditPublisherScreen(name: name); return EditPublisherScreen(name: name);
@@ -284,27 +222,18 @@ final routerProvider = Provider<GoRouter>((ref) {
), ),
], ],
), ),
ShellRoute(
builder:
(context, state, child) =>
DeveloperHubShellScreen(child: child),
routes: [
GoRoute( GoRoute(
name: 'developerHub', name: 'developerHub',
path: '/developers', path: '/developers',
builder: (context, state) => const DeveloperHubScreen(),
),
GoRoute(
name: 'developerProjects',
path: '/developers/:name/projects',
builder: builder:
(context, state) => DevProjectsScreen( (context, state) => DeveloperHubScreen(
publisherName: state.pathParameters['name']!, initialPublisherName: state.uri.queryParameters['publisher'],
), initialProjectId: state.uri.queryParameters['project'],
), ),
routes: [
GoRoute( GoRoute(
name: 'developerProjectNew', name: 'developerProjectNew',
path: '/developers/:name/projects/new', path: ':name/projects/new',
builder: builder:
(context, state) => NewProjectScreen( (context, state) => NewProjectScreen(
publisherName: state.pathParameters['name']!, publisherName: state.pathParameters['name']!,
@@ -312,7 +241,7 @@ final routerProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
name: 'developerProjectEdit', name: 'developerProjectEdit',
path: '/developers/:name/projects/:id/edit', path: ':name/projects/:id/edit',
builder: builder:
(context, state) => EditProjectScreen( (context, state) => EditProjectScreen(
publisherName: state.pathParameters['name']!, publisherName: state.pathParameters['name']!,
@@ -321,12 +250,18 @@ final routerProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
name: 'developerProjectDetail', name: 'developerProjectDetail',
path: '/developers/:name/projects/:projectId', path: ':name/projects/:projectId',
builder: builder: (context, state) {
(context, state) => ProjectDetailScreen( final name = state.pathParameters['name']!;
publisherName: state.pathParameters['name']!, final projectId = state.pathParameters['projectId']!;
projectId: state.pathParameters['projectId']!, // Redirect to hub with project selected
), WidgetsBinding.instance.addPostFrameCallback((_) {
context.go(
'/developers?publisher=$name&project=$projectId',
);
});
return const SizedBox.shrink(); // Temporary placeholder
},
routes: [ routes: [
GoRoute( GoRoute(
name: 'developerAppNew', name: 'developerAppNew',
@@ -357,6 +292,15 @@ final routerProvider = Provider<GoRouter>((ref) {
appId: state.pathParameters['appId']!, appId: state.pathParameters['appId']!,
), ),
), ),
GoRoute(
name: 'developerBotNew',
path: 'bots/new',
builder:
(context, state) => NewBotScreen(
publisherName: state.pathParameters['name']!,
projectId: state.pathParameters['projectId']!,
),
),
GoRoute( GoRoute(
name: 'developerBotDetail', name: 'developerBotDetail',
path: 'bots/:botId', path: 'bots/:botId',
@@ -367,15 +311,6 @@ final routerProvider = Provider<GoRouter>((ref) {
botId: state.pathParameters['botId']!, botId: state.pathParameters['botId']!,
), ),
), ),
GoRoute(
name: 'developerBotNew',
path: 'bots/new',
builder:
(context, state) => NewBotScreen(
publisherName: state.pathParameters['name']!,
projectId: state.pathParameters['projectId']!,
),
),
GoRoute( GoRoute(
name: 'developerBotEdit', name: 'developerBotEdit',
path: 'bots/:id/edit', path: 'bots/:id/edit',

View File

@@ -211,21 +211,11 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
icon: Symbols.system_update, icon: Symbols.system_update,
title: 'Check for updates', title: 'Check for updates',
onTap: () async { onTap: () async {
// Fetch latest release and show the unified sheet
final svc = UpdateService(); final svc = UpdateService();
// Reuse service fetch + compare to decide content
showLoadingModal(context); showLoadingModal(context);
final release = await svc.fetchLatestRelease(); svc.checkForUpdates(context);
if (!context.mounted) return; if (!context.mounted) return;
hideLoadingModal(context); hideLoadingModal(context);
if (release != null) {
await svc.showUpdateSheet(context, release);
} else {
showInfoAlert(
'Currently cannot get update from the GitHub.',
'Unable to check for updates',
);
}
}, },
), ),
_buildListTile( _buildListTile(

View File

@@ -66,7 +66,6 @@ class AccountScreen extends HookConsumerWidget {
isNoBackground: isWide, isNoBackground: isWide,
appBar: AppBar(backgroundColor: Colors.transparent, toolbarHeight: 0), appBar: AppBar(backgroundColor: Colors.transparent, toolbarHeight: 0),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: getTabbedPadding(context),
child: Column( child: Column(
spacing: 4, spacing: 4,
children: <Widget>[ children: <Widget>[
@@ -176,7 +175,7 @@ class AccountScreen extends HookConsumerWidget {
], ],
).padding(horizontal: 16, vertical: 12), ).padding(horizontal: 16, vertical: 12),
onTap: () { onTap: () {
context.pushNamed('creatorHub'); context.goNamed('creatorHub');
}, },
), ),
).height(140), ).height(140),

View File

@@ -1,42 +1,21 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
import 'package:island/models/wallet.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/account/credits.dart'; import 'package:island/screens/account/credits.dart';
import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart'; import 'package:island/services/time.dart';
import 'package:island/widgets/account/leveling_progress.dart'; import 'package:island/widgets/account/leveling_progress.dart';
import 'package:island/widgets/account/restore_purchase_sheet.dart'; import 'package:island/widgets/account/stellar_program_tab.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/payment/payment_overlay.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
part 'leveling.g.dart'; part 'leveling.g.dart';
@riverpod
Future<SnWalletSubscription?> accountStellarSubscription(Ref ref) async {
try {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/id/subscriptions/fuzzy/solian.stellar');
return SnWalletSubscription.fromJson(resp.data);
} catch (err) {
if (err is DioException && err.response?.statusCode == 404) return null;
rethrow;
}
}
@riverpod @riverpod
class LevelingHistoryNotifier extends _$LevelingHistoryNotifier class LevelingHistoryNotifier extends _$LevelingHistoryNotifier
with CursorPagingNotifierMixin<SnExperienceRecord> { with CursorPagingNotifierMixin<SnExperienceRecord> {
@@ -129,7 +108,7 @@ class LevelingScreen extends HookConsumerWidget {
children: [ children: [
_buildLevelingTab(context, ref, user.value!), _buildLevelingTab(context, ref, user.value!),
const SocialCreditsTab(), const SocialCreditsTab(),
_buildStellarProgramTab(context, ref), const StellarProgramTab(),
], ],
), ),
), ),
@@ -148,7 +127,6 @@ class LevelingScreen extends HookConsumerWidget {
return Center( return Center(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
constraints: const BoxConstraints(maxWidth: 480),
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
const SliverGap(20), const SliverGap(20),
@@ -180,6 +158,12 @@ class LevelingScreen extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text(
'${'levelingProgressLevel'.tr(args: [currentLevel.toString()])} / 120',
textAlign: TextAlign.start,
style: Theme.of(context).textTheme.bodySmall,
),
const Gap(8),
LinearProgressIndicator( LinearProgressIndicator(
value: currentLevel / 120, value: currentLevel / 120,
minHeight: 10, minHeight: 10,
@@ -190,12 +174,6 @@ class LevelingScreen extends HookConsumerWidget {
Theme.of(context).colorScheme.surfaceContainerHigh, Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
), ),
const Gap(8),
Text(
'${'levelingProgressLevel'.tr(args: [currentLevel.toString()])} / 120',
textAlign: TextAlign.right,
style: Theme.of(context).textTheme.bodySmall,
),
], ],
).padding(horizontal: 16, top: 16, bottom: 12), ).padding(horizontal: 16, top: 16, bottom: 12),
), ),
@@ -260,462 +238,12 @@ class LevelingScreen extends HookConsumerWidget {
), ),
), ),
SliverGap(getTabbedPadding(context, vertical: 20).vertical), SliverGap(20),
], ],
), ),
), ),
); );
} }
Widget _buildStellarProgramTab(BuildContext context, WidgetRef ref) {
final stellarSubscription = ref.watch(accountStellarSubscriptionProvider);
return SingleChildScrollView(
padding: getTabbedPadding(context, horizontal: 20, vertical: 20),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildMembershipSection(context, ref, stellarSubscription),
const Gap(16),
],
),
),
),
);
}
Widget _buildMembershipSection(
BuildContext context,
WidgetRef ref,
AsyncValue<SnWalletSubscription?> stellarSubscriptionAsync,
) {
return stellarSubscriptionAsync.when(
data: (membership) => _buildMembershipContent(context, ref, membership),
loading:
() => Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primaryContainer,
Theme.of(context).colorScheme.secondaryContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: const Center(child: CircularProgressIndicator()),
),
error:
(error, stack) => Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primaryContainer,
Theme.of(context).colorScheme.secondaryContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Text('Error loading membership: $error'),
),
);
}
Widget _buildMembershipContent(
BuildContext context,
WidgetRef ref,
SnWalletSubscription? membership,
) {
final isActive = membership?.isActive ?? false;
Future<void> membershipCancel() async {
if (!isActive || membership == null) return;
final confirm = await showConfirmAlert(
'membershipCancelHint'.tr(),
'membershipCancelConfirm'.tr(),
);
if (!confirm || !context.mounted) return;
try {
showLoadingModal(context);
final client = ref.watch(apiClientProvider);
await client.post('/id/subscriptions/${membership.identifier}/cancel');
ref.invalidate(accountStellarSubscriptionProvider);
ref.read(userInfoProvider.notifier).fetchUser();
if (context.mounted) {
hideLoadingModal(context);
showSnackBar('membershipCancelSuccess'.tr());
}
} catch (err) {
if (context.mounted) hideLoadingModal(context);
showErrorAlert(err);
}
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primaryContainer,
Theme.of(context).colorScheme.secondaryContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
isActive ? Icons.star : Icons.star_border,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
const Gap(8),
Text(
'stellarMembership'.tr(),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Gap(12),
if (isActive) ...[
_buildCurrentMembershipCard(context, membership!),
const Gap(12),
FilledButton.icon(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.error,
),
foregroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.onError,
),
),
onPressed: membershipCancel,
icon: const Icon(Symbols.cancel),
label: Text('membershipCancel'.tr()),
),
],
if (!isActive) ...[
Text(
'chooseYourPlan'.tr(),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Gap(12),
_buildMembershipTiers(context, ref, membership),
],
// Restore Purchase Button
// As you know Apple platform need IAP
if (kIsWeb || !(Platform.isIOS || Platform.isMacOS))
OutlinedButton.icon(
onPressed: () => _showRestorePurchaseSheet(context, ref),
icon: const Icon(Icons.restore),
label: Text('restorePurchase'.tr()),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
).padding(top: 12),
],
),
);
}
Widget _buildCurrentMembershipCard(
BuildContext context,
SnWalletSubscription membership,
) {
final tierName = _getMembershipTierName(membership.identifier);
final tierColor = _getMembershipTierColor(context, membership.identifier);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: tierColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: tierColor, width: 1),
),
child: Row(
children: [
Icon(Icons.verified, color: tierColor, size: 20),
const Gap(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'currentMembership'.tr(args: [tierName]),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: tierColor,
),
),
if (membership.endedAt != null)
Text(
'membershipExpires'.tr(
args: [membership.endedAt!.formatSystem()],
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
}
Widget _buildMembershipTiers(
BuildContext context,
WidgetRef ref,
SnWalletSubscription? currentMembership,
) {
final tiers = [
{
'id': 'solian.stellar.primary',
'name': 'membershipTierStellar'.tr(),
'price': 'membershipPriceStellar'.tr(),
'color': Colors.blue,
},
{
'id': 'solian.stellar.nova',
'name': 'membershipTierNova'.tr(),
'price': 'membershipPriceNova'.tr(),
'color': Color.fromRGBO(57, 197, 187, 1),
},
{
'id': 'solian.stellar.supernova',
'name': 'membershipTierSupernova'.tr(),
'price': 'membershipPriceSupernova'.tr(),
'color': Colors.orange,
},
];
return Column(
children:
tiers.map((tier) {
final isCurrentTier = currentMembership?.identifier == tier['id'];
final tierColor = tier['color'] as Color;
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap:
isCurrentTier
? null
: () => _purchaseMembership(
context,
ref,
tier['id'] as String,
),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color:
isCurrentTier
? tierColor.withOpacity(0.1)
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color:
isCurrentTier
? tierColor
: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
width: isCurrentTier ? 2 : 1,
),
),
child: Row(
children: [
Container(
width: 4,
height: 40,
decoration: BoxDecoration(
color: tierColor,
borderRadius: BorderRadius.circular(2),
),
),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
tier['name'] as String,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: isCurrentTier ? tierColor : null,
),
),
const Gap(8),
if (isCurrentTier)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: tierColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'membershipCurrentBadge'.tr(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
Text(
tier['price'] as String,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
if (!isCurrentTier)
Icon(
Icons.arrow_forward_ios,
size: 16,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
),
);
}).toList(),
);
}
String _getMembershipTierName(String identifier) {
switch (identifier) {
case 'solian.stellar.primary':
return 'membershipTierStellar'.tr();
case 'solian.stellar.nova':
return 'membershipTierNova'.tr();
case 'solian.stellar.supernova':
return 'membershipTierSupernova'.tr();
default:
return 'membershipTierUnknown'.tr();
}
}
Color _getMembershipTierColor(BuildContext context, String identifier) {
switch (identifier) {
case 'solian.stellar.primary':
return Colors.blue;
case 'solian.stellar.nova':
return Colors.purple;
case 'solian.stellar.supernova':
return Colors.orange;
default:
return Theme.of(context).colorScheme.primary;
}
}
Future<void> _showRestorePurchaseSheet(
BuildContext context,
WidgetRef ref,
) async {
await showModalBottomSheet(
context: context,
builder: (context) => const RestorePurchaseSheet(),
);
}
Future<void> _purchaseMembership(
BuildContext context,
WidgetRef ref,
String tierId,
) async {
final client = ref.watch(apiClientProvider);
try {
showLoadingModal(context);
final resp = await client.post(
'/id/subscriptions',
data: {
'identifier': tierId,
'payment_method': 'solian.wallet',
'payment_details': {'currency': 'golds'},
'cycle_duration_days': 30,
},
options: Options(headers: {'X-Noop': true}),
);
final subscription = SnWalletSubscription.fromJson(resp.data);
if (subscription.status == 1) return;
final orderResp = await client.post(
'/id/subscriptions/${subscription.identifier}/order',
);
final order = SnWalletOrder.fromJson(orderResp.data);
if (context.mounted) hideLoadingModal(context);
// Show payment overlay to complete the payment
if (!context.mounted) return;
final paidOrder = await PaymentOverlay.show(
context: context,
order: order,
enableBiometric: true,
);
if (context.mounted) showLoadingModal(context);
if (paidOrder != null) {
// Wait for server to handle order
await Future.delayed(const Duration(seconds: 1));
ref.invalidate(accountStellarSubscriptionProvider);
ref.read(userInfoProvider.notifier).fetchUser();
if (context.mounted) {
showSnackBar('membershipPurchaseSuccess'.tr());
}
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
} }
class LevelStairsPainter extends CustomPainter { class LevelStairsPainter extends CustomPainter {

View File

@@ -6,27 +6,6 @@ part of 'leveling.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$accountStellarSubscriptionHash() =>
r'80abcdefb3868775fd8fe3c980215713efff5948';
/// See also [accountStellarSubscription].
@ProviderFor(accountStellarSubscription)
final accountStellarSubscriptionProvider =
AutoDisposeFutureProvider<SnWalletSubscription?>.internal(
accountStellarSubscription,
name: r'accountStellarSubscriptionProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$accountStellarSubscriptionHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AccountStellarSubscriptionRef =
AutoDisposeFutureProviderRef<SnWalletSubscription?>;
String _$levelingHistoryNotifierHash() => String _$levelingHistoryNotifierHash() =>
r'e795f9b7911c9e50f15c095ea237cb0e87bf1e89'; r'e795f9b7911c9e50f15c095ea237cb0e87bf1e89';

View File

@@ -9,10 +9,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/services/timezone.dart'; import 'package:island/services/timezone.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@@ -62,19 +62,13 @@ class UpdateProfileScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
fileData: UniversalFile( fileData: UniversalFile(
data: result, data: result,
type: UniversalFileType.image, type: UniversalFileType.image,
), ),
atk: token,
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future; ).future;
if (cloudFile == null) { if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...'); throw ArgumentError('Failed to upload the file...');

View File

@@ -168,7 +168,7 @@ class AccountConnectionNewSheet extends HookConsumerWidget {
scopes: [AppleIDAuthorizationScopes.email], scopes: [AppleIDAuthorizationScopes.email],
webAuthenticationOptions: WebAuthenticationOptions( webAuthenticationOptions: WebAuthenticationOptions(
clientId: 'dev.solsynth.solarpass', clientId: 'dev.solsynth.solarpass',
redirectUri: Uri.parse('https://id.solian.app/auth/callback'), redirectUri: Uri.parse('https://solian.app/auth/callback'),
), ),
); );

View File

@@ -98,7 +98,7 @@ class _AccountBasicInfo extends StatelessWidget {
onPressed: () { onPressed: () {
SharePlus.instance.share( SharePlus.instance.share(
ShareParams( ShareParams(
uri: Uri.parse('https://id.solian.app/@${data.name}'), uri: Uri.parse('https://solian.app/@${data.name}'),
), ),
); );
}, },

View File

@@ -1,10 +1,12 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/network.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'captcha.config.g.dart'; part 'captcha.config.g.dart';
@riverpod @riverpod
Future<String> captchaUrl(Ref ref) async { Future<String> captchaUrl(Ref ref) async {
const baseUrl = "https://solian.app"; final apiClient = ref.watch(apiClientProvider);
final baseUrl = await apiClient.get('/config/site');
return '$baseUrl/auth/captcha'; return '$baseUrl/auth/captcha';
} }

View File

@@ -6,7 +6,7 @@ part of 'captcha.config.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$captchaUrlHash() => r'd46bc43032cef504547cd528a40c23cf76f27cc8'; String _$captchaUrlHash() => r'5d59de4f26a0544bf4fbd5209943f0b111959ce6';
/// See also [captchaUrl]. /// See also [captchaUrl].
@ProviderFor(captchaUrl) @ProviderFor(captchaUrl)

View File

@@ -1,11 +1,10 @@
import 'dart:developer';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart' hide ConnectionState; import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/call.dart'; import 'package:island/pods/chat/call.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/call_button.dart'; import 'package:island/widgets/chat/call_button.dart';
import 'package:island/widgets/chat/call_overlay.dart'; import 'package:island/widgets/chat/call_overlay.dart';
@@ -26,14 +25,14 @@ class CallScreen extends HookConsumerWidget {
final callNotifier = ref.watch(callNotifierProvider.notifier); final callNotifier = ref.watch(callNotifierProvider.notifier);
useEffect(() { useEffect(() {
log('[Call] Joining the call...'); talker.info('[Call] Joining the call...');
callNotifier.joinRoom(roomId).catchError((_) { callNotifier.joinRoom(roomId).catchError((_) {
showConfirmAlert( showConfirmAlert(
'Seems there already has a call connected, do you want override it?', 'Seems there already has a call connected, do you want override it?',
'Call already connected', 'Call already connected',
).then((value) { ).then((value) {
if (value != true) return; if (value != true) return;
log('[Call] Joining the call... with overrides'); talker.info('[Call] Joining the call... with overrides');
callNotifier.disconnect(); callNotifier.disconnect();
callNotifier.dispose(); callNotifier.dispose();
callNotifier.joinRoom(roomId); callNotifier.joinRoom(roomId);

View File

@@ -1,5 +1,3 @@
import 'package:collection/collection.dart';
import 'package:croppy/croppy.dart' hide cropImage;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -7,25 +5,18 @@ import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/models/file.dart'; import 'package:island/pods/chat/call.dart';
import 'package:island/models/realm.dart'; import 'package:island/pods/chat/chat_summary.dart';
import 'package:island/pods/call.dart';
import 'package:island/pods/chat_summary.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/screens/realm/realms.dart'; import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_picker.dart'; import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/realm/realm_selection_dropdown.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:island/screens/tabs.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -178,6 +169,101 @@ Future<List<SnChatRoom>> chatroomsJoined(Ref ref) async {
.toList(); .toList();
} }
class ChatListBodyWidget extends HookConsumerWidget {
final bool isFloating;
final TabController tabController;
final ValueNotifier<int> selectedTab;
const ChatListBodyWidget({
super.key,
this.isFloating = false,
required this.tabController,
required this.selectedTab,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final chats = ref.watch(chatroomsJoinedProvider);
final callState = ref.watch(callNotifierProvider);
Widget bodyWidget = Column(
children: [
Consumer(
builder: (context, ref, _) {
final summaryState = ref.watch(chatSummaryProvider);
return summaryState.maybeWhen(
loading:
() => const LinearProgressIndicator(
minHeight: 2,
borderRadius: BorderRadius.zero,
),
orElse: () => const SizedBox.shrink(),
);
},
),
Expanded(
child: chats.when(
data:
(items) => RefreshIndicator(
onRefresh:
() => Future.sync(() {
ref.invalidate(chatroomsJoinedProvider);
}),
child: ListView.builder(
padding: EdgeInsets.only(
bottom: callState.isConnected ? 96 : 0,
),
itemCount:
items
.where(
(item) =>
selectedTab.value == 0 ||
(selectedTab.value == 1 && item.type == 1) ||
(selectedTab.value == 2 && item.type != 1),
)
.length,
itemBuilder: (context, index) {
final filteredItems =
items
.where(
(item) =>
selectedTab.value == 0 ||
(selectedTab.value == 1 &&
item.type == 1) ||
(selectedTab.value == 2 && item.type != 1),
)
.toList();
final item = filteredItems[index];
return ChatRoomListTile(
room: item,
isDirect: item.type == 1,
onTap: () {
context.pushNamed(
'chatRoom',
pathParameters: {'id': item.id},
);
},
);
},
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, stack) => ResponseErrorWidget(
error: error,
onRetry: () {
ref.invalidate(chatroomsJoinedProvider);
},
),
),
),
],
);
return isFloating ? Card(child: bodyWidget) : bodyWidget;
}
}
class ChatShellScreen extends HookConsumerWidget { class ChatShellScreen extends HookConsumerWidget {
final Widget child; final Widget child;
const ChatShellScreen({super.key, required this.child}); const ChatShellScreen({super.key, required this.child});
@@ -191,9 +277,23 @@ class ChatShellScreen extends HookConsumerWidget {
isRoot: true, isRoot: true,
child: Row( child: Row(
children: [ children: [
Flexible(flex: 2, child: ChatListScreen(isAside: true)), Flexible(
const VerticalDivider(width: 1), flex: 2,
Flexible(flex: 4, child: child), child: ChatListScreen(
isAside: true,
isFloating: true,
).padding(left: 16, vertical: 16),
),
const Gap(8),
Flexible(
flex: 4,
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
),
child: child,
).padding(top: 16),
),
], ],
), ),
); );
@@ -205,24 +305,23 @@ class ChatShellScreen extends HookConsumerWidget {
class ChatListScreen extends HookConsumerWidget { class ChatListScreen extends HookConsumerWidget {
final bool isAside; final bool isAside;
const ChatListScreen({super.key, this.isAside = false}); final bool isFloating;
const ChatListScreen({
super.key,
this.isAside = false,
this.isFloating = false,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context); final isWide = isWideScreen(context);
if (isWide && !isAside) {
return const EmptyPageHolder();
}
final chats = ref.watch(chatroomsJoinedProvider);
final chatInvites = ref.watch(chatroomInvitesProvider); final chatInvites = ref.watch(chatroomInvitesProvider);
final tabController = useTabController(initialLength: 3); final tabController = useTabController(initialLength: 3);
final selectedTab = useState( final selectedTab = useState(
0, 0,
); // 0 for All, 1 for Direct Messages, 2 for Group Chats ); // 0 for All, 1 for Direct Messages, 2 for Group Chats
final callState = ref.watch(callNotifierProvider);
useEffect(() { useEffect(() {
tabController.addListener(() { tabController.addListener(() {
selectedTab.value = tabController.index; selectedTab.value = tabController.index;
@@ -250,6 +349,76 @@ class ChatListScreen extends HookConsumerWidget {
} }
} }
if (isAside) {
return Card(
margin: EdgeInsets.zero,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Column(
children: [
Row(
children: [
Expanded(
child: TabBar(
dividerColor: Colors.transparent,
controller: tabController,
tabAlignment: TabAlignment.start,
isScrollable: true,
tabs: [
const Tab(icon: Icon(Symbols.chat)),
const Tab(icon: Icon(Symbols.person)),
const Tab(icon: Icon(Symbols.group)),
],
),
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: Badge(
label: Text(
chatInvites.when(
data: (invites) => invites.length.toString(),
error: (_, _) => '0',
loading: () => '0',
),
),
isLabelVisible: chatInvites.when(
data: (invites) => invites.isNotEmpty,
error: (_, _) => false,
loading: () => false,
),
child: const Icon(Symbols.email),
),
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => const _ChatInvitesSheet(),
);
},
),
),
],
).padding(horizontal: 8),
const Divider(height: 1),
Expanded(
child: ChatListBodyWidget(
isFloating: false,
tabController: tabController,
selectedTab: selectedTab,
),
),
],
),
),
);
}
if (isWide && !isAside) {
return const EmptyPageHolder();
}
return AppScaffold( return AppScaffold(
extendBody: false, // Prevent conflicts with tabs navigation extendBody: false, // Prevent conflicts with tabs navigation
appBar: AppBar( appBar: AppBar(
@@ -352,82 +521,10 @@ class ChatListScreen extends HookConsumerWidget {
}, },
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
), ),
floatingActionButtonLocation: TabbedFabLocation(context), body: ChatListBodyWidget(
body: Column( isFloating: false,
children: [ tabController: tabController,
Consumer( selectedTab: selectedTab,
builder: (context, ref, _) {
final summaryState = ref.watch(chatSummaryProvider);
return summaryState.maybeWhen(
loading:
() => const LinearProgressIndicator(
minHeight: 2,
borderRadius: BorderRadius.zero,
),
orElse: () => const SizedBox.shrink(),
);
},
),
Expanded(
child: chats.when(
data:
(items) => RefreshIndicator(
onRefresh:
() => Future.sync(() {
ref.invalidate(chatroomsJoinedProvider);
}),
child: ListView.builder(
padding: getTabbedPadding(
context,
bottom: callState.isConnected ? 96 : null,
),
itemCount:
items
.where(
(item) =>
selectedTab.value == 0 ||
(selectedTab.value == 1 &&
item.type == 1) ||
(selectedTab.value == 2 && item.type != 1),
)
.length,
itemBuilder: (context, index) {
final filteredItems =
items
.where(
(item) =>
selectedTab.value == 0 ||
(selectedTab.value == 1 &&
item.type == 1) ||
(selectedTab.value == 2 &&
item.type != 1),
)
.toList();
final item = filteredItems[index];
return ChatRoomListTile(
room: item,
isDirect: item.type == 1,
onTap: () {
context.pushNamed(
'chatRoom',
pathParameters: {'id': item.id},
);
},
);
},
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, stack) => ResponseErrorWidget(
error: error,
onRetry: () {
ref.invalidate(chatroomsJoinedProvider);
},
),
),
),
],
), ),
); );
} }
@@ -463,275 +560,6 @@ Future<SnChatMember?> chatroomIdentity(Ref ref, String? identifier) async {
} }
} }
class NewChatScreen extends StatelessWidget {
const NewChatScreen({super.key});
@override
Widget build(BuildContext context) {
return const EditChatScreen();
}
}
class EditChatScreen extends HookConsumerWidget {
final String? id;
const EditChatScreen({super.key, this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>(), []);
final submitting = useState(false);
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final picture = useState<SnCloudFile?>(null);
final background = useState<SnCloudFile?>(null);
final isPublic = useState(true);
final isCommunity = useState(false);
final chat = ref.watch(chatroomProvider(id));
final joinedRealms = ref.watch(realmsJoinedProvider);
final currentRealm = useState<SnRealm?>(null);
useEffect(() {
if (chat.value != null) {
nameController.text = chat.value!.name ?? '';
descriptionController.text = chat.value!.description ?? '';
picture.value = chat.value!.picture;
background.value = chat.value!.background;
isPublic.value = chat.value!.isPublic;
isCommunity.value = chat.value!.isCommunity;
currentRealm.value = joinedRealms.value?.firstWhereOrNull(
(realm) => realm.id == chat.value!.realmId,
);
}
return;
}, [chat, joinedRealms]);
void setPicture(String position) async {
showLoadingModal(context);
var result = await ref
.read(imagePickerProvider)
.pickImage(source: ImageSource.gallery);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
hideLoadingModal(context);
result = await cropImage(
context,
image: result,
allowedAspectRatios: [
if (position == 'background')
const CropAspectRatio(height: 7, width: 16)
else
const CropAspectRatio(height: 1, width: 1),
],
);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
showLoadingModal(context);
submitting.value = true;
try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
final cloudFile =
await putFileToCloud(
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,
),
atk: token,
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
switch (position) {
case 'picture':
picture.value = cloudFile;
case 'background':
background.value = cloudFile;
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
submitting.value = false;
}
}
Future<void> performAction() async {
if (!formKey.currentState!.validate()) return;
submitting.value = true;
try {
final client = ref.watch(apiClientProvider);
final resp = await client.request(
id == null ? '/sphere/chat' : '/sphere/chat/$id',
data: {
'name': nameController.text,
'description': descriptionController.text,
'background_id': background.value?.id,
'picture_id': picture.value?.id,
'realm_id': currentRealm.value?.id,
'is_public': isPublic.value,
'is_community': isCommunity.value,
},
options: Options(method: id == null ? 'POST' : 'PATCH'),
);
if (context.mounted) {
context.pop(SnChatRoom.fromJson(resp.data));
}
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
return AppScaffold(
appBar: AppBar(
title: Text(id == null ? 'createChatRoom' : 'editChatRoom').tr(),
leading: const PageBackButton(),
),
body: SingleChildScrollView(
child: Column(
children: [
RealmSelectionDropdown(
value: currentRealm.value,
realms: joinedRealms.when(
data: (realms) => realms,
loading: () => [],
error: (_, _) => [],
),
onChanged: (SnRealm? value) {
currentRealm.value = value;
},
isLoading: joinedRealms.isLoading,
error: joinedRealms.error?.toString(),
),
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
GestureDetector(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child:
background.value != null
? CloudFileWidget(
item: background.value!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
setPicture('background');
},
),
Positioned(
left: 20,
bottom: -32,
child: GestureDetector(
child: ProfilePictureWidget(
fileId: picture.value?.id,
radius: 40,
fallbackIcon: Symbols.group,
),
onTap: () {
setPicture('picture');
},
),
),
],
),
).padding(bottom: 32),
Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Name'),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
alignLabelWithHint: true,
),
minLines: 3,
maxLines: null,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
CheckboxListTile(
secondary: const Icon(Symbols.public),
title: Text('publicChat').tr(),
subtitle: Text('publicChatDescription').tr(),
value: isPublic.value,
onChanged: (value) {
isPublic.value = value ?? true;
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
CheckboxListTile(
secondary: const Icon(Symbols.travel_explore),
title: Text('communityChat').tr(),
subtitle: Text('communityChatDescription').tr(),
value: isCommunity.value,
onChanged: (value) {
isCommunity.value = value ?? false;
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
],
),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: submitting.value ? null : performAction,
label: const Text('Save'),
icon: const Icon(Symbols.save),
),
),
],
).padding(all: 24),
),
],
),
),
);
}
}
@riverpod @riverpod
Future<List<SnChatMember>> chatroomInvites(Ref ref) async { Future<List<SnChatMember>> chatroomInvites(Ref ref) async {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);

View File

@@ -0,0 +1,299 @@
import 'package:collection/collection.dart';
import 'package:croppy/croppy.dart' hide cropImage;
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/file.dart';
import 'package:island/models/realm.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/chat/chat.dart';
import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class NewChatScreen extends StatelessWidget {
const NewChatScreen({super.key});
@override
Widget build(BuildContext context) {
return const EditChatScreen();
}
}
class EditChatScreen extends HookConsumerWidget {
final String? id;
const EditChatScreen({super.key, this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>(), []);
final submitting = useState(false);
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final picture = useState<SnCloudFile?>(null);
final background = useState<SnCloudFile?>(null);
final isPublic = useState(true);
final isCommunity = useState(false);
final chat = ref.watch(chatroomProvider(id));
final joinedRealms = ref.watch(realmsJoinedProvider);
final currentRealm = useState<SnRealm?>(null);
useEffect(() {
if (chat.value != null) {
nameController.text = chat.value!.name ?? '';
descriptionController.text = chat.value!.description ?? '';
picture.value = chat.value!.picture;
background.value = chat.value!.background;
isPublic.value = chat.value!.isPublic;
isCommunity.value = chat.value!.isCommunity;
currentRealm.value = joinedRealms.value?.firstWhereOrNull(
(realm) => realm.id == chat.value!.realmId,
);
}
return;
}, [chat, joinedRealms]);
void setPicture(String position) async {
showLoadingModal(context);
var result = await ref
.read(imagePickerProvider)
.pickImage(source: ImageSource.gallery);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
hideLoadingModal(context);
result = await cropImage(
context,
image: result,
allowedAspectRatios: [
if (position == 'background')
const CropAspectRatio(height: 7, width: 16)
else
const CropAspectRatio(height: 1, width: 1),
],
);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
showLoadingModal(context);
submitting.value = true;
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,
),
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
switch (position) {
case 'picture':
picture.value = cloudFile;
case 'background':
background.value = cloudFile;
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
submitting.value = false;
}
}
Future<void> performAction() async {
if (!formKey.currentState!.validate()) return;
submitting.value = true;
try {
final client = ref.watch(apiClientProvider);
final resp = await client.request(
id == null ? '/sphere/chat' : '/sphere/chat/$id',
data: {
'name': nameController.text,
'description': descriptionController.text,
'background_id': background.value?.id,
'picture_id': picture.value?.id,
'realm_id': currentRealm.value?.id,
'is_public': isPublic.value,
'is_community': isCommunity.value,
},
options: Options(method: id == null ? 'POST' : 'PATCH'),
);
if (context.mounted) {
context.pop(SnChatRoom.fromJson(resp.data));
}
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
return AppScaffold(
appBar: AppBar(
title: Text(id == null ? 'createChatRoom' : 'editChatRoom').tr(),
leading: const PageBackButton(),
),
body: SingleChildScrollView(
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
GestureDetector(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child:
background.value != null
? CloudFileWidget(
item: background.value!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
setPicture('background');
},
),
Positioned(
left: 20,
bottom: -32,
child: GestureDetector(
child: ProfilePictureWidget(
fileId: picture.value?.id,
radius: 40,
fallbackIcon: Symbols.group,
),
onTap: () {
setPicture('picture');
},
),
),
],
),
).padding(bottom: 32),
Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Name'),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
alignLabelWithHint: true,
),
minLines: 3,
maxLines: null,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
DropdownButtonFormField<SnRealm>(
value: currentRealm.value,
decoration: InputDecoration(labelText: 'realm'.tr()),
items: [
DropdownMenuItem<SnRealm>(
value: null,
child: Text('none'.tr()),
),
...joinedRealms.maybeWhen(
data:
(realms) => realms.map(
(realm) => DropdownMenuItem(
value: realm,
child: Text(realm.name),
),
),
orElse: () => [],
),
],
onChanged:
joinedRealms.isLoading
? null
: (SnRealm? value) {
currentRealm.value = value;
},
),
const SizedBox(height: 16),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
CheckboxListTile(
secondary: const Icon(Symbols.public),
title: Text('publicChat').tr(),
subtitle: Text('publicChatDescription').tr(),
value: isPublic.value,
onChanged: (value) {
isPublic.value = value ?? true;
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
CheckboxListTile(
secondary: const Icon(Symbols.travel_explore),
title: Text('communityChat').tr(),
subtitle: Text('communityChatDescription').tr(),
value: isCommunity.value,
onChanged: (value) {
isCommunity.value = value ?? false;
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
],
),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: submitting.value ? null : performAction,
label: const Text('Save'),
icon: const Icon(Symbols.save),
),
),
],
).padding(all: 24),
),
],
),
),
);
}
}

View File

@@ -17,7 +17,7 @@ import "package:island/widgets/chat/message_item.dart";
import "package:island/widgets/response.dart"; import "package:island/widgets/response.dart";
import "package:island/pods/network.dart"; import "package:island/pods/network.dart";
import "package:island/services/responsive.dart"; import "package:island/services/responsive.dart";
import "package:island/pods/messages_notifier.dart"; import "package:island/pods/chat/messages_notifier.dart";
class PublicRoomPreview extends HookConsumerWidget { class PublicRoomPreview extends HookConsumerWidget {
final String id; final String id;

File diff suppressed because it is too large Load Diff

View File

@@ -410,7 +410,7 @@ class ChatDetailScreen extends HookConsumerWidget {
if (result is SearchMessagesResult) { if (result is SearchMessagesResult) {
// Navigate back to room screen with message to jump to // Navigate back to room screen with message to jump to
if (context.mounted) { if (context.mounted) {
context.pop(result.messageId); context.pop(result);
} }
} }
}, },

View File

@@ -3,11 +3,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/messages_notifier.dart'; import 'package:island/pods/chat/messages_notifier.dart';
import 'package:island/pods/chat/chat_rooms.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/message_list_tile.dart'; import 'package:island/widgets/chat/message_list_tile.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:super_sliver_list/super_sliver_list.dart'; import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:island/services/responsive.dart';
import 'dart:async';
// Class to represent the result when popping from search messages // Class to represent the result when popping from search messages
class SearchMessagesResult { class SearchMessagesResult {
@@ -15,6 +18,93 @@ class SearchMessagesResult {
const SearchMessagesResult(this.messageId); const SearchMessagesResult(this.messageId);
} }
// Search states for better UX
enum SearchState { idle, searching, results, noResults, error }
class _SearchFilters extends StatelessWidget {
final ValueNotifier<bool> withLinks;
final ValueNotifier<bool> withAttachments;
final void Function(String) performSearch;
final TextEditingController searchController;
final bool isLarge;
const _SearchFilters({
required this.withLinks,
required this.withAttachments,
required this.performSearch,
required this.searchController,
required this.isLarge,
});
@override
Widget build(BuildContext context) {
if (isLarge) {
return Row(
children: [
IconButton(
icon: Icon(
Symbols.link,
color:
withLinks.value
? Theme.of(context).colorScheme.primary
: Theme.of(context).iconTheme.color,
),
onPressed: () {
withLinks.value = !withLinks.value;
performSearch(searchController.text);
},
tooltip: 'searchLinks'.tr(),
),
IconButton(
icon: Icon(
Symbols.file_copy,
color:
withAttachments.value
? Theme.of(context).colorScheme.primary
: Theme.of(context).iconTheme.color,
),
onPressed: () {
withAttachments.value = !withAttachments.value;
performSearch(searchController.text);
},
tooltip: 'searchAttachments'.tr(),
),
],
);
} else {
return Row(
children: [
FilterChip(
avatar: const Icon(Symbols.link, size: 16),
label: const Text('searchLinks').tr(),
selected: withLinks.value,
onSelected: (bool? value) {
withLinks.value = value!;
performSearch(searchController.text);
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
const SizedBox(width: 8),
FilterChip(
avatar: const Icon(Symbols.file_copy, size: 16),
label: const Text('searchAttachments').tr(),
selected: withAttachments.value,
onSelected: (bool? value) {
withAttachments.value = value!;
performSearch(searchController.text);
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
],
);
}
}
}
class SearchMessagesScreen extends HookConsumerWidget { class SearchMessagesScreen extends HookConsumerWidget {
final String roomId; final String roomId;
@@ -25,99 +115,329 @@ class SearchMessagesScreen extends HookConsumerWidget {
final searchController = useTextEditingController(); final searchController = useTextEditingController();
final withLinks = useState(false); final withLinks = useState(false);
final withAttachments = useState(false); final withAttachments = useState(false);
final searchState = useState(SearchState.idle);
final searchResultCount = useState<int?>(null);
final searchResults = useState<AsyncValue<List<dynamic>>>(
const AsyncValue.data([]),
);
// Debounce timer for search optimization
final debounceTimer = useRef<Timer?>(null);
final messagesNotifier = ref.read( final messagesNotifier = ref.read(
messagesNotifierProvider(roomId).notifier, messagesNotifierProvider(roomId).notifier,
); );
final messages = ref.watch(messagesNotifierProvider(roomId));
// Optimized search function with debouncing
void performSearch(String query) async {
final trimmedQuery = query.trim();
final hasFilters = withLinks.value || withAttachments.value;
if (trimmedQuery.isEmpty && !hasFilters) {
searchState.value = SearchState.idle;
searchResultCount.value = null;
searchResults.value = const AsyncValue.data([]);
return;
}
searchState.value = SearchState.searching;
searchResults.value = const AsyncValue.loading();
// Cancel previous search if still active
debounceTimer.value?.cancel();
// Debounce search to avoid excessive API calls
debounceTimer.value = Timer(const Duration(milliseconds: 300), () async {
try {
final results = await messagesNotifier.getSearchResults(
query.trim(),
withLinks: withLinks.value,
withAttachments: withAttachments.value,
);
searchResults.value = AsyncValue.data(results);
searchState.value =
results.isEmpty ? SearchState.noResults : SearchState.results;
searchResultCount.value = results.length;
} catch (error, stackTrace) {
searchResults.value = AsyncValue.error(error, stackTrace);
searchState.value = SearchState.error;
}
});
}
// Search state is now managed locally in performSearch
useEffect(() { useEffect(() {
// Clear search when screen is disposed // Clear search when screen is disposed
return () { return () {
messagesNotifier.clearSearch(); debounceTimer.value?.cancel();
// Note: Don't access ref here as widget may be disposed
// Flashing messages will be cleared by the next screen or jump operation
}; };
}, []); }, []);
// Clear flashing messages when screen initializes (safer than in dispose)
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clear flashing messages when entering search screen
ref.read(flashingMessagesProvider.notifier).state = {};
});
return null;
}, []);
final isLarge = isWideScreen(context);
return AppScaffold( return AppScaffold(
appBar: AppBar(title: const Text('searchMessages').tr()), appBar: AppBar(
title: const Text('searchMessages').tr(),
bottom:
searchState.value == SearchState.searching
? const PreferredSize(
preferredSize: Size.fromHeight(2),
child: LinearProgressIndicator(),
)
: null,
),
body: Column( body: Column(
children: [ children: [
Column( // Search input section
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Material(
elevation: 2,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(32),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child:
isLarge
? Row(
children: [ children: [
TextField( Expanded(
child: TextField(
controller: searchController, controller: searchController,
autofocus: true,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'searchMessagesHint'.tr(), hintText: 'searchMessagesHint'.tr(),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: EdgeInsets.only( contentPadding: const EdgeInsets.symmetric(
left: 16, horizontal: 12,
right: 16, vertical: 12,
top: 12,
bottom: 16,
), ),
suffix: IconButton( suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (searchResultCount.value != null &&
searchState.value ==
SearchState.results)
Container(
margin: const EdgeInsets.only(
right: 8,
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.1),
borderRadius: BorderRadius.circular(
12,
),
),
child: Text(
'${searchResultCount.value}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color:
Theme.of(
context,
).colorScheme.primary,
),
),
),
if (searchController.text.isNotEmpty)
IconButton(
iconSize: 18, iconSize: 18,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
searchController.clear(); searchController.clear();
messagesNotifier.clearSearch(); performSearch('');
}, },
), ),
],
), ),
onChanged: (query) {
messagesNotifier.searchMessages(
query,
withLinks: withLinks.value,
withAttachments: withAttachments.value,
);
},
), ),
Row( onChanged: performSearch,
),
),
const SizedBox(width: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: _SearchFilters(
withLinks: withLinks,
withAttachments: withAttachments,
performSearch: performSearch,
searchController: searchController,
isLarge: isLarge,
),
),
],
)
: Column(
children: [ children: [
Expanded( TextField(
child: CheckboxListTile( controller: searchController,
secondary: const Icon(Symbols.link), autofocus: true,
title: const Text('searchLinks').tr(), decoration: InputDecoration(
value: withLinks.value, hintText: 'searchMessagesHint'.tr(),
onChanged: (bool? value) { border: InputBorder.none,
withLinks.value = value!; isDense: true,
messagesNotifier.searchMessages( contentPadding: const EdgeInsets.symmetric(
searchController.text, horizontal: 12,
withLinks: withLinks.value, vertical: 12,
withAttachments: withAttachments.value, ),
); suffixIcon: Row(
}, mainAxisSize: MainAxisSize.min,
children: [
if (searchResultCount.value != null &&
searchState.value ==
SearchState.results)
Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.1),
borderRadius: BorderRadius.circular(
12,
), ),
), ),
Expanded( child: Text(
child: CheckboxListTile( '${searchResultCount.value}',
secondary: const Icon(Symbols.file_copy), style: TextStyle(
title: const Text('searchAttachments').tr(), fontSize: 12,
value: withAttachments.value, fontWeight: FontWeight.bold,
onChanged: (bool? value) { color:
withAttachments.value = value!; Theme.of(
messagesNotifier.searchMessages( context,
searchController.text, ).colorScheme.primary,
withLinks: withLinks.value, ),
withAttachments: withAttachments.value, ),
); ),
if (searchController.text.isNotEmpty)
IconButton(
iconSize: 18,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
performSearch('');
}, },
), ),
],
),
),
onChanged: performSearch,
),
Padding(
padding: const EdgeInsets.only(
left: 8,
right: 8,
top: 8,
bottom: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_SearchFilters(
withLinks: withLinks,
withAttachments: withAttachments,
performSearch: performSearch,
searchController: searchController,
isLarge: false,
),
],
),
), ),
], ],
), ),
),
),
),
// Search results section
Expanded(
child: searchResults.value.when(
data: (messageList) {
switch (searchState.value) {
case SearchState.idle:
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'searchMessagesHint'.tr(),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).disabledColor,
),
),
], ],
), ),
const Divider(height: 1), );
Expanded(
child: messages.when( case SearchState.noResults:
data: return Center(
(messageList) => child: Column(
messageList.isEmpty mainAxisAlignment: MainAxisAlignment.center,
? Center(child: Text('noMessagesFound'.tr())) children: [
: SuperListView.builder( Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'noMessagesFound'.tr(),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).disabledColor,
),
),
const SizedBox(height: 8),
Text(
'tryDifferentKeywords'.tr(),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: Theme.of(context).disabledColor,
),
),
],
),
);
case SearchState.results:
return SuperListView.builder(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
reverse: false, // Show newest messages at the top reverse: false, // Show newest messages at the top
itemCount: messageList.length, itemCount: messageList.length,
@@ -131,12 +451,54 @@ class SearchMessagesScreen extends HookConsumerWidget {
}, },
); );
}, },
);
default:
return const SizedBox.shrink();
}
},
loading: () {
if (searchState.value == SearchState.searching) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Searching...'),
],
), ),
loading: () => const Center(child: CircularProgressIndicator()), );
error: }
(error, _) => Center( return const Center(child: CircularProgressIndicator());
child: Text('errorGeneric'.tr(args: [error.toString()])), },
error: (error, _) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
), ),
const SizedBox(height: 16),
Text(
'searchError'.tr(),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () => performSearch(searchController.text),
icon: const Icon(Icons.refresh),
label: const Text('retry').tr(),
),
],
),
);
},
), ),
), ),
], ],

View File

@@ -9,7 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/models/publisher.dart'; import 'package:island/models/publisher.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/screens/creators/publishers.dart'; import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/utils/text.dart'; import 'package:island/utils/text.dart';
import 'package:island/widgets/account/account_picker.dart'; import 'package:island/widgets/account/account_picker.dart';
@@ -102,42 +102,168 @@ class PublisherMemberListNotifier extends _$PublisherMemberListNotifier
} }
} }
class CreatorHubShellScreen extends StatelessWidget { class PublisherSelector extends StatelessWidget {
final Widget child; final SnPublisher? currentPublisher;
const CreatorHubShellScreen({super.key, required this.child}); final List<DropdownMenuItem<SnPublisher>> publishersMenu;
final ValueChanged<SnPublisher?>? onChanged;
final bool isReadOnly;
const PublisherSelector({
super.key,
required this.currentPublisher,
required this.publishersMenu,
this.onChanged,
this.isReadOnly = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isWide = isWideScreen(context); if (isReadOnly || currentPublisher == null) {
if (isWide) { return ProfilePictureWidget(
return AppBackground( radius: 16,
isRoot: true, fileId: currentPublisher?.picture?.id,
child: Row( ).center().padding(right: 8);
}
return DropdownButtonHideUnderline(
child: DropdownButton2<SnPublisher>(
value: currentPublisher,
hint: CircleAvatar(
radius: 16,
child: Icon(
Symbols.person,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer.withOpacity(0.9),
fill: 1,
),
).center().padding(right: 8),
items: publishersMenu,
onChanged: onChanged,
selectedItemBuilder: (context) {
return publishersMenu
.map(
(e) => ProfilePictureWidget(
radius: 16,
fileId: e.value?.picture?.id,
).center().padding(right: 8),
)
.toList();
},
buttonStyleData: ButtonStyleData(
height: 40,
padding: const EdgeInsets.only(left: 14, right: 8),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
),
dropdownStyleData: DropdownStyleData(
width: 320,
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)),
),
menuItemStyleData: const MenuItemStyleData(
height: 64,
padding: EdgeInsets.only(left: 14, right: 14),
),
iconStyleData: IconStyleData(
icon: Icon(Icons.arrow_drop_down),
iconSize: 19,
iconEnabledColor: Theme.of(context).appBarTheme.foregroundColor!,
iconDisabledColor: Theme.of(context).appBarTheme.foregroundColor!,
),
),
);
}
}
class _PublisherUnselectedWidget extends HookConsumerWidget {
final ValueChanged<SnPublisher> onPublisherSelected;
const _PublisherUnselectedWidget({required this.onPublisherSelected});
@override
Widget build(BuildContext context, WidgetRef ref) {
final publishers = ref.watch(publishersManagedProvider);
final publisherInvites = ref.watch(publisherInvitesProvider);
final hasPublishers = publishers.value?.isNotEmpty ?? false;
return Card(
margin: const EdgeInsets.all(16),
child: Column(
children: [ children: [
Flexible(flex: 2, child: const CreatorHubScreen(isAside: true)), if (!hasPublishers) ...[
const VerticalDivider(width: 1), const Icon(
Flexible(flex: 3, child: child), Symbols.info,
fill: 1,
size: 32,
).padding(bottom: 6, top: 24),
Text(
'creatorHubUnselectedHint',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
).tr(),
const Gap(24),
],
if (hasPublishers)
...(publishers.value?.map(
(publisher) => ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
leading: ProfilePictureWidget(file: publisher.picture),
title: Text(publisher.nick),
subtitle: Text('@${publisher.name}'),
onTap: () => onPublisherSelected(publisher),
),
) ??
[]),
const Divider(height: 1),
ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
leading: const CircleAvatar(child: Icon(Symbols.mail)),
title: Text('publisherCollabInvitation').tr(),
subtitle: Text(
'publisherCollabInvitationCount',
).plural(publisherInvites.value?.length ?? 0),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => const _PublisherInviteSheet(),
);
},
),
ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
leading: const CircleAvatar(child: Icon(Symbols.add)),
title: Text('createPublisher').tr(),
subtitle: Text('createPublisherHint').tr(),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
context.pushNamed('creatorNew').then((value) {
if (value != null) {
ref.invalidate(publishersManagedProvider);
}
});
},
),
], ],
), ),
); );
} }
return AppBackground(isRoot: true, child: child);
}
} }
class CreatorHubScreen extends HookConsumerWidget { class CreatorHubScreen extends HookConsumerWidget {
final bool isAside; const CreatorHubScreen({super.key});
const CreatorHubScreen({super.key, this.isAside = false});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context);
if (isWide && !isAside) {
return Container(color: Theme.of(context).colorScheme.surface);
}
final publishers = ref.watch(publishersManagedProvider); final publishers = ref.watch(publishersManagedProvider);
final publisherInvites = ref.watch(publisherInvitesProvider);
final currentPublisher = useState<SnPublisher?>( final currentPublisher = useState<SnPublisher?>(
publishers.value?.firstOrNull, publishers.value?.firstOrNull,
); );
@@ -207,227 +333,94 @@ class CreatorHubScreen extends HookConsumerWidget {
publisherFeaturesProvider(currentPublisher.value?.name), publisherFeaturesProvider(currentPublisher.value?.name),
); );
return AppScaffold( Widget buildNavigationWidget(bool isWide) {
appBar: AppBar( final leftItems = [
leading: !isWide ? const PageBackButton() : null,
title: Text('creatorHub').tr(),
actions: [
DropdownButtonHideUnderline(
child: DropdownButton2<SnPublisher>(
alignment: Alignment.centerRight,
value: currentPublisher.value,
hint: CircleAvatar(
radius: 16,
child: Icon(
Symbols.person,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer.withOpacity(0.9),
fill: 1,
),
).center().padding(right: 8),
items: [...publishersMenu],
onChanged: (value) {
currentPublisher.value = value;
},
selectedItemBuilder: (context) {
return [
...publishersMenu.map(
(e) => ProfilePictureWidget(
radius: 16,
fileId: e.value?.picture?.id,
).center().padding(right: 8),
),
];
},
buttonStyleData: ButtonStyleData(
height: 40,
padding: const EdgeInsets.only(left: 14, right: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
),
),
dropdownStyleData: DropdownStyleData(
width: 320,
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
),
),
menuItemStyleData: const MenuItemStyleData(
height: 64,
padding: EdgeInsets.only(left: 14, right: 14),
),
iconStyleData: IconStyleData(
icon: Icon(Icons.arrow_drop_down),
iconSize: 19,
iconEnabledColor:
Theme.of(context).appBarTheme.foregroundColor!,
iconDisabledColor:
Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
const Gap(8),
],
),
body: publisherStats.when(
data:
(stats) => SingleChildScrollView(
child:
currentPublisher.value == null
? Column(
children: [
const Gap(24),
const Icon(Symbols.info, size: 32).padding(bottom: 4),
Text(
'creatorHubUnselectedHint',
textAlign: TextAlign.center,
).tr(),
const Gap(24),
const Divider(height: 1),
...(publishers.value?.map(
(publisher) => ListTile(
leading: ProfilePictureWidget(
file: publisher.picture,
),
title: Text(publisher.nick),
subtitle: Text('@${publisher.name}'),
onTap: () {
currentPublisher.value = publisher;
},
),
) ??
[]),
ListTile( ListTile(
leading: const CircleAvatar( shape: RoundedRectangleBorder(
child: Icon(Symbols.mail), borderRadius: const BorderRadius.all(Radius.circular(8)),
), ),
title: Text('publisherCollabInvitation').tr(),
subtitle: Text(
'publisherCollabInvitationCount',
).plural(publisherInvites.value?.length ?? 0),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => const _PublisherInviteSheet(),
);
},
),
ListTile(
leading: const CircleAvatar(
child: Icon(Symbols.add),
),
title: Text('createPublisher').tr(),
subtitle: Text('createPublisherHint').tr(),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
context.pushNamed('creatorNew').then((value) {
if (value != null) {
ref.invalidate(publishersManagedProvider);
}
});
},
),
],
)
: Column(
children: [
if (stats != null)
_PublisherStatsWidget(
stats: stats,
).padding(vertical: 12, horizontal: 12),
ListTile(
minTileHeight: 48, minTileHeight: 48,
title: Text('stickers').tr(), title: Text('stickers').tr(),
trailing: Icon(Symbols.chevron_right), trailing: Icon(Symbols.chevron_right),
leading: const Icon(Symbols.ar_stickers), leading: const Icon(Symbols.ar_stickers),
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
onTap: () { onTap: () {
context.pushNamed( context.pushNamed(
'creatorStickers', 'creatorStickers',
pathParameters: { pathParameters: {'name': currentPublisher.value!.name},
'name': currentPublisher.value!.name,
},
); );
}, },
), ),
ListTile( ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
minTileHeight: 48, minTileHeight: 48,
title: Text('posts').tr(), title: Text('posts').tr(),
trailing: Icon(Symbols.chevron_right), trailing: Icon(Symbols.chevron_right),
leading: const Icon(Symbols.sticky_note_2), leading: const Icon(Symbols.sticky_note_2),
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
onTap: () { onTap: () {
context.pushNamed( context.pushNamed(
'creatorPosts', 'creatorPosts',
pathParameters: { pathParameters: {'name': currentPublisher.value!.name},
'name': currentPublisher.value!.name,
},
); );
}, },
), ),
ListTile( ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
minTileHeight: 48, minTileHeight: 48,
title: Text('polls').tr(), title: Text('polls').tr(),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
leading: const Icon(Symbols.poll), leading: const Icon(Symbols.poll),
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
),
onTap: () { onTap: () {
context.pushNamed( context.pushNamed(
'creatorPolls', 'creatorPolls',
pathParameters: { pathParameters: {'name': currentPublisher.value!.name},
'name': currentPublisher.value!.name,
},
); );
}, },
), ),
ListTile( ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
minTileHeight: 48,
title: const Text('webFeeds').tr(),
trailing: const Icon(Symbols.chevron_right),
leading: const Icon(Symbols.rss_feed),
onTap: () {
context.push('/creators/${currentPublisher.value!.name}/feeds');
},
),
];
final rightItems = [
ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
minTileHeight: 48, minTileHeight: 48,
title: Text('publisherMembers').tr(), title: Text('publisherMembers').tr(),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
leading: const Icon(Symbols.group), leading: const Icon(Symbols.group),
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
),
onTap: () { onTap: () {
showModalBottomSheet( showModalBottomSheet(
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: builder:
(context) => _PublisherMemberListSheet( (context) => _PublisherMemberListSheet(
publisherUname: publisherUname: currentPublisher.value!.name,
currentPublisher.value!.name,
), ),
); );
}, },
), ),
ListTile(
minTileHeight: 48,
title: const Text('webFeeds').tr(),
trailing: const Icon(Symbols.chevron_right),
leading: const Icon(Symbols.rss_feed),
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
),
onTap: () {
context.push(
'/creators/${currentPublisher.value!.name}/feeds',
);
},
),
ExpansionTile( ExpansionTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
title: Text('publisherFeatures').tr(), title: Text('publisherFeatures').tr(),
leading: const Icon(Symbols.flag), leading: const Icon(Symbols.flag),
tilePadding: EdgeInsets.symmetric(horizontal: 24), tilePadding: const EdgeInsets.only(left: 16, right: 24),
minTileHeight: 48, minTileHeight: 48,
children: [ children: [
...publisherFeatures.when( ...publisherFeatures.when(
@@ -437,28 +430,19 @@ class CreatorHubScreen extends HookConsumerWidget {
'publisherFeature${entry.key.capitalizeEachWord()}'; 'publisherFeature${entry.key.capitalizeEachWord()}';
return ListTile( return ListTile(
minTileHeight: 48, minTileHeight: 48,
contentPadding: EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(horizontal: 16),
horizontal: 24,
),
leading: Icon( leading: Icon(
Symbols.circle, Symbols.circle,
color: color: entry.value ? Colors.green : Colors.red,
entry.value
? Colors.green
: Colors.red,
fill: 1, fill: 1,
size: 16, size: 16,
).padding(left: 2, top: 4), ).padding(left: 2, top: 4),
title: Text(keyPrefix).tr(), title: Text(keyPrefix).tr(),
subtitle: Column( subtitle: Column(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
Text('${keyPrefix}Description').tr(), Text('${keyPrefix}Description').tr(),
if (!entry.value) if (!entry.value) Text('${keyPrefix}Hint').tr().bold(),
Text(
'${keyPrefix}Hint',
).tr().bold(),
], ],
), ),
isThreeLine: true, isThreeLine: true,
@@ -470,37 +454,131 @@ class CreatorHubScreen extends HookConsumerWidget {
), ),
], ],
), ),
Divider(height: 1).padding(vertical: 8),
ListTile( ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
minTileHeight: 48, minTileHeight: 48,
title: Text('editPublisher').tr(), title: Text('editPublisher').tr(),
trailing: Icon(Symbols.chevron_right), trailing: Icon(Symbols.chevron_right),
leading: const Icon(Symbols.edit), leading: const Icon(Symbols.edit),
contentPadding: EdgeInsets.symmetric( onTap: updatePublisher,
horizontal: 24,
),
onTap: () {
updatePublisher();
},
), ),
ListTile( ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
minTileHeight: 48, minTileHeight: 48,
title: Text('deletePublisher').tr(), title: Text('deletePublisher').tr(),
trailing: Icon(Symbols.chevron_right), trailing: Icon(Symbols.chevron_right),
leading: const Icon(Symbols.delete), leading: const Icon(Symbols.delete),
contentPadding: EdgeInsets.symmetric( onTap: deletePublisher,
horizontal: 24,
), ),
onTap: () { ];
deletePublisher();
if (isWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Expanded(
child: Card(
margin: EdgeInsets.zero,
child: Column(children: leftItems),
),
),
Expanded(
child: Card(
margin: EdgeInsets.zero,
child: Column(children: rightItems),
),
),
],
).padding(horizontal: 12);
} else {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [...leftItems, const Divider(height: 8), ...rightItems],
),
);
}
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('creatorHub').tr(),
actions: [
if (!isWideScreen(context))
PublisherSelector(
currentPublisher: currentPublisher.value,
publishersMenu: publishersMenu,
onChanged: (value) {
currentPublisher.value = value;
}, },
), ),
const Gap(8),
],
),
body: LayoutBuilder(
builder: (context, constraints) {
final isWide = isWideScreen(context);
final maxWidth = isWide ? 800.0 : double.infinity;
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: publisherStats.when(
data:
(stats) => SingleChildScrollView(
child:
currentPublisher.value == null
? ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: _PublisherUnselectedWidget(
onPublisherSelected: (publisher) {
currentPublisher.value = publisher;
},
),
).center()
: isWide
? Column(
spacing: 8,
children: [
PublisherSelector(
currentPublisher: currentPublisher.value,
publishersMenu: publishersMenu,
onChanged: (value) {
currentPublisher.value = value;
},
),
if (stats != null)
_PublisherStatsWidget(
stats: stats,
).padding(horizontal: 12),
buildNavigationWidget(true),
],
)
: Column(
spacing: 12,
children: [
if (stats != null)
_PublisherStatsWidget(
stats: stats,
).padding(horizontal: 16),
buildNavigationWidget(false),
], ],
), ),
), ),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => const SizedBox.shrink(), error: (_, _) => const SizedBox.shrink(),
), ),
),
);
},
),
); );
} }
} }
@@ -576,6 +654,18 @@ class _PublisherStatsWidget extends StatelessWidget {
height: 100, height: 100,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Tooltip(
richMessage: TextSpan(
text: statLabel.tr(),
style: TextStyle(fontWeight: FontWeight.bold),
children: [
TextSpan(text: ' '),
TextSpan(
text: statValue,
style: TextStyle(fontWeight: FontWeight.normal),
),
],
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -583,6 +673,8 @@ class _PublisherStatsWidget extends StatelessWidget {
Text( Text(
statValue, statValue,
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
const Gap(4), const Gap(4),
Text( Text(
@@ -594,6 +686,7 @@ class _PublisherStatsWidget extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
} }

View File

@@ -84,6 +84,7 @@ class CreatorPollListScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold( return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: const Text('Polls')), appBar: AppBar(title: const Text('Polls')),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _createPoll(context), onPressed: () => _createPoll(context),
@@ -105,10 +106,13 @@ class CreatorPollListScreen extends HookConsumerWidget {
return endItemView; return endItemView;
} }
final pollWithStats = data.items[index]; final pollWithStats = data.items[index];
return _CreatorPollItem( return ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: _CreatorPollItem(
pollWithStats: pollWithStats, pollWithStats: pollWithStats,
pubName: pubName, pubName: pubName,
); ),
).center();
}, },
), ),
), ),

View File

@@ -1,12 +1,9 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/post_list.dart'; import 'package:island/widgets/post/post_list.dart';
import 'package:material_symbols_icons/symbols.dart';
class CreatorPostListScreen extends HookConsumerWidget { class CreatorPostListScreen extends HookConsumerWidget {
final String pubName; final String pubName;
@@ -16,61 +13,19 @@ class CreatorPostListScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final refreshKey = useState(0); final refreshKey = useState(0);
void showCreatePostSheet() {
showModalBottomSheet(
context: context,
builder:
(context) => SheetScaffold(
titleText: 'create'.tr(),
child: Column(
children: [
ListTile(
leading: const Icon(Symbols.edit),
title: Text('Post'),
subtitle: Text('Create a regular post'),
onTap: () async {
Navigator.pop(context);
final result = await context.pushNamed(
'postCompose',
queryParameters: {'type': '0'},
);
if (result == true) {
refreshKey.value++;
}
},
),
ListTile(
leading: const Icon(Symbols.article),
title: Text('Article'),
subtitle: Text('Create a detailed article'),
onTap: () async {
Navigator.pop(context);
final result = await context.pushNamed(
'postCompose',
queryParameters: {'type': '1'},
);
if (result == true) {
refreshKey.value++;
}
},
),
],
),
),
);
}
return AppScaffold( return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: Text('posts').tr()), appBar: AppBar(title: Text('posts').tr()),
body: CustomScrollView( body: CustomScrollView(
key: ValueKey(refreshKey.value), key: ValueKey(refreshKey.value),
slivers: [ slivers: [
SliverPostList(pubName: pubName, itemType: PostItemType.creator), SliverPostList(
], pubName: pubName,
itemType: PostItemType.creator,
maxWidth: 640,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
), ),
floatingActionButton: FloatingActionButton( ],
onPressed: showCreatePostSheet,
child: const Icon(Symbols.add),
), ),
); );
} }

View File

@@ -10,21 +10,19 @@ import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/publisher.dart'; import 'package:island/models/publisher.dart';
import 'package:island/models/realm.dart'; import 'package:island/models/realm.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/realm/realms.dart'; import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/realm/realm_selection_dropdown.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
part 'publishers.g.dart'; part 'publishers_form.g.dart';
@riverpod @riverpod
Future<List<SnPublisher>> publishersManaged(Ref ref) async { Future<List<SnPublisher>> publishersManaged(Ref ref) async {
@@ -95,19 +93,13 @@ class EditPublisherScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
fileData: UniversalFile( fileData: UniversalFile(
data: result, data: result,
type: UniversalFileType.image, type: UniversalFileType.image,
), ),
atk: token, client: ref.read(apiClientProvider),
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future; ).future;
if (cloudFile == null) { if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...'); throw ArgumentError('Failed to upload the file...');
@@ -186,27 +178,15 @@ class EditPublisherScreen extends HookConsumerWidget {
} }
return AppScaffold( return AppScaffold(
isNoBackground: false,
appBar: AppBar( appBar: AppBar(
title: Text(name == null ? 'createPublisher' : 'editPublisher').tr(), title: Text(name == null ? 'createPublisher' : 'editPublisher').tr(),
leading: const PageBackButton(), leading: const PageBackButton(),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: getTabbedPadding(context, bottom: 16), padding: EdgeInsets.only(bottom: 16),
child: Column( child: Column(
children: [ children: [
RealmSelectionDropdown(
value: currentRealm.value,
realms: joinedRealms.when(
data: (realms) => realms,
loading: () => [],
error: (_, _) => [],
),
onChanged: (SnRealm? value) {
currentRealm.value = value;
},
isLoading: joinedRealms.isLoading,
error: joinedRealms.error?.toString(),
),
AspectRatio( AspectRatio(
aspectRatio: 16 / 7, aspectRatio: 16 / 7,
child: Stack( child: Stack(
@@ -280,6 +260,32 @@ class EditPublisherScreen extends HookConsumerWidget {
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
DropdownButtonFormField<SnRealm>(
value: currentRealm.value,
decoration: InputDecoration(labelText: 'realm'.tr()),
items: [
DropdownMenuItem<SnRealm>(
value: null,
child: Text('individual'.tr()),
),
...joinedRealms.maybeWhen(
data:
(realms) => realms.map(
(realm) => DropdownMenuItem(
value: realm,
child: Text(realm.name),
),
),
orElse: () => [],
),
],
onChanged:
joinedRealms.isLoading
? null
: (SnRealm? value) {
currentRealm.value = value;
},
),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [

View File

@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
part of 'publishers.dart'; part of 'publishers_form.dart';
// ************************************************************************** // **************************************************************************
// RiverpodGenerator // RiverpodGenerator

View File

@@ -8,13 +8,14 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/sticker.dart'; import 'package:island/models/sticker.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/screens/creators/stickers/stickers.dart'; import 'package:island/screens/creators/stickers/stickers.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_file_picker.dart'; import 'package:island/widgets/content/cloud_file_picker.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -33,13 +34,13 @@ Future<List<SnSticker>> stickerPackContent(Ref ref, String packId) async {
.toList(); .toList();
} }
class StickerPackDetailScreen extends HookConsumerWidget { class StickerPackDetailContent extends HookConsumerWidget {
final String id; final String id;
final String pubName; final String pubName;
const StickerPackDetailScreen({ const StickerPackDetailContent({
super.key, super.key,
required this.pubName,
required this.id, required this.id,
required this.pubName,
}); });
@override @override
@@ -67,34 +68,7 @@ class StickerPackDetailScreen extends HookConsumerWidget {
} }
} }
return AppScaffold( return pack.when(
appBar: AppBar(
title: Text(pack.value?.name ?? 'loading'.tr()),
actions: [
IconButton(
icon: const Icon(Symbols.add_circle),
onPressed: () {
context
.pushNamed(
'creatorStickerNew',
pathParameters: {'name': pubName, 'packId': id},
)
.then((value) {
if (value != null) {
ref.invalidate(stickerPackContentProvider(id));
}
});
},
),
_StickerPackActionMenu(
pubName: pubName,
packId: id,
iconShadow: Shadow(),
),
const Gap(8),
],
),
body: pack.when(
data: data:
(pack) => Column( (pack) => Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -124,10 +98,13 @@ class StickerPackDetailScreen extends HookConsumerWidget {
spacing: 4, spacing: 4,
children: [ children: [
const Icon(Symbols.tag, size: 16), const Icon(Symbols.tag, size: 16),
SelectableText( Flexible(
child: SelectableText(
pack.id, pack.id,
maxLines: 1,
style: GoogleFonts.robotoMono(), style: GoogleFonts.robotoMono(),
), ),
),
], ],
).opacity(0.85), ).opacity(0.85),
], ],
@@ -148,7 +125,7 @@ class StickerPackDetailScreen extends HookConsumerWidget {
), ),
gridDelegate: gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent( const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 48, maxCrossAxisExtent: 80,
mainAxisSpacing: 8, mainAxisSpacing: 8,
crossAxisSpacing: 8, crossAxisSpacing: 8,
), ),
@@ -166,7 +143,7 @@ class StickerPackDetailScreen extends HookConsumerWidget {
Clipboard.setData( Clipboard.setData(
ClipboardData( ClipboardData(
text: text:
':${pack.prefix}${sticker.slug}:', ':${pack.prefix}+${sticker.slug}:',
), ),
); );
}, },
@@ -176,21 +153,21 @@ class StickerPackDetailScreen extends HookConsumerWidget {
title: 'edit'.tr(), title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit), image: MenuImage.icon(Symbols.edit),
callback: () { callback: () {
context showModalBottomSheet(
.pushNamed( context: context,
'creatorStickerEdit', isScrollControlled: true,
pathParameters: { builder:
'name': pubName, (context) => SheetScaffold(
'packId': id, titleText: 'editSticker'.tr(),
'id': sticker.id, child: StickerForm(
}, packId: id,
) id: sticker.id,
.then((value) { ),
),
).then((value) {
if (value != null) { if (value != null) {
ref.invalidate( ref.invalidate(
stickerPackContentProvider( stickerPackContentProvider(id),
id,
),
); );
} }
}); });
@@ -222,6 +199,7 @@ class StickerPackDetailScreen extends HookConsumerWidget {
), ),
child: CloudImageWidget( child: CloudImageWidget(
fileId: sticker.imageId, fileId: sticker.imageId,
fit: BoxFit.contain,
), ),
), ),
), ),
@@ -243,17 +221,17 @@ class StickerPackDetailScreen extends HookConsumerWidget {
(err, _) => (err, _) =>
Text('Error: $err').textAlignment(TextAlign.center).center(), Text('Error: $err').textAlignment(TextAlign.center).center(),
loading: () => const CircularProgressIndicator().center(), loading: () => const CircularProgressIndicator().center(),
),
); );
} }
} }
class _StickerPackActionMenu extends HookConsumerWidget { class StickerPackActionMenu extends HookConsumerWidget {
final String pubName; final String pubName;
final String packId; final String packId;
final Shadow iconShadow; final Shadow iconShadow;
const _StickerPackActionMenu({ const StickerPackActionMenu({
super.key,
required this.pubName, required this.pubName,
required this.packId, required this.packId,
required this.iconShadow, required this.iconShadow,
@@ -267,7 +245,22 @@ class _StickerPackActionMenu extends HookConsumerWidget {
(context) => [ (context) => [
PopupMenuItem( PopupMenuItem(
onTap: () { onTap: () {
context.push('/creators/$pubName/stickers/$packId/edit'); showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'editStickerPack'.tr(),
child: StickerPackForm(
pubName: pubName,
packId: packId,
),
),
).then((value) {
if (value != null) {
ref.invalidate(stickerPackProvider(packId));
}
});
}, },
child: Row( child: Row(
children: [ children: [
@@ -310,42 +303,10 @@ class _StickerPackActionMenu extends HookConsumerWidget {
} }
} }
@freezed class StickerForm extends HookConsumerWidget {
sealed class StickerWithPackQuery with _$StickerWithPackQuery {
const factory StickerWithPackQuery({
required String packId,
required String id,
}) = _StickerWithPackQuery;
}
@riverpod
Future<SnSticker?> stickerPackSticker(
Ref ref,
StickerWithPackQuery? query,
) async {
if (query == null) return null;
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get(
'/sphere/stickers/${query.packId}/content/${query.id}',
);
if (resp.data == null) return null;
return SnSticker.fromJson(resp.data);
}
class NewStickersScreen extends StatelessWidget {
final String packId;
const NewStickersScreen({super.key, required this.packId});
@override
Widget build(BuildContext context) {
return EditStickersScreen(packId: packId, id: null);
}
}
class EditStickersScreen extends HookConsumerWidget {
final String packId; final String packId;
final String? id; final String? id;
const EditStickersScreen({super.key, required this.packId, required this.id}); const StickerForm({super.key, required this.packId, this.id});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -395,16 +356,16 @@ class EditStickersScreen extends HookConsumerWidget {
} }
} }
return AppScaffold( return Column(
appBar: AppBar(
title: Text(id == null ? 'createSticker' : 'editSticker').tr(),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
SizedBox( SizedBox(
height: 96, height: 80,
width: 96, width: 80,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)), borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container( child: Container(
@@ -419,45 +380,39 @@ class EditStickersScreen extends HookConsumerWidget {
), ),
), ),
), ),
const Gap(16), IconButton.filledTonal(
Form( onPressed: () {
key: formKey,
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: imageController,
decoration: InputDecoration(
labelText: 'stickerImage'.tr(),
border: const UnderlineInputBorder(),
suffix: InkWell(
onTap: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => CloudFilePicker(), builder:
(context) => CloudFilePicker(
allowedTypes: {UniversalFileType.image},
),
).then((value) { ).then((value) {
if (value == null) return; if (value == null) return;
image.value = value[0].id; image.value = value[0].id;
imageController.text = image.value!; imageController.text = image.value!;
}); });
}, },
borderRadius: BorderRadius.all(Radius.circular(8)), icon: const Icon(Symbols.cloud_upload),
child: const Icon(
Symbols.cloud_upload,
).padding(horizontal: 4),
), ),
],
), ),
readOnly: true, const Gap(16),
onTapOutside: Form(
(_) => FocusManager.instance.primaryFocus?.unfocus(), key: formKey,
), child: Column(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField( TextFormField(
controller: slugController, controller: slugController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'stickerSlug'.tr(), labelText: 'stickerSlug'.tr(),
helperText: 'stickerSlugHint'.tr(), helperText: 'stickerSlugHint'.tr(),
border: const UnderlineInputBorder(), border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
), ),
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
@@ -475,7 +430,28 @@ class EditStickersScreen extends HookConsumerWidget {
), ),
), ),
], ],
).padding(horizontal: 24, vertical: 24), ).padding(horizontal: 24, vertical: 16);
);
} }
} }
@freezed
sealed class StickerWithPackQuery with _$StickerWithPackQuery {
const factory StickerWithPackQuery({
required String packId,
required String id,
}) = _StickerWithPackQuery;
}
@riverpod
Future<SnSticker?> stickerPackSticker(
Ref ref,
StickerWithPackQuery? query,
) async {
if (query == null) return null;
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get(
'/sphere/stickers/${query.packId}/content/${query.id}',
);
if (resp.data == null) return null;
return SnSticker.fromJson(resp.data);
}

View File

@@ -1,14 +1,16 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/sticker.dart'; import 'package:island/models/sticker.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/screens/creators/stickers/pack_detail.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -22,29 +24,50 @@ class StickersScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final content = SliverStickerPacksList(pubName: pubName);
return AppScaffold( return AppScaffold(
isNoBackground: false,
appBar: AppBar( appBar: AppBar(
title: const Text('stickers').tr(), title: const Text('stickers').tr(),
actions: [ actions: [const Gap(8)],
IconButton( ),
floatingActionButton: FloatingActionButton(
onPressed: () { onPressed: () {
context showModalBottomSheet(
.pushNamed( context: context,
'creatorStickerPackNew', isScrollControlled: true,
pathParameters: {'name': pubName}, builder:
) (context) => SheetScaffold(
.then((value) { titleText: 'createStickerPack'.tr(),
child: StickerPackForm(pubName: pubName),
),
).then((value) {
if (value != null) { if (value != null) {
ref.invalidate(stickerPacksNotifierProvider(pubName)); ref.invalidate(stickerPacksNotifierProvider(pubName));
} }
}); });
}, },
icon: const Icon(Symbols.add_circle), child: const Icon(Symbols.add),
), ),
const Gap(8), body:
], isWideScreen(context)
? Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
), ),
body: SliverStickerPacksList(pubName: pubName), ),
margin: const EdgeInsets.only(top: 16),
child: content,
),
),
)
: content,
); );
} }
} }
@@ -70,13 +93,52 @@ class SliverStickerPacksList extends HookConsumerWidget {
final sticker = data.items[index]; final sticker = data.items[index];
return ListTile( return ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
title: Text(sticker.name), title: Text(sticker.name),
subtitle: Text(sticker.description), subtitle: Text(sticker.description),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () {
context.pushNamed( showModalBottomSheet(
'creatorStickerPackDetail', context: context,
pathParameters: {'name': pubName, 'packId': sticker.id}, isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: sticker.name,
actions: [
IconButton(
icon: const Icon(Symbols.add_circle),
onPressed: () {
final id = sticker.id;
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'createSticker'.tr(),
child: StickerForm(packId: id),
),
).then((value) {
if (value != null) {
ref.invalidate(
stickerPackContentProvider(id),
);
}
});
},
),
StickerPackActionMenu(
pubName: pubName,
packId: sticker.id,
iconShadow: Shadow(),
),
],
child: StickerPackDetailContent(
id: sticker.id,
pubName: pubName,
),
),
); );
}, },
); );
@@ -135,20 +197,10 @@ Future<SnStickerPack?> stickerPack(Ref ref, String? packId) async {
return SnStickerPack.fromJson(resp.data); return SnStickerPack.fromJson(resp.data);
} }
class NewStickerPacksScreen extends HookConsumerWidget { class StickerPackForm extends HookConsumerWidget {
final String pubName;
const NewStickerPacksScreen({super.key, required this.pubName});
@override
Widget build(BuildContext context, WidgetRef ref) {
return EditStickerPacksScreen(pubName: pubName);
}
}
class EditStickerPacksScreen extends HookConsumerWidget {
final String pubName; final String pubName;
final String? packId; final String? packId;
const EditStickerPacksScreen({super.key, required this.pubName, this.packId}); const StickerPackForm({super.key, required this.pubName, this.packId});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -187,7 +239,7 @@ class EditStickerPacksScreen extends HookConsumerWidget {
options: Options(method: packId == null ? 'POST' : 'PATCH'), options: Options(method: packId == null ? 'POST' : 'PATCH'),
); );
if (!context.mounted) return; if (!context.mounted) return;
context.pop(SnStickerPack.fromJson(resp.data)); Navigator.of(context).pop(SnStickerPack.fromJson(resp.data));
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
} finally { } finally {
@@ -195,24 +247,21 @@ class EditStickerPacksScreen extends HookConsumerWidget {
} }
} }
return AppScaffold( return Column(
appBar: AppBar(
title:
Text(packId == null ? 'createStickerPack' : 'editStickerPack').tr(),
),
body: Column(
children: [ children: [
Form( Form(
key: formKey, key: formKey,
child: Column( child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 16,
children: [ children: [
TextFormField( TextFormField(
controller: nameController, controller: nameController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'name'.tr(), labelText: 'name'.tr(),
border: const UnderlineInputBorder(), border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
@@ -227,7 +276,9 @@ class EditStickerPacksScreen extends HookConsumerWidget {
controller: descriptionController, controller: descriptionController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'description'.tr(), labelText: 'description'.tr(),
border: const UnderlineInputBorder(), border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
alignLabelWithHint: true, alignLabelWithHint: true,
), ),
minLines: 3, minLines: 3,
@@ -239,8 +290,10 @@ class EditStickerPacksScreen extends HookConsumerWidget {
controller: prefixController, controller: prefixController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'stickerPackPrefix'.tr(), labelText: 'stickerPackPrefix'.tr(),
border: const UnderlineInputBorder(), border: const OutlineInputBorder(
helperText: 'deleteStickerHint'.tr(), borderRadius: BorderRadius.all(Radius.circular(12)),
),
helperText: 'stickerPackPrefixHint'.tr(),
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
@@ -264,7 +317,6 @@ class EditStickerPacksScreen extends HookConsumerWidget {
), ),
), ),
], ],
).padding(horizontal: 24, vertical: 16), ).padding(horizontal: 24, vertical: 16);
);
} }
} }

View File

@@ -6,25 +6,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/webfeed.dart'; import 'package:island/models/webfeed.dart';
import 'package:island/pods/webfeed.dart'; import 'package:island/pods/webfeed.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
class WebFeedNewScreen extends StatelessWidget { class WebfeedForm extends HookConsumerWidget {
final String pubName;
const WebFeedNewScreen({super.key, required this.pubName});
@override
Widget build(BuildContext context) {
return WebFeedEditScreen(pubName: pubName, feedId: null);
}
}
class WebFeedEditScreen extends HookConsumerWidget {
final String pubName; final String pubName;
final String? feedId; final String? feedId;
const WebFeedEditScreen({super.key, required this.pubName, this.feedId}); const WebfeedForm({super.key, required this.pubName, this.feedId});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -113,14 +103,14 @@ class WebFeedEditScreen extends HookConsumerWidget {
); );
return feedAsync.when( return feedAsync.when(
loading: loading: () => const Center(child: CircularProgressIndicator()),
() => const AppScaffold(
body: Center(child: CircularProgressIndicator()),
),
error: error:
(error, stack) => AppScaffold( (error, _) => ResponseErrorWidget(
appBar: AppBar(title: const Text('Error')), error: error,
body: Center(child: Text('Error: $error')), onRetry:
() => ref.invalidate(
webFeedNotifierProvider((pubName: pubName, feedId: feedId)),
),
), ),
data: (feed) { data: (feed) {
// Initialize form fields if they're empty and we have a feed // Initialize form fields if they're empty and we have a feed
@@ -131,40 +121,8 @@ class WebFeedEditScreen extends HookConsumerWidget {
isScrapEnabled.value = feed.config.scrapPage; isScrapEnabled.value = feed.config.scrapPage;
} }
return _buildForm(
context,
formKey: formKey,
titleController: titleController,
urlController: urlController,
descriptionController: descriptionController,
isScrapEnabled: isScrapEnabled.value,
onScrapEnabledChanged: (value) => isScrapEnabled.value = value,
onSave: saveFeed,
onDelete: deleteFeed,
isLoading: isLoading.value,
ref: ref,
hasFeedId: feedId != null,
);
},
);
}
Widget _buildForm(
BuildContext context, {
required WidgetRef ref,
required GlobalKey<FormState> formKey,
required TextEditingController titleController,
required TextEditingController urlController,
required TextEditingController descriptionController,
required bool isScrapEnabled,
required ValueChanged<bool> onScrapEnabledChanged,
required VoidCallback onSave,
required VoidCallback onDelete,
required bool isLoading,
required bool hasFeedId,
}) {
final scrapNow = useCallback(() async { final scrapNow = useCallback(() async {
showLoadingModal(context); isLoading.value = true;
try { try {
await ref await ref
.read( .read(
@@ -181,30 +139,20 @@ class WebFeedEditScreen extends HookConsumerWidget {
} catch (e) { } catch (e) {
showErrorAlert(e); showErrorAlert(e);
} finally { } finally {
if (context.mounted) hideLoadingModal(context); isLoading.value = false;
} }
}, [pubName, feedId, ref, context]); }, [pubName, feedId, ref, context, isLoading]);
return AppScaffold( final formFields = Column(
appBar: AppBar(
title: Text(hasFeedId ? 'Edit Web Feed' : 'New Web Feed'),
actions: [
if (hasFeedId)
IconButton(
icon: const Icon(Symbols.delete_forever),
onPressed: isLoading ? null : onDelete,
),
const SizedBox(width: 8),
],
),
body: Form(
key: formKey,
child: SingleChildScrollView(
child: Column(
children: [ children: [
TextFormField( TextFormField(
controller: titleController, controller: titleController,
decoration: const InputDecoration(labelText: 'Title'), decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return 'Please enter a title'; return 'Please enter a title';
@@ -220,6 +168,9 @@ class WebFeedEditScreen extends HookConsumerWidget {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'URL', labelText: 'URL',
hintText: 'https://example.com/feed', hintText: 'https://example.com/feed',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
validator: (value) { validator: (value) {
@@ -241,6 +192,9 @@ class WebFeedEditScreen extends HookConsumerWidget {
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Description', labelText: 'Description',
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
), ),
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
@@ -260,30 +214,49 @@ class WebFeedEditScreen extends HookConsumerWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
value: isScrapEnabled, value: isScrapEnabled.value,
onChanged: onScrapEnabledChanged, onChanged: (value) => isScrapEnabled.value = value,
), ),
], ],
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (hasFeedId) ...[ if (feedId != null) ...[
FilledButton.tonalIcon( TextButton.icon(
onPressed: isLoading ? null : scrapNow, onPressed: isLoading.value ? null : scrapNow,
icon: const Icon(Symbols.refresh), icon: const Icon(Symbols.refresh),
label: const Text('Scrape Now'), label: const Text('Scrape Now'),
).alignment(Alignment.centerRight), ).alignment(Alignment.centerRight),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
FilledButton.icon( ],
onPressed: isLoading ? null : onSave, ).padding(all: 20);
final formWidget = Form(
key: formKey,
child: SingleChildScrollView(child: formFields),
);
final buttonsRow = Row(
children: [
if (feedId != null)
TextButton.icon(
onPressed: isLoading.value ? null : deleteFeed,
icon: const Icon(Symbols.delete_forever),
label: const Text('Delete Web Feed'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
const Spacer(),
TextButton.icon(
onPressed: isLoading.value ? null : saveFeed,
icon: const Icon(Symbols.save), icon: const Icon(Symbols.save),
label: Text('saveChanges').tr(), label: Text('saveChanges').tr(),
).alignment(Alignment.centerRight), ),
], ],
).padding(all: 20), ).padding(horizontal: 20, vertical: 12);
),
), return Column(children: [Expanded(child: formWidget), buttonsRow]);
},
); );
} }
} }

View File

@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:island/pods/webfeed.dart'; import 'package:island/pods/webfeed.dart';
import 'package:island/screens/creators/webfeed/webfeed_edit.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/empty_state.dart'; import 'package:island/widgets/empty_state.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class WebFeedListScreen extends ConsumerWidget { class WebFeedListScreen extends ConsumerWidget {
final String pubName; final String pubName;
@@ -17,13 +19,19 @@ class WebFeedListScreen extends ConsumerWidget {
final feedsAsync = ref.watch(webFeedListProvider(pubName)); final feedsAsync = ref.watch(webFeedListProvider(pubName));
return AppScaffold( return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: const Text('Web Feeds')), appBar: AppBar(title: const Text('Web Feeds')),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
onPressed: () { onPressed: () {
context.pushNamed( showModalBottomSheet(
'creatorFeedNew', context: context,
pathParameters: {'name': pubName}, isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'New Web Feed',
child: WebfeedForm(pubName: pubName, feedId: null),
),
); );
}, },
), ),
@@ -43,7 +51,9 @@ class WebFeedListScreen extends ConsumerWidget {
itemCount: feeds.length, itemCount: feeds.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final feed = feeds[index]; final feed = feeds[index];
return Card( return ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: Card(
margin: const EdgeInsets.symmetric( margin: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 12,
vertical: 4, vertical: 4,
@@ -66,13 +76,22 @@ class WebFeedListScreen extends ConsumerWidget {
), ),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () {
context.pushNamed( showModalBottomSheet(
'creatorFeedEdit', context: context,
pathParameters: {'name': pubName, 'feedId': feed.id}, isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'Edit Web Feed',
child: WebfeedForm(
pubName: pubName,
feedId: feed.id,
),
),
); );
}, },
), ),
); ),
).center();
}, },
), ),
); );

View File

@@ -6,10 +6,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/models/custom_app.dart'; import 'package:island/models/custom_app.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/screens/developers/apps.dart'; import 'package:island/screens/developers/apps.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
@@ -137,19 +137,13 @@ class EditAppScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
fileData: UniversalFile( fileData: UniversalFile(
data: result, data: result,
type: UniversalFileType.image, type: UniversalFileType.image,
), ),
atk: token,
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future; ).future;
if (cloudFile == null) { if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...'); throw ArgumentError('Failed to upload the file...');

View File

@@ -7,9 +7,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/models/bot.dart'; import 'package:island/models/bot.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
@@ -123,19 +123,13 @@ class EditBotScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
fileData: UniversalFile( fileData: UniversalFile(
data: result, data: result,
type: UniversalFileType.image, type: UniversalFileType.image,
), ),
atk: token, client: ref.read(apiClientProvider),
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future; ).future;
if (cloudFile == null) { if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...'); throw ArgumentError('Failed to upload the file...');

View File

@@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/dev_project.dart'; import 'package:island/models/dev_project.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/screens/developers/projects.dart'; import 'package:island/screens/developers/hub.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';

File diff suppressed because it is too large Load Diff

View File

@@ -168,5 +168,125 @@ final developersProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
typedef DevelopersRef = AutoDisposeFutureProviderRef<List<SnDeveloper>>; typedef DevelopersRef = AutoDisposeFutureProviderRef<List<SnDeveloper>>;
String _$devProjectsHash() => r'87fdcab47cd7d79ab019a5625617abeb1ffa1f39';
/// See also [devProjects].
@ProviderFor(devProjects)
const devProjectsProvider = DevProjectsFamily();
/// See also [devProjects].
class DevProjectsFamily extends Family<AsyncValue<List<DevProject>>> {
/// See also [devProjects].
const DevProjectsFamily();
/// See also [devProjects].
DevProjectsProvider call(String pubName) {
return DevProjectsProvider(pubName);
}
@override
DevProjectsProvider getProviderOverride(
covariant DevProjectsProvider provider,
) {
return call(provider.pubName);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'devProjectsProvider';
}
/// See also [devProjects].
class DevProjectsProvider extends AutoDisposeFutureProvider<List<DevProject>> {
/// See also [devProjects].
DevProjectsProvider(String pubName)
: this._internal(
(ref) => devProjects(ref as DevProjectsRef, pubName),
from: devProjectsProvider,
name: r'devProjectsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$devProjectsHash,
dependencies: DevProjectsFamily._dependencies,
allTransitiveDependencies: DevProjectsFamily._allTransitiveDependencies,
pubName: pubName,
);
DevProjectsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.pubName,
}) : super.internal();
final String pubName;
@override
Override overrideWith(
FutureOr<List<DevProject>> Function(DevProjectsRef provider) create,
) {
return ProviderOverride(
origin: this,
override: DevProjectsProvider._internal(
(ref) => create(ref as DevProjectsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
pubName: pubName,
),
);
}
@override
AutoDisposeFutureProviderElement<List<DevProject>> createElement() {
return _DevProjectsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is DevProjectsProvider && other.pubName == pubName;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, pubName.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin DevProjectsRef on AutoDisposeFutureProviderRef<List<DevProject>> {
/// The parameter `pubName` of this provider.
String get pubName;
}
class _DevProjectsProviderElement
extends AutoDisposeFutureProviderElement<List<DevProject>>
with DevProjectsRef {
_DevProjectsProviderElement(super.provider);
@override
String get pubName => (origin as DevProjectsProvider).pubName;
}
// ignore_for_file: type=lint // ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,92 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/screens/developers/apps.dart';
import 'package:island/screens/developers/bots.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
class ProjectDetailScreen extends HookConsumerWidget {
final String publisherName;
final String projectId;
const ProjectDetailScreen({
super.key,
required this.publisherName,
required this.projectId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(initialLength: 2);
return AppScaffold(
appBar: AppBar(
title: Text('projectDetails').tr(),
actions: [
IconButton(
icon: const Icon(Symbols.add),
onPressed: () {
// Get current tab index
final index = tabController.index;
switch (index) {
case 0:
context.pushNamed(
'developerAppNew',
pathParameters: {
'name': publisherName,
'projectId': projectId,
},
);
break;
case 1:
context.pushNamed(
'developerBotNew',
pathParameters: {
'name': publisherName,
'projectId': projectId,
},
);
break;
}
},
),
const Gap(8),
],
bottom: TabBar(
controller: tabController,
tabs: [
Tab(
child: Text(
'customApps'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'bots'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
],
),
),
body: TabBarView(
controller: tabController,
children: [
CustomAppsScreen(publisherName: publisherName, projectId: projectId),
BotsScreen(publisherName: publisherName, projectId: projectId),
],
),
);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/dev_project.dart';
import 'package:island/screens/developers/apps.dart';
import 'package:island/screens/developers/bots.dart';
import 'package:island/services/responsive.dart';
class ProjectDetailView extends HookConsumerWidget {
final String publisherName;
final DevProject project;
final VoidCallback onBackToHub;
const ProjectDetailView({
super.key,
required this.publisherName,
required this.project,
required this.onBackToHub,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(initialLength: 2);
final isWide = isWideScreen(context);
if (isWide) {
return Row(
spacing: 8,
children: [
Card(
margin: const EdgeInsets.only(left: 16, bottom: 16, top: 12),
child: Transform.translate(
offset: const Offset(0, -56),
child: NavigationRail(
extended: isWiderScreen(context),
scrollable: true,
labelType:
isWiderScreen(context)
? null
: NavigationRailLabelType.selected,
backgroundColor: Colors.transparent,
selectedIndex: tabController.index,
onDestinationSelected:
(index) => tabController.animateTo(index),
destinations: [
NavigationRailDestination(
icon: Icon(Icons.apps),
label: Text('customApps'.tr()),
),
NavigationRailDestination(
icon: Icon(Icons.smart_toy),
label: Text('bots'.tr()),
),
],
),
),
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
CustomAppsScreen(
publisherName: publisherName,
projectId: project.id,
),
BotsScreen(publisherName: publisherName, projectId: project.id),
],
),
),
],
);
} else {
return Column(
children: [
Container(
color: Theme.of(context).colorScheme.surface,
child: TabBar(
dividerColor: Colors.transparent,
controller: tabController,
tabs: [
Tab(
child: Text('customApps'.tr(), textAlign: TextAlign.center),
),
Tab(child: Text('bots'.tr(), textAlign: TextAlign.center)),
],
),
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
CustomAppsScreen(
publisherName: publisherName,
projectId: project.id,
),
BotsScreen(publisherName: publisherName, projectId: project.id),
],
),
),
],
);
}
}
}

View File

@@ -1,150 +0,0 @@
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/dev_project.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'projects.g.dart';
@riverpod
Future<List<DevProject>> devProjects(Ref ref, String pubName) async {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/develop/developers/$pubName/projects');
return (resp.data as List)
.map((e) => DevProject.fromJson(e))
.cast<DevProject>()
.toList();
}
class DevProjectsScreen extends HookConsumerWidget {
final String publisherName;
const DevProjectsScreen({super.key, required this.publisherName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final projects = ref.watch(devProjectsProvider(publisherName));
return AppScaffold(
appBar: AppBar(
title: Text('projects').tr(),
actions: [
IconButton(
icon: const Icon(Symbols.add),
onPressed: () {
context.pushNamed(
'developerProjectNew',
pathParameters: {'name': publisherName},
);
},
),
const Gap(8),
],
),
body: projects.when(
data: (data) {
if (data.isEmpty) {
return Center(child: Text('noProjects').tr());
}
return RefreshIndicator(
onRefresh:
() => ref.refresh(devProjectsProvider(publisherName).future),
child: ListView.builder(
padding: EdgeInsets.only(top: 4),
itemCount: data.length,
itemBuilder: (context, index) {
final project = data[index];
return Card(
margin: const EdgeInsets.all(8.0),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
contentPadding: EdgeInsets.only(left: 20, right: 12),
title: Text(project.name),
subtitle: Text(project.description ?? ''),
trailing: PopupMenuButton(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Symbols.edit),
const SizedBox(width: 12),
Text('edit').tr(),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const SizedBox(width: 12),
Text(
'delete',
style: TextStyle(color: Colors.red),
).tr(),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
context.pushNamed(
'developerProjectEdit',
pathParameters: {
'name': publisherName,
'id': project.id,
},
);
} else if (value == 'delete') {
showConfirmAlert(
'deleteProjectHint'.tr(),
'deleteProject'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.read(apiClientProvider);
client.delete(
'/develop/developers/$publisherName/projects/${project.id}',
);
ref.invalidate(
devProjectsProvider(publisherName),
);
}
});
}
},
),
onTap: () {
context.pushNamed(
'developerProjectDetail',
pathParameters: {
'name': publisherName,
'projectId': project.id,
},
);
},
),
);
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error:
(err, stack) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(devProjectsProvider(publisherName)),
),
),
);
}
}

View File

@@ -1,151 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'projects.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$devProjectsHash() => r'87fdcab47cd7d79ab019a5625617abeb1ffa1f39';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [devProjects].
@ProviderFor(devProjects)
const devProjectsProvider = DevProjectsFamily();
/// See also [devProjects].
class DevProjectsFamily extends Family<AsyncValue<List<DevProject>>> {
/// See also [devProjects].
const DevProjectsFamily();
/// See also [devProjects].
DevProjectsProvider call(String pubName) {
return DevProjectsProvider(pubName);
}
@override
DevProjectsProvider getProviderOverride(
covariant DevProjectsProvider provider,
) {
return call(provider.pubName);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'devProjectsProvider';
}
/// See also [devProjects].
class DevProjectsProvider extends AutoDisposeFutureProvider<List<DevProject>> {
/// See also [devProjects].
DevProjectsProvider(String pubName)
: this._internal(
(ref) => devProjects(ref as DevProjectsRef, pubName),
from: devProjectsProvider,
name: r'devProjectsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$devProjectsHash,
dependencies: DevProjectsFamily._dependencies,
allTransitiveDependencies: DevProjectsFamily._allTransitiveDependencies,
pubName: pubName,
);
DevProjectsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.pubName,
}) : super.internal();
final String pubName;
@override
Override overrideWith(
FutureOr<List<DevProject>> Function(DevProjectsRef provider) create,
) {
return ProviderOverride(
origin: this,
override: DevProjectsProvider._internal(
(ref) => create(ref as DevProjectsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
pubName: pubName,
),
);
}
@override
AutoDisposeFutureProviderElement<List<DevProject>> createElement() {
return _DevProjectsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is DevProjectsProvider && other.pubName == pubName;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, pubName.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin DevProjectsRef on AutoDisposeFutureProviderRef<List<DevProject>> {
/// The parameter `pubName` of this provider.
String get pubName;
}
class _DevProjectsProviderElement
extends AutoDisposeFutureProviderElement<List<DevProject>>
with DevProjectsRef {
_DevProjectsProviderElement(super.provider);
@override
String get pubName => (origin as DevProjectsProvider).pubName;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -13,13 +13,13 @@ import 'package:island/pods/event_calendar.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/notification.dart'; import 'package:island/screens/notification.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/fortune_graph.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/widgets/check_in.dart'; import 'package:island/widgets/check_in.dart';
import 'package:island/widgets/post/post_featured.dart'; import 'package:island/widgets/post/post_featured.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/screens/tabs.dart'; import 'package:island/widgets/post/compose_card.dart';
import 'package:island/widgets/post/compose_dialog.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -42,6 +42,7 @@ Widget notificationIndicatorWidget(
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)), borderRadius: BorderRadius.all(Radius.circular(8)),
), ),
minTileHeight: 48,
leading: const Icon(Symbols.notifications), leading: const Icon(Symbols.notifications),
title: Row( title: Row(
children: [ children: [
@@ -51,7 +52,6 @@ Widget notificationIndicatorWidget(
], ],
), ),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
minTileHeight: 40,
contentPadding: EdgeInsets.only(left: 16, right: 15), contentPadding: EdgeInsets.only(left: 16, right: 15),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed('notifications'); GoRouter.of(context).pushNamed('notifications');
@@ -99,10 +99,6 @@ class ExploreScreen extends HookConsumerWidget {
final events = ref.watch(eventCalendarProvider(query.value)); final events = ref.watch(eventCalendarProvider(query.value));
final selectedDay = useState(now); final selectedDay = useState(now);
// Function to handle day selection for synchronizing between widgets
void onDaySelected(DateTime day) {
selectedDay.value = day;
}
final user = ref.watch(userInfoProvider); final user = ref.watch(userInfoProvider);
@@ -110,12 +106,10 @@ class ExploreScreen extends HookConsumerWidget {
notificationUnreadCountNotifierProvider, notificationUnreadCountNotifierProvider,
); );
return AppScaffold( final isWide = isWideScreen(context);
isNoBackground: false,
appBar: AppBar( final filterBar = Card(
toolbarHeight: 0, margin: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
@@ -128,37 +122,19 @@ class ExploreScreen extends HookConsumerWidget {
Tab( Tab(
icon: Tooltip( icon: Tooltip(
message: 'explore'.tr(), message: 'explore'.tr(),
child: Icon( child: Icon(Symbols.explore),
Symbols.explore,
color:
Theme.of(
context,
).appBarTheme.foregroundColor!,
),
), ),
), ),
Tab( Tab(
icon: Tooltip( icon: Tooltip(
message: 'exploreFilterSubscriptions'.tr(), message: 'exploreFilterSubscriptions'.tr(),
child: Icon( child: Icon(Symbols.subscriptions),
Symbols.subscriptions,
color:
Theme.of(
context,
).appBarTheme.foregroundColor!,
),
), ),
), ),
Tab( Tab(
icon: Tooltip( icon: Tooltip(
message: 'exploreFilterFriends'.tr(), message: 'exploreFilterFriends'.tr(),
child: Icon( child: Icon(Symbols.people),
Symbols.people,
color:
Theme.of(
context,
).appBarTheme.foregroundColor!,
),
), ),
), ),
], ],
@@ -168,10 +144,7 @@ class ExploreScreen extends HookConsumerWidget {
onPressed: () { onPressed: () {
context.pushNamed('articles'); context.pushNamed('articles');
}, },
icon: Icon( icon: Icon(Symbols.auto_stories),
Symbols.auto_stories,
color: Theme.of(context).appBarTheme.foregroundColor!,
),
tooltip: 'webArticlesStand'.tr(), tooltip: 'webArticlesStand'.tr(),
), ),
PopupMenuButton( PopupMenuButton(
@@ -226,58 +199,113 @@ class ExploreScreen extends HookConsumerWidget {
}, },
), ),
], ],
icon: Icon( icon: Icon(Symbols.action_key),
Symbols.action_key,
color: Theme.of(context).appBarTheme.foregroundColor!,
),
tooltip: 'search'.tr(), tooltip: 'search'.tr(),
), ),
], ],
) ).padding(horizontal: 8),
.padding(horizontal: 8) );
.border(
bottom: 1 / MediaQuery.of(context).devicePixelRatio, return AppScaffold(
color: Theme.of(context).dividerColor, isNoBackground: false,
), floatingActionButton:
), isWide
), ? null
floatingActionButton: InkWell( : InkWell(
onLongPress: () { onLongPress: () async {
context.pushNamed('postCompose', queryParameters: {'type': '1'}).then( final result = await PostComposeDialog.show(context);
(value) { if (result != null) {
if (value != null) {
activitiesNotifier.forceRefresh(); activitiesNotifier.forceRefresh();
} }
}, },
);
},
child: FloatingActionButton( child: FloatingActionButton(
heroTag: Key("explore-page-fab"), heroTag: Key("explore-page-fab"),
onPressed: () { onPressed: () async {
context.pushNamed('postCompose').then((value) { final result = await PostComposeDialog.show(context);
if (value != null) { if (result != null) {
activitiesNotifier.forceRefresh(); activitiesNotifier.forceRefresh();
} }
});
}, },
child: const Icon(Symbols.edit), child: const Icon(Symbols.edit),
), ),
), ),
floatingActionButtonLocation: TabbedFabLocation(context), body:
body: Builder( isWide
builder: (context) { ? _buildWideBody(
final isWide = isWideScreen(context);
final bodyView = _buildActivityList(
context, context,
ref, ref,
filterBar,
user,
notificationCount,
query,
events,
selectedDay,
currentFilter.value, currentFilter.value,
)
: _buildNarrowBody(context, ref, filterBar, currentFilter.value),
);
}
Widget _buildActivityList(
BuildContext context,
WidgetRef ref,
String? filter,
) {
final activitiesNotifier = ref.watch(
activityListNotifierProvider(filter).notifier,
);
final isWide = isWideScreen(context);
return PagingHelperSliverView(
provider: activityListNotifierProvider(filter),
futureRefreshable: activityListNotifierProvider(filter).future,
notifierRefreshable: activityListNotifierProvider(filter).notifier,
contentBuilder:
(data, widgetCount, endItemView) => _ActivityListView(
data: data,
widgetCount: widgetCount,
endItemView: endItemView,
activitiesNotifier: activitiesNotifier,
isWide: isWide,
),
);
}
Widget _buildWideBody(
BuildContext context,
WidgetRef ref,
Widget filterBar,
AsyncValue<dynamic> user,
AsyncValue<int?> notificationCount,
ValueNotifier<EventCalendarQuery> query,
AsyncValue<List<dynamic>> events,
ValueNotifier<DateTime> selectedDay,
String? currentFilter,
) {
final bodyView = _buildActivityList(context, ref, currentFilter);
final activitiesNotifier = ref.watch(
activityListNotifierProvider(currentFilter).notifier,
); );
if (isWide) {
return Row( return Row(
spacing: 12,
children: [ children: [
Flexible(flex: 3, child: bodyView.padding(left: 8)), Flexible(
flex: 3,
child: ExtendedRefreshIndicator(
onRefresh: () => Future.sync(activitiesNotifier.forceRefresh),
child: CustomScrollView(
slivers: [
const SliverGap(12),
SliverToBoxAdapter(child: filterBar),
const SliverGap(8),
bodyView,
],
),
),
),
if (user.value != null) if (user.value != null)
Flexible( Flexible(
flex: 2, flex: 2,
@@ -285,17 +313,12 @@ class ExploreScreen extends HookConsumerWidget {
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
spacing: 8,
children: [ children: [
CheckInWidget( CheckInWidget(
margin: EdgeInsets.only( margin: EdgeInsets.only(top: 12),
left: 8,
right: 12,
top: 16,
),
onChecked: () { onChecked: () {
ref.invalidate( ref.invalidate(eventCalendarProvider(query.value));
eventCalendarProvider(query.value),
);
}, },
), ),
if (notificationCount.value != null && if (notificationCount.value != null &&
@@ -303,26 +326,13 @@ class ExploreScreen extends HookConsumerWidget {
notificationIndicatorWidget( notificationIndicatorWidget(
context, context,
count: notificationCount.value ?? 0, count: notificationCount.value ?? 0,
margin: EdgeInsets.only( margin: EdgeInsets.zero,
left: 8,
right: 12,
top: 8,
), ),
), PostFeaturedList(),
PostFeaturedList().padding( PostComposeCard(
left: 8, onSubmit: (post) {
right: 12, activitiesNotifier.forceRefresh();
top: 8, },
),
FortuneGraphWidget(
margin: EdgeInsets.only(
left: 8,
right: 12,
top: 8,
),
events: events,
constrainWidth: true,
onPointSelected: onDaySelected,
), ),
], ],
), ),
@@ -349,43 +359,65 @@ class ExploreScreen extends HookConsumerWidget {
).padding(horizontal: 36, vertical: 16), ).padding(horizontal: 36, vertical: 16),
), ),
], ],
); ).padding(horizontal: 12);
} }
return bodyView; Widget _buildNarrowBody(
},
),
);
}
Widget _buildActivityList(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
String? filter, Widget filterBar,
String? currentFilter,
) { ) {
final activitiesNotifier = ref.watch( final user = ref.watch(userInfoProvider);
activityListNotifierProvider(filter).notifier, final notificationCount = ref.watch(
notificationUnreadCountNotifierProvider,
); );
final isWide = isWideScreen(context); final activitiesNotifier = ref.watch(
activityListNotifierProvider(currentFilter).notifier,
);
return ExtendedRefreshIndicator( final bodyView = _buildActivityList(context, ref, currentFilter);
return Column(
spacing: 8,
children: [
filterBar.padding(horizontal: 8, top: 8),
Expanded(
child: ExtendedRefreshIndicator(
onRefresh: () => Future.sync(activitiesNotifier.forceRefresh), onRefresh: () => Future.sync(activitiesNotifier.forceRefresh),
child: PagingHelperView( child: ClipRRect(
provider: activityListNotifierProvider(filter), borderRadius: const BorderRadius.all(Radius.circular(8)),
futureRefreshable: activityListNotifierProvider(filter).future, child: CustomScrollView(
notifierRefreshable: activityListNotifierProvider(filter).notifier, slivers: [
contentBuilder: if (user.value != null)
(data, widgetCount, endItemView) => Center( SliverToBoxAdapter(
child: _ActivityListView( child: CheckInWidget(
data: data, margin: const EdgeInsets.only(bottom: 8),
widgetCount: widgetCount,
endItemView: endItemView,
activitiesNotifier: activitiesNotifier,
contentOnly: isWide || filter != null,
), ),
), ),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: PostFeaturedList(),
), ),
),
if (notificationCount.value != null &&
notificationCount.value! > 0)
SliverToBoxAdapter(
child: notificationIndicatorWidget(
context,
count: notificationCount.value ?? 0,
margin: const EdgeInsets.only(bottom: 8),
),
),
bodyView,
],
),
).padding(horizontal: 8),
),
),
],
); );
} }
} }
@@ -464,7 +496,7 @@ class _DiscoveryActivityItem extends StatelessWidget {
}; };
return Card( return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -505,48 +537,22 @@ class _ActivityListView extends HookConsumerWidget {
final CursorPagingData<SnActivity> data; final CursorPagingData<SnActivity> data;
final int widgetCount; final int widgetCount;
final Widget endItemView; final Widget endItemView;
final bool contentOnly;
final ActivityListNotifier activitiesNotifier; final ActivityListNotifier activitiesNotifier;
final bool isWide;
const _ActivityListView({ const _ActivityListView({
required this.data, required this.data,
required this.widgetCount, required this.widgetCount,
required this.endItemView, required this.endItemView,
required this.activitiesNotifier, required this.activitiesNotifier,
this.contentOnly = false, required this.isWide,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userInfoProvider); return SliverList.separated(
final notificationCount = ref.watch(
notificationUnreadCountNotifierProvider,
);
return CustomScrollView(
slivers: [
SliverGap(12),
if (user.value != null && !contentOnly)
SliverToBoxAdapter(
child: CheckInWidget(
margin: EdgeInsets.only(left: 8, right: 8, bottom: 4),
),
),
if (!contentOnly)
SliverToBoxAdapter(
child: PostFeaturedList().padding(horizontal: 8, bottom: 4, top: 4),
),
if (!contentOnly && (notificationCount.value ?? 0) > 0)
SliverToBoxAdapter(
child: notificationIndicatorWidget(
context,
count: notificationCount.value ?? 0,
margin: EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 4),
),
),
SliverList.builder(
itemCount: widgetCount, itemCount: widgetCount,
separatorBuilder: (_, _) => const Gap(8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == widgetCount - 1) { if (index == widgetCount - 1) {
return endItemView; return endItemView;
@@ -574,10 +580,7 @@ class _ActivityListView extends HookConsumerWidget {
); );
}, },
); );
itemWidget = Card( itemWidget = Card(margin: EdgeInsets.zero, child: itemWidget);
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: itemWidget,
);
break; break;
case 'discovery': case 'discovery':
itemWidget = _DiscoveryActivityItem(data: item.data!); itemWidget = _DiscoveryActivityItem(data: item.data!);
@@ -588,9 +591,6 @@ class _ActivityListView extends HookConsumerWidget {
return itemWidget; return itemWidget;
}, },
),
SliverGap(getTabbedPadding(context).bottom),
],
); );
} }
} }

View File

@@ -6,7 +6,7 @@ part of 'file_list.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$billingUsageHash() => r'270ec8499378ee0c038aa44ad1c2e3ad9025740a'; String _$billingUsageHash() => r'58d8bc774868d60781574c85d6b25869a79c57aa';
/// See also [billingUsage]. /// See also [billingUsage].
@ProviderFor(billingUsage) @ProviderFor(billingUsage)
@@ -25,7 +25,7 @@ final billingUsageProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
typedef BillingUsageRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>; typedef BillingUsageRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
String _$billingQuotaHash() => r'0696b500fa8bb1270641bcacf262be58caff9b38'; String _$billingQuotaHash() => r'4ec5d728e439015800abb2d0d673b5a7329cc654';
/// See also [billingQuota]. /// See also [billingQuota].
@ProviderFor(billingQuota) @ProviderFor(billingQuota)
@@ -45,7 +45,7 @@ final billingQuotaProvider =
// ignore: unused_element // ignore: unused_element
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>; typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
String _$cloudFileListNotifierHash() => String _$cloudFileListNotifierHash() =>
r'e2c8a076a9e635c7b43a87d00f78775427ba6334'; r'22c45a8ea23147a3835ba870ad2f0bb833f853ea';
/// See also [CloudFileListNotifier]. /// See also [CloudFileListNotifier].
@ProviderFor(CloudFileListNotifier) @ProviderFor(CloudFileListNotifier)

View File

@@ -1,5 +1,3 @@
import 'dart:developer';
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';
@@ -7,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/models/poll.dart'; import 'package:island/models/poll.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@@ -92,10 +91,10 @@ class PollEditor extends Notifier<PollEditorState> {
questions: poll.questions, questions: poll.questions,
); );
} on DioException catch (e) { } on DioException catch (e) {
log('Failed to load poll $id: ${e.message}'); talker.error('Failed to load poll $id: ${e.message}');
// Keep state with id set; UI may handle error display. // Keep state with id set; UI may handle error display.
} catch (e) { } catch (e) {
log('Unexpected error loading poll $id: $e'); talker.error('Unexpected error loading poll $id: $e');
} finally { } finally {
if (context.mounted) hideLoadingModal(context); if (context.mounted) hideLoadingModal(context);
} }
@@ -417,6 +416,7 @@ class PollEditorScreen extends ConsumerWidget {
} }
return AppScaffold( return AppScaffold(
isNoBackground: false,
appBar: AppBar( appBar: AppBar(
title: Text(model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr()), title: Text(model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr()),
actions: [ actions: [
@@ -434,7 +434,10 @@ class PollEditorScreen extends ConsumerWidget {
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
child: Form( child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child:
Form(
key: ValueKey(model.id), key: ValueKey(model.id),
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -442,16 +445,19 @@ class PollEditorScreen extends ConsumerWidget {
TextFormField( TextFormField(
initialValue: model.title ?? '', initialValue: model.title ?? '',
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'title'.tr(), labelText: 'postTitle'.tr(),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(16)), borderRadius: BorderRadius.all(
Radius.circular(16),
),
), ),
), ),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
maxLength: 256, maxLength: 256,
onChanged: notifier.setTitle, onChanged: notifier.setTitle,
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
validator: (v) { validator: (v) {
if (v == null || v.trim().isEmpty) { if (v == null || v.trim().isEmpty) {
return 'pollTitleRequired'.tr(); return 'pollTitleRequired'.tr();
@@ -466,14 +472,17 @@ class PollEditorScreen extends ConsumerWidget {
labelText: 'description'.tr(), labelText: 'description'.tr(),
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(16)), borderRadius: BorderRadius.all(
Radius.circular(16),
),
), ),
), ),
maxLines: 3, maxLines: 3,
maxLength: 4096, maxLength: 4096,
onChanged: notifier.setDescription, onChanged: notifier.setDescription,
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(12), const Gap(12),
_EndDatePicker( _EndDatePicker(
@@ -505,7 +514,8 @@ class PollEditorScreen extends ConsumerWidget {
.map( .map(
(t) => MenuItemButton( (t) => MenuItemButton(
leadingIcon: Icon(_iconForType(t)), leadingIcon: Icon(_iconForType(t)),
onPressed: () => notifier.addQuestion(t), onPressed:
() => notifier.addQuestion(t),
child: Text(_labelForType(t)), child: Text(_labelForType(t)),
), ),
) )
@@ -553,13 +563,17 @@ class PollEditorScreen extends ConsumerWidget {
question: q, question: q,
onMoveUp: onMoveUp:
index > 0 index > 0
? () => notifier.moveQuestionUp(index) ? () =>
notifier.moveQuestionUp(index)
: null, : null,
onMoveDown: onMoveDown:
index < model.questions.length - 1 index < model.questions.length - 1
? () => notifier.moveQuestionDown(index) ? () => notifier.moveQuestionDown(
index,
)
: null, : null,
onDelete: () => notifier.removeQuestion(index), onDelete:
() => notifier.removeQuestion(index),
), ),
const Divider(height: 1), const Divider(height: 1),
Padding( Padding(
@@ -577,11 +591,15 @@ class PollEditorScreen extends ConsumerWidget {
const Gap(96), const Gap(96),
], ],
), ),
).center(),
), ),
), ),
Material( Material(
elevation: 2, elevation: 2,
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
child:
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: Row( child: Row(
children: [ children: [
OutlinedButton.icon( OutlinedButton.icon(
@@ -597,7 +615,9 @@ class PollEditorScreen extends ConsumerWidget {
_submitPoll(context, ref); _submitPoll(context, ref);
}, },
icon: const Icon(Icons.cloud_upload_outlined), icon: const Icon(Icons.cloud_upload_outlined),
label: Text(model.id == null ? 'create'.tr() : 'update'.tr()), label: Text(
model.id == null ? 'create'.tr() : 'update'.tr(),
),
), ),
], ],
).padding( ).padding(
@@ -605,6 +625,7 @@ class PollEditorScreen extends ConsumerWidget {
top: 16, top: 16,
bottom: MediaQuery.of(context).padding.bottom + 16, bottom: MediaQuery.of(context).padding.bottom + 16,
), ),
).center(),
), ),
], ],
), ),

View File

@@ -6,21 +6,20 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/screens/creators/publishers.dart'; import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/posts/compose_article.dart'; import 'package:island/screens/posts/compose_article.dart';
import 'package:island/services/responsive.dart'; import 'package:island/screens/posts/post_detail.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/attachment_uploader.dart';
import 'package:island/widgets/content/attachment_preview.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/compose_attachments.dart';
import 'package:island/widgets/post/compose_form_fields.dart';
import 'package:island/widgets/post/compose_info_banner.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_toolbar.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/publishers_modal.dart'; import 'package:island/widgets/post/publishers_modal.dart';
import 'package:island/screens/posts/post_detail.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/services/compose_storage_db.dart';
// DraftManagerSheet is now imported through compose_toolbar.dart
import 'package:island/widgets/post/compose_toolbar.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -92,9 +91,6 @@ class PostComposeScreen extends HookConsumerWidget {
return ArticleComposeScreen(originalPost: originalPost); return ArticleComposeScreen(originalPost: originalPost);
} }
// Otherwise, continue with regular post compose
final theme = Theme.of(context);
// When editing, preserve the original replied/forwarded post references // When editing, preserve the original replied/forwarded post references
final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost; final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost;
final effectiveForwardedPost = forwardedPost ?? originalPost?.forwardedPost; final effectiveForwardedPost = forwardedPost ?? originalPost?.forwardedPost;
@@ -210,109 +206,6 @@ class PostComposeScreen extends HookConsumerWidget {
); );
} }
Widget buildWideAttachmentGrid() {
return GridView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: state.attachments.value.length,
itemBuilder: (context, idx) {
final progressMap = state.attachmentProgress.value;
return AttachmentPreview(
item: state.attachments.value[idx],
progress: progressMap[idx],
onRequestUpload: () async {
final config = await showModalBottomSheet<AttachmentUploadConfig>(
context: context,
isScrollControlled: true,
builder:
(context) => AttachmentUploaderSheet(
ref: ref,
state: state,
index: idx,
),
);
if (config != null) {
await ComposeLogic.uploadAttachment(
ref,
state,
idx,
poolId: config.poolId,
);
}
},
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
onUpdate:
(value) => ComposeLogic.updateAttachment(state, value, idx),
onMove: (delta) {
state.attachments.value = ComposeLogic.moveAttachment(
state.attachments.value,
idx,
delta,
);
},
);
},
);
}
Widget buildNarrowAttachmentList() {
return Column(
children: [
for (var idx = 0; idx < state.attachments.value.length; idx++)
Container(
margin: const EdgeInsets.only(bottom: 8),
child: () {
final progressMap = state.attachmentProgress.value;
return AttachmentPreview(
item: state.attachments.value[idx],
progress: progressMap[idx],
onRequestUpload: () async {
final config =
await showModalBottomSheet<AttachmentUploadConfig>(
context: context,
isScrollControlled: true,
builder:
(context) => AttachmentUploaderSheet(
ref: ref,
state: state,
index: idx,
),
);
if (config != null) {
await ComposeLogic.uploadAttachment(
ref,
state,
idx,
poolId: config.poolId,
);
}
},
onDelete:
() => ComposeLogic.deleteAttachment(ref, state, idx),
onUpdate:
(value) =>
ComposeLogic.updateAttachment(state, value, idx),
onMove: (delta) {
state.attachments.value = ComposeLogic.moveAttachment(
state.attachments.value,
idx,
delta,
);
},
);
}(),
),
],
);
}
// Build UI
return PopScope( return PopScope(
onPopInvoked: (_) { onPopInvoked: (_) {
if (originalPost == null) { if (originalPost == null) {
@@ -362,7 +255,57 @@ class PostComposeScreen extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Reply/Forward info section // Reply/Forward info section
_buildInfoBanner(context), ComposeInfoBanner(
originalPost: originalPost,
replyingTo: repliedPost,
forwardingTo: forwardedPost,
onReferencePostTap: (context, post) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder:
(context) => DraggableScrollableSheet(
initialChildSize: 0.7,
maxChildSize: 0.9,
minChildSize: 0.5,
builder:
(context, scrollController) => Container(
decoration: BoxDecoration(
color:
Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(
vertical: 8,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.outline,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.all(16),
child: PostItem(item: post),
),
),
],
),
),
),
);
},
),
// Main content area // Main content area
Expanded( Expanded(
@@ -414,78 +357,27 @@ class PostComposeScreen extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TextField( ComposeFormFields(
controller: state.titleController, state: state,
decoration: InputDecoration( showPublisherAvatar: false,
hintText: 'postTitle'.tr(), onPublisherTap: () {
border: InputBorder.none, showModalBottomSheet(
isCollapsed: true, isScrollControlled: true,
contentPadding: const EdgeInsets.symmetric( context: context,
vertical: 8, builder:
horizontal: 8, (context) => const PublisherModal(),
), ).then((value) {
), if (value != null) {
style: theme.textTheme.titleMedium, state.currentPublisher.value = value;
onTapOutside: }
(_) => });
FocusManager.instance.primaryFocus
?.unfocus(),
),
TextField(
controller: state.descriptionController,
decoration: InputDecoration(
hintText: 'postDescription'.tr(),
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.fromLTRB(
8,
4,
8,
12,
),
),
style: theme.textTheme.bodyMedium,
minLines: 1,
maxLines: 3,
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
// Content field with borderless design
TextField(
controller: state.contentController,
style: theme.textTheme.bodyMedium,
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'postContent'.tr(),
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 8,
),
),
maxLines: null,
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
const Gap(8),
// Attachments preview
if (state.attachments.value.isNotEmpty)
LayoutBuilder(
builder: (context, constraints) {
final isWide = isWideScreen(context);
return isWide
? buildWideAttachmentGrid()
: buildNarrowAttachmentList();
}, },
) ),
else const Gap(8),
const SizedBox.shrink(), ComposeAttachments(
state: state,
isCompact: false,
),
], ],
), ),
), ),
@@ -503,262 +395,4 @@ class PostComposeScreen extends HookConsumerWidget {
), ),
); );
} }
Widget _buildInfoBanner(BuildContext context) {
// When editing, preserve the original replied/forwarded post references
final effectiveRepliedPost =
initialState?.replyingTo ?? originalPost?.repliedPost;
final effectiveForwardedPost =
initialState?.forwardingTo ?? originalPost?.forwardedPost;
// Show editing banner when editing a post
if (originalPost != null) {
return Column(
children: [
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.primaryContainer,
child: Row(
children: [
Icon(
Symbols.edit,
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const Gap(8),
Text(
'postEditing'.tr(),
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
).padding(horizontal: 16, vertical: 8),
),
// Show reply/forward banners below editing banner if they exist
if (effectiveRepliedPost != null)
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.reply, size: 16),
const Gap(4),
Text(
'postReplyingTo'.tr(),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const Gap(8),
_buildCompactReferencePost(context, effectiveRepliedPost),
],
).padding(all: 16),
),
if (effectiveForwardedPost != null)
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.forward, size: 16),
const Gap(4),
Text(
'postForwardingTo'.tr(),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const Gap(8),
_buildCompactReferencePost(context, effectiveForwardedPost),
],
).padding(all: 16),
),
],
);
}
// Show banner for replies (including when editing a reply)
if (effectiveRepliedPost != null) {
return Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.reply, size: 16),
const Gap(4),
Text(
'postReplyingTo'.tr(),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const Gap(8),
_buildCompactReferencePost(context, effectiveRepliedPost),
],
).padding(all: 16),
);
}
// Show banner for forwards (including when editing a forward)
if (effectiveForwardedPost != null) {
return Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.forward, size: 16),
const Gap(4),
Text(
'postForwardingTo'.tr(),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const Gap(8),
_buildCompactReferencePost(context, effectiveForwardedPost),
],
).padding(all: 16),
);
}
return const SizedBox.shrink();
}
Widget _buildCompactReferencePost(BuildContext context, SnPost post) {
return GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder:
(context) => DraggableScrollableSheet(
initialChildSize: 0.7,
maxChildSize: 0.9,
minChildSize: 0.5,
builder:
(context, scrollController) => Container(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.outline,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.all(16),
child: PostItem(item: post),
),
),
],
),
),
),
);
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
),
),
child: Row(
children: [
ProfilePictureWidget(
fileId: post.publisher.picture?.id,
radius: 16,
),
const Gap(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.publisher.nick,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
if (post.title?.isNotEmpty ?? false)
Text(
post.title!,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (post.content?.isNotEmpty ?? false)
Text(
post.content!,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (post.attachments.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.attach_file,
size: 12,
color: Theme.of(context).colorScheme.secondary,
),
const Gap(4),
Text(
'postHasAttachments'.plural(post.attachments.length),
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontSize: 11,
),
),
],
),
],
),
),
Icon(
Symbols.open_in_full,
size: 16,
color: Theme.of(context).colorScheme.outline,
),
],
),
),
);
}
} }

View File

@@ -7,21 +7,20 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/creators/publishers.dart'; import 'package:island/screens/posts/post_detail.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/attachment_uploader.dart'; import 'package:island/widgets/attachment_uploader.dart';
import 'package:island/screens/posts/post_detail.dart';
import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/content/attachment_preview.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/post/compose_form_fields.dart';
import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart'; import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/post/compose_toolbar.dart'; import 'package:island/widgets/post/compose_toolbar.dart';
import 'package:island/widgets/post/publishers_modal.dart'; import 'package:island/widgets/post/publishers_modal.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -233,64 +232,20 @@ class ArticleComposeScreen extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TextField( ComposeFormFields(
controller: state.titleController, state: state,
decoration: InputDecoration( showPublisherAvatar: false,
hintText: 'postTitle'.tr(), onPublisherTap: () {
border: InputBorder.none, showModalBottomSheet(
isCollapsed: true, isScrollControlled: true,
contentPadding: const EdgeInsets.symmetric( context: context,
vertical: 8, builder: (context) => const PublisherModal(),
horizontal: 8, ).then((value) {
), if (value != null) {
), state.currentPublisher.value = value;
style: theme.textTheme.titleMedium, }
onTapOutside: });
(_) => FocusManager.instance.primaryFocus?.unfocus(), },
),
TextField(
controller: state.descriptionController,
decoration: InputDecoration(
hintText: 'postDescription'.tr(),
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
),
style: theme.textTheme.bodyMedium,
minLines: 1,
maxLines: 3,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
Expanded(
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent:
(event) => ComposeLogic.handleKeyPress(
event,
state,
ref,
context,
originalPost: originalPost,
),
child: TextField(
controller: state.contentController,
style: theme.textTheme.bodyMedium,
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'postContent'.tr(),
contentPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 8,
),
),
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
), ),
// Attachments preview // Attachments preview

View File

@@ -12,6 +12,7 @@ import 'package:island/screens/posts/compose.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/widgets/post/post_award_sheet.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_award_history_sheet.dart'; import 'package:island/widgets/post/post_award_history_sheet.dart';
import 'package:island/widgets/post/post_pin_sheet.dart'; import 'package:island/widgets/post/post_pin_sheet.dart';
@@ -273,7 +274,14 @@ class PostActionButtons extends HookConsumerWidget {
actions.add( actions.add(
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: () {}, onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => PostAwardSheet(post: post),
);
},
onLongPress: () { onLongPress: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,

View File

@@ -0,0 +1,276 @@
import 'package:croppy/croppy.dart' show CropAspectRatio;
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart';
import 'package:island/models/realm.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class NewRealmScreen extends StatelessWidget {
const NewRealmScreen({super.key});
@override
Widget build(BuildContext context) {
return const EditRealmScreen();
}
}
class EditRealmScreen extends HookConsumerWidget {
final String? slug;
const EditRealmScreen({super.key, this.slug});
@override
Widget build(BuildContext context, WidgetRef ref) {
final submitting = useState(false);
final picture = useState<SnCloudFile?>(null);
final background = useState<SnCloudFile?>(null);
final isPublic = useState(true);
final isCommunity = useState(false);
final slugController = useTextEditingController();
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
final realm = ref.watch(realmProvider(slug));
useEffect(() {
if (realm.value != null) {
picture.value = realm.value!.picture;
background.value = realm.value!.background;
slugController.text = realm.value!.slug;
nameController.text = realm.value!.name;
descriptionController.text = realm.value!.description;
isPublic.value = realm.value!.isPublic;
isCommunity.value = realm.value!.isCommunity;
}
return null;
}, [realm]);
void setPicture(String position) async {
showLoadingModal(context);
var result = await ref
.read(imagePickerProvider)
.pickImage(source: ImageSource.gallery);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
hideLoadingModal(context);
result = await cropImage(
context,
image: result,
allowedAspectRatios: [
if (position == 'background')
const CropAspectRatio(height: 7, width: 16)
else
const CropAspectRatio(height: 1, width: 1),
],
);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
showLoadingModal(context);
submitting.value = true;
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,
),
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
switch (position) {
case 'picture':
picture.value = cloudFile;
case 'background':
background.value = cloudFile;
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
submitting.value = false;
}
}
Future<void> performAction() async {
if (!formKey.currentState!.validate()) return;
submitting.value = true;
try {
final client = ref.watch(apiClientProvider);
final resp = await client.request(
'/sphere${slug == null ? '/realms' : '/realms/$slug'}',
data: {
'slug': slugController.text,
'name': nameController.text,
'description': descriptionController.text,
'background_id': background.value?.id,
'picture_id': picture.value?.id,
'is_public': isPublic.value,
'is_community': isCommunity.value,
},
options: Options(method: slug == null ? 'POST' : 'PATCH'),
);
if (context.mounted) {
context.pop(SnRealm.fromJson(resp.data));
}
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(slug == null ? 'createRealm'.tr() : 'editRealm'.tr()),
leading: const PageBackButton(),
),
body: SingleChildScrollView(
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
GestureDetector(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child:
background.value != null
? CloudFileWidget(
item: background.value!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
setPicture('background');
},
),
Positioned(
left: 20,
bottom: -32,
child: GestureDetector(
child: ProfilePictureWidget(
fileId: picture.value?.id,
radius: 40,
fallbackIcon: Symbols.group,
),
onTap: () {
setPicture('picture');
},
),
),
],
),
).padding(bottom: 32),
Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: slugController,
decoration: InputDecoration(
labelText: 'slug'.tr(),
helperText: 'slugHint'.tr(),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: nameController,
decoration: InputDecoration(labelText: 'name'.tr()),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: InputDecoration(
labelText: 'description'.tr(),
alignLabelWithHint: true,
),
minLines: 3,
maxLines: null,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
CheckboxListTile(
secondary: const Icon(Symbols.public),
title: Text('publicRealm').tr(),
subtitle: Text('publicRealmDescription').tr(),
value: isPublic.value,
onChanged: (value) {
isPublic.value = value ?? true;
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
CheckboxListTile(
secondary: const Icon(Symbols.travel_explore),
title: Text('communityRealm').tr(),
subtitle: Text('communityRealmDescription').tr(),
value: isCommunity.value,
onChanged: (value) {
isCommunity.value = value ?? false;
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
],
),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: submitting.value ? null : performAction,
label: Text('saveChanges'.tr()),
icon: const Icon(Symbols.save),
),
),
],
).padding(all: 24),
),
],
),
),
);
}
}

View File

@@ -1,24 +1,15 @@
import 'package:croppy/croppy.dart' show CropAspectRatio;
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart';
import 'package:island/models/realm.dart'; import 'package:island/models/realm.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/file.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:island/screens/tabs.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -34,6 +25,14 @@ Future<List<SnRealm>> realmsJoined(Ref ref) async {
return resp.data.map((e) => SnRealm.fromJson(e)).cast<SnRealm>().toList(); return resp.data.map((e) => SnRealm.fromJson(e)).cast<SnRealm>().toList();
} }
@riverpod
Future<SnRealm?> realm(Ref ref, String? identifier) async {
if (identifier == null) return null;
final client = ref.watch(apiClientProvider);
final resp = await client.get('/sphere/realms/$identifier');
return SnRealm.fromJson(resp.data);
}
class RealmListScreen extends HookConsumerWidget { class RealmListScreen extends HookConsumerWidget {
const RealmListScreen({super.key}); const RealmListScreen({super.key});
@@ -90,7 +89,6 @@ class RealmListScreen extends HookConsumerWidget {
}); });
}, },
), ),
floatingActionButtonLocation: TabbedFabLocation(context),
body: ExtendedRefreshIndicator( body: ExtendedRefreshIndicator(
child: realms.when( child: realms.when(
data: data:
@@ -100,7 +98,7 @@ class RealmListScreen extends HookConsumerWidget {
child: ListView.separated( child: ListView.separated(
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: 8, top: 8,
bottom: getTabbedPadding(context).bottom + 8, bottom: MediaQuery.of(context).padding.bottom + 8,
), ),
itemCount: value.length, itemCount: value.length,
itemBuilder: (context, item) { itemBuilder: (context, item) {
@@ -127,277 +125,6 @@ class RealmListScreen extends HookConsumerWidget {
} }
} }
@riverpod
Future<SnRealm?> realm(Ref ref, String? identifier) async {
if (identifier == null) return null;
final client = ref.watch(apiClientProvider);
final resp = await client.get('/sphere/realms/$identifier');
return SnRealm.fromJson(resp.data);
}
class NewRealmScreen extends StatelessWidget {
const NewRealmScreen({super.key});
@override
Widget build(BuildContext context) {
return const EditRealmScreen();
}
}
class EditRealmScreen extends HookConsumerWidget {
final String? slug;
const EditRealmScreen({super.key, this.slug});
@override
Widget build(BuildContext context, WidgetRef ref) {
final submitting = useState(false);
final picture = useState<SnCloudFile?>(null);
final background = useState<SnCloudFile?>(null);
final isPublic = useState(true);
final isCommunity = useState(false);
final slugController = useTextEditingController();
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
final realm = ref.watch(realmProvider(slug));
useEffect(() {
if (realm.value != null) {
picture.value = realm.value!.picture;
background.value = realm.value!.background;
slugController.text = realm.value!.slug;
nameController.text = realm.value!.name;
descriptionController.text = realm.value!.description;
isPublic.value = realm.value!.isPublic;
isCommunity.value = realm.value!.isCommunity;
}
return null;
}, [realm]);
void setPicture(String position) async {
showLoadingModal(context);
var result = await ref
.read(imagePickerProvider)
.pickImage(source: ImageSource.gallery);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
hideLoadingModal(context);
result = await cropImage(
context,
image: result,
allowedAspectRatios: [
if (position == 'background')
const CropAspectRatio(height: 7, width: 16)
else
const CropAspectRatio(height: 1, width: 1),
],
);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
showLoadingModal(context);
submitting.value = true;
try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Access token is null');
final cloudFile =
await putFileToCloud(
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,
),
atk: token,
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
switch (position) {
case 'picture':
picture.value = cloudFile;
case 'background':
background.value = cloudFile;
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
submitting.value = false;
}
}
Future<void> performAction() async {
if (!formKey.currentState!.validate()) return;
submitting.value = true;
try {
final client = ref.watch(apiClientProvider);
final resp = await client.request(
'/sphere${slug == null ? '/realms' : '/realms/$slug'}',
data: {
'slug': slugController.text,
'name': nameController.text,
'description': descriptionController.text,
'background_id': background.value?.id,
'picture_id': picture.value?.id,
'is_public': isPublic.value,
'is_community': isCommunity.value,
},
options: Options(method: slug == null ? 'POST' : 'PATCH'),
);
if (context.mounted) {
context.pop(SnRealm.fromJson(resp.data));
}
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(slug == null ? 'createRealm'.tr() : 'editRealm'.tr()),
leading: const PageBackButton(),
),
body: SingleChildScrollView(
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
GestureDetector(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child:
background.value != null
? CloudFileWidget(
item: background.value!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
setPicture('background');
},
),
Positioned(
left: 20,
bottom: -32,
child: GestureDetector(
child: ProfilePictureWidget(
fileId: picture.value?.id,
radius: 40,
fallbackIcon: Symbols.group,
),
onTap: () {
setPicture('picture');
},
),
),
],
),
).padding(bottom: 32),
Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: slugController,
decoration: InputDecoration(
labelText: 'slug'.tr(),
helperText: 'slugHint'.tr(),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: nameController,
decoration: InputDecoration(labelText: 'name'.tr()),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: InputDecoration(
labelText: 'description'.tr(),
alignLabelWithHint: true,
),
minLines: 3,
maxLines: null,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
CheckboxListTile(
secondary: const Icon(Symbols.public),
title: Text('publicRealm').tr(),
subtitle: Text('publicRealmDescription').tr(),
value: isPublic.value,
onChanged: (value) {
isPublic.value = value ?? true;
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
CheckboxListTile(
secondary: const Icon(Symbols.travel_explore),
title: Text('communityRealm').tr(),
subtitle: Text('communityRealmDescription').tr(),
value: isCommunity.value,
onChanged: (value) {
isCommunity.value = value ?? false;
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
],
),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: submitting.value ? null : performAction,
label: Text('saveChanges'.tr()),
icon: const Icon(Symbols.save),
),
),
],
).padding(all: 24),
),
],
),
),
);
}
}
@riverpod @riverpod
Future<List<SnRealmMember>> realmInvites(Ref ref) async { Future<List<SnRealmMember>> realmInvites(Ref ref) async {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);

View File

@@ -22,11 +22,31 @@ import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/file_pool.dart'; import 'package:island/pods/file_pool.dart';
import 'package:island/models/file_pool.dart';
class SettingsScreen extends HookConsumerWidget { class SettingsScreen extends HookConsumerWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
String _getLanguageDisplayName(Locale locale) {
switch ('${locale.languageCode}-${locale.countryCode}') {
case 'en-US':
return 'English (US)';
case 'es-ES':
return 'Español (España)';
case 'ja-JP':
return '日本語 (日本)';
case 'ko-KR':
return '한국어 (대한민국)';
case 'zh-CN':
return '简体中文';
case 'zh-OG':
return '文言文 (华夏)';
case 'zh-TW':
return '繁體中文 (台灣)';
default:
return '${locale.languageCode}-${locale.countryCode}';
}
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider); final serverUrl = ref.watch(serverUrlProvider);
@@ -65,9 +85,7 @@ class SettingsScreen extends HookConsumerWidget {
) { ) {
return DropdownMenuItem<Locale?>( return DropdownMenuItem<Locale?>(
value: ele, value: ele,
child: Text( child: Text(_getLanguageDisplayName(ele)).fontSize(14),
'${ele.languageCode}-${ele.countryCode}',
).fontSize(14),
); );
}), }),
DropdownMenuItem<Locale?>( DropdownMenuItem<Locale?>(
@@ -93,6 +111,48 @@ class SettingsScreen extends HookConsumerWidget {
), ),
), ),
// Theme mode settings
ListTile(
minLeadingWidth: 48,
title: Text('settingsThemeMode').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.dark_mode),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<String>(
isExpanded: true,
items: [
DropdownMenuItem<String>(
value: 'system',
child: Text('settingsThemeModeSystem').tr().fontSize(14),
),
DropdownMenuItem<String>(
value: 'light',
child: Text('settingsThemeModeLight').tr().fontSize(14),
),
DropdownMenuItem<String>(
value: 'dark',
child: Text('settingsThemeModeDark').tr().fontSize(14),
),
],
value: settings.themeMode,
onChanged: (String? value) {
if (value != null) {
ref
.read(appSettingsNotifierProvider.notifier)
.setThemeMode(value);
showSnackBar('settingsApplied'.tr());
}
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5),
height: 40,
width: 140,
),
menuItemStyleData: const MenuItemStyleData(height: 40),
),
),
),
// Custom fonts settings // Custom fonts settings
ListTile( ListTile(
isThreeLine: true, isThreeLine: true,
@@ -174,11 +234,20 @@ class SettingsScreen extends HookConsumerWidget {
), ),
// Color scheme settings // Color scheme settings
ListTile( Theme(
minLeadingWidth: 48, data: Theme.of(
context,
).copyWith(listTileTheme: ListTileThemeData(minLeadingWidth: 48)),
child: ExpansionTile(
title: Text('settingsColorScheme').tr(), title: Text('settingsColorScheme').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17), tilePadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.palette), leading: const Icon(Symbols.palette),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
children: [
// Seed color picker
ListTile(
title: Text('Seed Color').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
trailing: GestureDetector( trailing: GestureDetector(
onTap: () { onTap: () {
showDialog( showDialog(
@@ -190,11 +259,13 @@ class SettingsScreen extends HookConsumerWidget {
: Colors.indigo; : Colors.indigo;
return AlertDialog( return AlertDialog(
title: Text('settingsColorScheme').tr(), title: Text('Seed Color').tr(),
content: SingleChildScrollView( content: SingleChildScrollView(
child: ColorPicker( child: ColorPicker(
paletteType: PaletteType.rgbWithBlue, paletteType: PaletteType.hsv,
enableAlpha: false, enableAlpha: true,
showLabel: true,
hexInputBar: true,
pickerColor: selectedColor, pickerColor: selectedColor,
onColorChanged: (color) { onColorChanged: (color) {
selectedColor = color; selectedColor = color;
@@ -231,13 +302,149 @@ class SettingsScreen extends HookConsumerWidget {
: Colors.indigo, : Colors.indigo,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5), color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.5),
width: 2, width: 2,
), ),
), ),
), ),
), ),
), ),
// Custom colors section
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child:
Text(
'Custom Colors',
style: Theme.of(context).textTheme.titleMedium,
).bold(),
),
// Primary color
_ColorPickerTile(
title: 'Primary',
color:
settings.customColors?.primary != null
? Color(settings.customColors!.primary!)
: null,
onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors();
ref
.read(appSettingsNotifierProvider.notifier)
.setCustomColors(current.copyWith(primary: color?.value));
},
),
// Secondary
_ColorPickerTile(
title: 'Secondary',
color:
settings.customColors?.secondary != null
? Color(settings.customColors!.secondary!)
: null,
onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors();
ref
.read(appSettingsNotifierProvider.notifier)
.setCustomColors(current.copyWith(secondary: color?.value));
},
),
// Tertiary
_ColorPickerTile(
title: 'Tertiary',
color:
settings.customColors?.tertiary != null
? Color(settings.customColors!.tertiary!)
: null,
onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors();
ref
.read(appSettingsNotifierProvider.notifier)
.setCustomColors(current.copyWith(tertiary: color?.value));
},
),
// Surface
_ColorPickerTile(
title: 'Surface',
color:
settings.customColors?.surface != null
? Color(settings.customColors!.surface!)
: null,
onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors();
ref
.read(appSettingsNotifierProvider.notifier)
.setCustomColors(current.copyWith(surface: color?.value));
},
),
// Background
_ColorPickerTile(
title: 'Background',
color:
settings.customColors?.background != null
? Color(settings.customColors!.background!)
: null,
onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors();
ref
.read(appSettingsNotifierProvider.notifier)
.setCustomColors(
current.copyWith(background: color?.value),
);
},
),
// Error
_ColorPickerTile(
title: 'Error',
color:
settings.customColors?.error != null
? Color(settings.customColors!.error!)
: null,
onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors();
ref
.read(appSettingsNotifierProvider.notifier)
.setCustomColors(current.copyWith(error: color?.value));
},
),
// Reset custom colors
ListTile(
title: Text('Reset Custom Colors').tr(),
trailing: const Icon(Symbols.restart_alt).padding(right: 2),
contentPadding: EdgeInsets.symmetric(horizontal: 20),
onTap: () {
ref
.read(appSettingsNotifierProvider.notifier)
.setCustomColors(null);
showSnackBar('settingsApplied'.tr());
},
),
],
),
),
// Card background opacity settings
ListTile(
minLeadingWidth: 48,
title: Text('settingsCardBackgroundOpacity').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.opacity),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8),
child: Slider(
value: settings.cardTransparency,
min: 0.0,
max: 1.0,
year2023: true,
padding: EdgeInsets.only(right: 24),
label: '${(settings.cardTransparency * 100).round()}%',
onChanged: (value) {
ref
.read(appSettingsNotifierProvider.notifier)
.setAppTransparentBackground(value);
},
),
),
),
// Background image settings (only for non-web platforms) // Background image settings (only for non-web platforms)
if (!kIsWeb && docBasepath.value != null) if (!kIsWeb && docBasepath.value != null)
@@ -417,7 +624,7 @@ class SettingsScreen extends HookConsumerWidget {
if (user.value != null) if (user.value != null)
pools.when( pools.when(
data: (data) { data: (data) {
final validPools = data.filterValid(); final validPools = data;
final currentPoolId = resolveDefaultPoolId(ref, data); final currentPoolId = resolveDefaultPoolId(ref, data);
return ListTile( return ListTile(
@@ -437,11 +644,14 @@ class SettingsScreen extends HookConsumerWidget {
validPools.map((p) { validPools.map((p) {
return DropdownMenuItem<String>( return DropdownMenuItem<String>(
value: p.id, value: p.id,
child: Tooltip(
message: p.name,
child: Text( child: Text(
p.name, p.name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
).fontSize(14), ).fontSize(14),
),
); );
}).toList(), }).toList(),
value: currentPoolId, value: currentPoolId,
@@ -577,8 +787,33 @@ class SettingsScreen extends HookConsumerWidget {
]; ];
// Desktop-specific settings // Desktop-specific settings
// But nothing for now final desktopSettings =
final desktopSettings = !isDesktop ? <Widget>[] : <Widget>[]; !isDesktop
? <Widget>[]
: [
ListTile(
minLeadingWidth: 48,
title: Text('settingsWindowOpacity').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.opacity),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8),
child: Slider(
value: settings.windowOpacity,
min: 0.1,
max: 1.0,
year2023: true,
padding: EdgeInsets.only(right: 24),
label: '${(settings.windowOpacity * 100).round()}%',
onChanged: (value) {
ref
.read(appSettingsNotifierProvider.notifier)
.setWindowOpacity(value);
},
),
),
),
];
// Create a responsive layout based on screen width // Create a responsive layout based on screen width
Widget buildSettingsList() { Widget buildSettingsList() {
@@ -699,3 +934,83 @@ class _SettingsSection extends StatelessWidget {
); );
} }
} }
// Helper widget for color picker tiles
class _ColorPickerTile extends StatelessWidget {
final String title;
final Color? color;
final ValueChanged<Color?> onColorChanged;
const _ColorPickerTile({
required this.title,
required this.color,
required this.onColorChanged,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
trailing: GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (context) {
Color selectedColor = color ?? Colors.transparent;
return AlertDialog(
title: Text(title),
content: SingleChildScrollView(
child: ColorPicker(
paletteType: PaletteType.hsv,
enableAlpha: true,
showLabel: true,
hexInputBar: true,
pickerColor: selectedColor,
onColorChanged: (newColor) {
selectedColor = newColor;
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('cancel').tr(),
),
TextButton(
onPressed: () {
onColorChanged(selectedColor);
Navigator.of(context).pop();
},
child: Text('confirm').tr(),
),
TextButton(
onPressed: () {
onColorChanged(null);
Navigator.of(context).pop();
},
child: Text('Reset').tr(),
),
],
);
},
);
},
child: Container(
width: 24,
height: 24,
margin: EdgeInsets.symmetric(horizontal: 2, vertical: 8),
decoration: BoxDecoration(
color: color ?? Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
width: 2,
),
),
),
),
);
}
}

View File

@@ -68,9 +68,12 @@ class TabsScreen extends HookConsumerWidget {
final currentIndex = getCurrentIndex(); final currentIndex = getCurrentIndex();
if (isWideScreen(context)) { if (isWideScreen(context)) {
return Row( return Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Row(
children: [ children: [
NavigationRail( NavigationRail(
backgroundColor: Colors.transparent,
destinations: destinations:
destinations destinations
.map( .map(
@@ -83,28 +86,36 @@ class TabsScreen extends HookConsumerWidget {
selectedIndex: currentIndex, selectedIndex: currentIndex,
onDestinationSelected: onDestinationSelected, onDestinationSelected: onDestinationSelected,
), ),
const VerticalDivider(width: 1), Expanded(
Expanded(child: child ?? const SizedBox.shrink()), child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
),
child: child ?? const SizedBox.shrink(),
),
),
], ],
),
); );
} }
return Stack( return Scaffold(
children: [ backgroundColor: Colors.transparent,
Positioned.fill(child: child ?? const SizedBox.shrink()), extendBody: true,
Positioned( body: ClipRRect(
left: 0, borderRadius: const BorderRadius.only(
right: 0, topLeft: Radius.circular(16),
bottom: 0, topRight: Radius.circular(16),
child: ConditionalBottomNav( ),
child: child ?? const SizedBox.shrink(),
),
bottomNavigationBar: ConditionalBottomNav(
child: ClipRRect( child: ClipRRect(
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), filter: ImageFilter.blur(sigmaX: 1, sigmaY: 1),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of( color: Theme.of(context).colorScheme.surface.withOpacity(0.8),
context,
).colorScheme.surface.withOpacity(0.8),
), ),
child: MediaQuery.removePadding( child: MediaQuery.removePadding(
context: context, context: context,
@@ -117,8 +128,7 @@ class TabsScreen extends HookConsumerWidget {
), ),
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
height: 56, height: 56,
labelBehavior: labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
NavigationDestinationLabelBehavior.alwaysHide,
selectedIndex: currentIndex, selectedIndex: currentIndex,
onDestinationSelected: onDestinationSelected, onDestinationSelected: onDestinationSelected,
destinations: destinations, destinations: destinations,
@@ -128,8 +138,6 @@ class TabsScreen extends HookConsumerWidget {
), ),
), ),
), ),
),
],
); );
} }
} }
@@ -157,7 +165,6 @@ class TabbedFabLocation extends FloatingActionButtonLocation {
scaffoldGeometry.floatingActionButtonSize.height - scaffoldGeometry.floatingActionButtonSize.height -
scaffoldGeometry.bottomSheetSize.height - scaffoldGeometry.bottomSheetSize.height -
safeAreaPadding.bottom - safeAreaPadding.bottom -
(isWideScreen(context) ? 32 : 80) +
16; 16;
return Offset(fabX, fabY); return Offset(fabX, fabY);

View File

@@ -1,8 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:tray_manager/tray_manager.dart'; import 'package:tray_manager/tray_manager.dart';
import 'package:window_manager/window_manager.dart';
class TrayService { class TrayService {
TrayService._(); TrayService._();
@@ -48,15 +47,10 @@ class TrayService {
void handleAction(MenuItem item) { void handleAction(MenuItem item) {
switch (item.key) { switch (item.key) {
case 'show_window': case 'show_window':
() async { windowManager.show();
appWindow.show();
appWindow.restore();
await Future.delayed(const Duration(milliseconds: 32));
appWindow.show();
}();
break; break;
case 'exit_app': case 'exit_app':
appWindow.close(); windowManager.destroy();
break; break;
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,441 @@ final walletCurrentProvider = AutoDisposeFutureProvider<SnWallet?>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
typedef WalletCurrentRef = AutoDisposeFutureProviderRef<SnWallet?>; typedef WalletCurrentRef = AutoDisposeFutureProviderRef<SnWallet?>;
String _$walletStatsHash() => r'23d692a922c2388135be6a46afa73c018762eb57';
/// See also [walletStats].
@ProviderFor(walletStats)
final walletStatsProvider = AutoDisposeFutureProvider<SnWalletStats>.internal(
walletStats,
name: r'walletStatsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$walletStatsHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef WalletStatsRef = AutoDisposeFutureProviderRef<SnWalletStats>;
String _$walletFundsHash() => r'7ceb415f64fcadab2b10461e27b95bf92352c707';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [walletFunds].
@ProviderFor(walletFunds)
const walletFundsProvider = WalletFundsFamily();
/// See also [walletFunds].
class WalletFundsFamily extends Family<AsyncValue<List<SnWalletFund>>> {
/// See also [walletFunds].
const WalletFundsFamily();
/// See also [walletFunds].
WalletFundsProvider call({int offset = 0, int take = 20}) {
return WalletFundsProvider(offset: offset, take: take);
}
@override
WalletFundsProvider getProviderOverride(
covariant WalletFundsProvider provider,
) {
return call(offset: provider.offset, take: provider.take);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'walletFundsProvider';
}
/// See also [walletFunds].
class WalletFundsProvider
extends AutoDisposeFutureProvider<List<SnWalletFund>> {
/// See also [walletFunds].
WalletFundsProvider({int offset = 0, int take = 20})
: this._internal(
(ref) => walletFunds(ref as WalletFundsRef, offset: offset, take: take),
from: walletFundsProvider,
name: r'walletFundsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$walletFundsHash,
dependencies: WalletFundsFamily._dependencies,
allTransitiveDependencies: WalletFundsFamily._allTransitiveDependencies,
offset: offset,
take: take,
);
WalletFundsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.offset,
required this.take,
}) : super.internal();
final int offset;
final int take;
@override
Override overrideWith(
FutureOr<List<SnWalletFund>> Function(WalletFundsRef provider) create,
) {
return ProviderOverride(
origin: this,
override: WalletFundsProvider._internal(
(ref) => create(ref as WalletFundsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
offset: offset,
take: take,
),
);
}
@override
AutoDisposeFutureProviderElement<List<SnWalletFund>> createElement() {
return _WalletFundsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is WalletFundsProvider &&
other.offset == offset &&
other.take == take;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, offset.hashCode);
hash = _SystemHash.combine(hash, take.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin WalletFundsRef on AutoDisposeFutureProviderRef<List<SnWalletFund>> {
/// The parameter `offset` of this provider.
int get offset;
/// The parameter `take` of this provider.
int get take;
}
class _WalletFundsProviderElement
extends AutoDisposeFutureProviderElement<List<SnWalletFund>>
with WalletFundsRef {
_WalletFundsProviderElement(super.provider);
@override
int get offset => (origin as WalletFundsProvider).offset;
@override
int get take => (origin as WalletFundsProvider).take;
}
String _$walletFundRecipientsHash() =>
r'18eb815eb709449dd5c545d81fc0ee43ca667578';
/// See also [walletFundRecipients].
@ProviderFor(walletFundRecipients)
const walletFundRecipientsProvider = WalletFundRecipientsFamily();
/// See also [walletFundRecipients].
class WalletFundRecipientsFamily
extends Family<AsyncValue<List<SnWalletFundRecipient>>> {
/// See also [walletFundRecipients].
const WalletFundRecipientsFamily();
/// See also [walletFundRecipients].
WalletFundRecipientsProvider call({int offset = 0, int take = 20}) {
return WalletFundRecipientsProvider(offset: offset, take: take);
}
@override
WalletFundRecipientsProvider getProviderOverride(
covariant WalletFundRecipientsProvider provider,
) {
return call(offset: provider.offset, take: provider.take);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'walletFundRecipientsProvider';
}
/// See also [walletFundRecipients].
class WalletFundRecipientsProvider
extends AutoDisposeFutureProvider<List<SnWalletFundRecipient>> {
/// See also [walletFundRecipients].
WalletFundRecipientsProvider({int offset = 0, int take = 20})
: this._internal(
(ref) => walletFundRecipients(
ref as WalletFundRecipientsRef,
offset: offset,
take: take,
),
from: walletFundRecipientsProvider,
name: r'walletFundRecipientsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$walletFundRecipientsHash,
dependencies: WalletFundRecipientsFamily._dependencies,
allTransitiveDependencies:
WalletFundRecipientsFamily._allTransitiveDependencies,
offset: offset,
take: take,
);
WalletFundRecipientsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.offset,
required this.take,
}) : super.internal();
final int offset;
final int take;
@override
Override overrideWith(
FutureOr<List<SnWalletFundRecipient>> Function(
WalletFundRecipientsRef provider,
)
create,
) {
return ProviderOverride(
origin: this,
override: WalletFundRecipientsProvider._internal(
(ref) => create(ref as WalletFundRecipientsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
offset: offset,
take: take,
),
);
}
@override
AutoDisposeFutureProviderElement<List<SnWalletFundRecipient>>
createElement() {
return _WalletFundRecipientsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is WalletFundRecipientsProvider &&
other.offset == offset &&
other.take == take;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, offset.hashCode);
hash = _SystemHash.combine(hash, take.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin WalletFundRecipientsRef
on AutoDisposeFutureProviderRef<List<SnWalletFundRecipient>> {
/// The parameter `offset` of this provider.
int get offset;
/// The parameter `take` of this provider.
int get take;
}
class _WalletFundRecipientsProviderElement
extends AutoDisposeFutureProviderElement<List<SnWalletFundRecipient>>
with WalletFundRecipientsRef {
_WalletFundRecipientsProviderElement(super.provider);
@override
int get offset => (origin as WalletFundRecipientsProvider).offset;
@override
int get take => (origin as WalletFundRecipientsProvider).take;
}
String _$walletFundHash() => r'a690b0def8f4293b4a8f244e44f8bb735687e5dd';
/// See also [walletFund].
@ProviderFor(walletFund)
const walletFundProvider = WalletFundFamily();
/// See also [walletFund].
class WalletFundFamily extends Family<AsyncValue<SnWalletFund>> {
/// See also [walletFund].
const WalletFundFamily();
/// See also [walletFund].
WalletFundProvider call(String fundId) {
return WalletFundProvider(fundId);
}
@override
WalletFundProvider getProviderOverride(
covariant WalletFundProvider provider,
) {
return call(provider.fundId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'walletFundProvider';
}
/// See also [walletFund].
class WalletFundProvider extends AutoDisposeFutureProvider<SnWalletFund> {
/// See also [walletFund].
WalletFundProvider(String fundId)
: this._internal(
(ref) => walletFund(ref as WalletFundRef, fundId),
from: walletFundProvider,
name: r'walletFundProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$walletFundHash,
dependencies: WalletFundFamily._dependencies,
allTransitiveDependencies: WalletFundFamily._allTransitiveDependencies,
fundId: fundId,
);
WalletFundProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.fundId,
}) : super.internal();
final String fundId;
@override
Override overrideWith(
FutureOr<SnWalletFund> Function(WalletFundRef provider) create,
) {
return ProviderOverride(
origin: this,
override: WalletFundProvider._internal(
(ref) => create(ref as WalletFundRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
fundId: fundId,
),
);
}
@override
AutoDisposeFutureProviderElement<SnWalletFund> createElement() {
return _WalletFundProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is WalletFundProvider && other.fundId == fundId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, fundId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin WalletFundRef on AutoDisposeFutureProviderRef<SnWalletFund> {
/// The parameter `fundId` of this provider.
String get fundId;
}
class _WalletFundProviderElement
extends AutoDisposeFutureProviderElement<SnWalletFund>
with WalletFundRef {
_WalletFundProviderElement(super.provider);
@override
String get fundId => (origin as WalletFundProvider).fundId;
}
String _$transactionListNotifierHash() => String _$transactionListNotifierHash() =>
r'7b777cd44f3351f68f7bd1dd76bfe8b388381bdb'; r'7b777cd44f3351f68f7bd1dd76bfe8b388381bdb';

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