Compare commits

...

152 Commits

Author SHA1 Message Date
5663df6ef1 🚀 Launch 3.3.0+148 2025-11-23 13:07:15 +08:00
e996a0c95f 👽 Update the verification mark 2025-11-23 13:04:07 +08:00
a090e93f57 🗑️ Remove the chat role display in message 2025-11-23 12:56:02 +08:00
c69034c071 Better notification list 2025-11-23 12:55:49 +08:00
369ea6cf5b 🐛 Fix unmounted setState 2025-11-23 12:46:49 +08:00
2e371b5296 💄 More accurate notification unread count 2025-11-23 12:45:19 +08:00
2e9d61bcfa Chat unread indicator across all chat 2025-11-23 12:40:52 +08:00
9c2b5b0dfa 🐛 Fix further remote messages will not be loaded 2025-11-23 12:25:46 +08:00
3b40f515b3 🐛 Fix file list go back to wrong page 2025-11-23 12:18:57 +08:00
5ee61dbef2 Pagination in chat message sync 2025-11-23 12:18:46 +08:00
b151ef6686 🐛 Try to fix message loading 2025-11-23 11:54:51 +08:00
ff934d0f08 💄 Update the captcha style 2025-11-23 11:39:52 +08:00
abe5ded896 🚀 Launch 3.3.0+147 2025-11-23 02:01:00 +08:00
f1d72a5215 🐛 Fix android build no check 2025-11-23 02:00:13 +08:00
864cbe73b7 🐛 Try to fix share intent fails 2025-11-23 01:54:01 +08:00
108a6da074 🌐 Localized files 2025-11-23 01:43:54 +08:00
f9a09599c9 ⬆️ Upgrade dependecies 2025-11-23 01:23:43 +08:00
9067dadd3e 🐛 Fix reaction sheet popover goes out of the screen 2025-11-23 01:21:54 +08:00
09f8df1e78 💄 Optimize design of the call content 2025-11-23 01:12:04 +08:00
2c5f246c55 💄 Redesign the video of the call 2025-11-23 00:53:00 +08:00
a66c6ea654 💫 Animated call overlay 2025-11-23 00:35:42 +08:00
3ad4bb4518 ♻️ Rebuild the call 2025-11-23 00:26:40 +08:00
53f0dcb825 Optimize performance for message item 2025-11-22 20:46:41 +08:00
557f5a2389 👔 Hide the friends overview on mobile 2025-11-22 20:26:41 +08:00
78f14f890f 💄 Optimize embedded section of chat input 2025-11-22 20:11:01 +08:00
77b2effb34 💫 Update the animation of alert's dialog 2025-11-22 19:18:42 +08:00
f02b4abf65 💄 Optimize audio player height 2025-11-22 18:58:25 +08:00
3f37c4f761 ♻️ Remove platform alert and use flutter dialog instead 2025-11-22 18:56:18 +08:00
5deb910fa4 ♻️ Refactored all ScaffoldMessager to use unifined snackbar API 2025-11-22 18:42:12 +08:00
f50a19f573 🐛 Dozens of bug fixes 2025-11-22 18:36:10 +08:00
98c8a356e8 ♻️ Rebuild the activity heatmap to close #189 2025-11-22 16:19:23 +08:00
d0c16ea08f Site quick open page 2025-11-22 16:02:30 +08:00
f2c1b2a531 File management actions 2025-11-22 16:01:27 +08:00
3061f0c5a9 Site file edit 2025-11-22 15:43:35 +08:00
98f7f33c65 Site file management able to navigate folders 2025-11-22 15:24:16 +08:00
d9af5d32fd Site file management able to upload site 2025-11-22 14:59:44 +08:00
f2031697ec 🐛 Fix the site refresh didn't wrok 2025-11-22 14:44:41 +08:00
9b85b7573c 💄 Optimize publication site screen 2025-11-22 14:39:03 +08:00
4fb739b33b 💄 Desktop optimization for the site dashboard 2025-11-21 00:40:45 +08:00
c03ba3bc3a ♻️ Breakdown of the site detail page 2025-11-21 00:34:39 +08:00
fc65440420 🐛 Fix file upload in site 2025-11-21 00:24:35 +08:00
7b85533184 Pages management in site detail 2025-11-21 00:05:36 +08:00
77d9eb60c6 Page details 2025-11-20 22:40:20 +08:00
4d8953cd22 Site mode 2025-11-20 21:58:59 +08:00
fafa460fe8 Site basis 2025-11-20 21:29:08 +08:00
faf3a677d4 Rewind AI slop 2025-11-20 00:21:21 +08:00
0f644a0234 🐛 Fix chat list tiles renders wrong account 2025-11-20 00:11:13 +08:00
18d16fdd57 🐛 Fix bugs in message db 2025-11-20 00:01:36 +08:00
18e890d63c 💄 Optimize cloud file sizing 2025-11-19 22:56:38 +08:00
9c5e50c16a 🐛 Fix share post via screenshot entirely broke 2025-11-19 22:49:47 +08:00
96a2c8182e ⬆️ Upgrade dependecies 2025-11-19 21:41:57 +08:00
56b27c3e82 Use cached chat rooms for first time render chat 2025-11-19 00:50:22 +08:00
ad4bf94195 ♻️ Refactored chat db 2025-11-19 00:29:22 +08:00
b77a832d8a 🐛 Fix autohide of upload 2025-11-18 22:52:45 +08:00
5e61805db7 💄 Upload overlay auto hide 2025-11-18 22:38:27 +08:00
35b96b0bd2 💄 Optimize downloading and files 2025-11-18 22:21:23 +08:00
c8ad791ff3 💄 Optimize cloud files 2025-11-18 22:06:38 +08:00
1e908502dc Able to open file detail view from lightbox 2025-11-18 21:59:35 +08:00
715ce1a368 File reference list 2025-11-18 21:45:13 +08:00
548c9963ee File list selection select all 2025-11-18 21:32:25 +08:00
db5199438a Selection and batch operations in file list 2025-11-18 21:17:09 +08:00
4409a6fb1e More global filters om file list 2025-11-18 20:33:49 +08:00
26a24b0e41 Pdf viewer zoom 2025-11-18 13:06:08 +08:00
9b948d259b Cached pdf viewer 2025-11-18 13:00:57 +08:00
1f713b5b2b 💄 Make captcha undismissable 2025-11-18 12:57:21 +08:00
f92cfafda4 Downloading file tasks 2025-11-18 01:45:15 +08:00
fa208b44d7 🐛 Fix publisher account name shows wrong 2025-11-18 01:31:15 +08:00
94adecafbb 💄 Optimize file detail view styling 2025-11-18 00:32:26 +08:00
0303ef4a93 💄 Optimize file list again 2025-11-18 00:20:10 +08:00
c2b18ce10b 🐛 Fix file list 2025-11-18 00:04:07 +08:00
0767bb53ce Put clean up recycled files back 2025-11-17 23:53:11 +08:00
b233f9a410 💄 File list loading indicator 2025-11-17 23:10:13 +08:00
256024fb46 💄 Adjust upload overlay auto show and hide logic 2025-11-17 22:57:53 +08:00
4a80aaf24d Unindexed files filter 2025-11-17 22:57:42 +08:00
aafd160c44 🐛 Fix waterfall styling issue 2025-11-17 22:00:51 +08:00
4a800725e3 Zoom image via mosue scroll 2025-11-17 22:00:35 +08:00
24791b3293 🎉 Launch 3.3.0+146 2025-11-17 01:33:34 +08:00
3ac263d483 🐛 Fix build on web 2025-11-17 01:26:34 +08:00
2445d8adf8 💄 Optimzations 2025-11-17 01:23:27 +08:00
d4f95bbbf4 Claim fund 2025-11-17 01:20:49 +08:00
943e4b7b5c 🐛 Fix edit post didn't inherent poll and fund 2025-11-16 23:58:36 +08:00
7edc02a1d3 ♻️ No longer two submit post function 2025-11-16 23:54:50 +08:00
3f9881e943 Fund creation and attach found to message 2025-11-16 23:43:28 +08:00
50c25e919c 🐛 Bug fixes in cloud file collection 2025-11-16 23:00:14 +08:00
99fb08dd55 Send message with poll 2025-11-16 22:43:18 +08:00
e43bc6b8a8 💄 Optimize poll 2025-11-16 22:29:24 +08:00
c247cdf81c ♻️ Refactored poll editor 2025-11-16 22:15:10 +08:00
3ffa730505 💄 Optimize chat input expand style 2025-11-16 21:49:41 +08:00
1cc34d3073 Chat input expansiable section basis 2025-11-16 21:42:10 +08:00
96a919cc4e 💫 Animated height padding in inputs 2025-11-16 20:20:24 +08:00
e7e3bfcadf 🐛 Fix oidc callback 2025-11-16 18:38:36 +08:00
a8617a5040 💄 Collspible link embeds 2025-11-16 18:23:24 +08:00
d94f8d004f 💄 Shows friends overview on mobile as well 2025-11-16 18:10:55 +08:00
d93b066979 📝 Rename the currencies 2025-11-16 17:59:10 +08:00
320664a547 💄 Friends overview optimization 2025-11-16 17:50:36 +08:00
98f4698d5b 🐛 Fixes of serval bugs 2025-11-16 17:40:37 +08:00
82397dd087 Friends overview basis 2025-11-16 17:26:31 +08:00
4ec10ceb47 ♻️ Rework of the oidc login flow (wip) 2025-11-16 17:07:34 +08:00
4b03b45a0d 💄 Create account, login modal varaint and more auth check to prevent user from entering certain widget in unauthorized state 2025-11-16 14:53:40 +08:00
7a72d32649 🐛 Fix publisher page account avatar no gesture detector, close #188 2025-11-16 12:06:48 +08:00
5152dd13ea 💄 Continue optimize the post reaction sheet 2025-11-16 12:04:13 +08:00
fd377aa7af 💄 Change the post reaction sheet sticker picker align and close 2025-11-16 11:56:49 +08:00
67044148f1 💄 Fix chat input overlaps with message sometimes 2025-11-16 11:48:51 +08:00
92bc43e4df 🐛 Fix chat creation cause stack has no page 2025-11-16 11:48:35 +08:00
a1a7b34c86 ♻️ Use bottom modal sheet for chat creation form 2025-11-16 11:46:31 +08:00
40c0e052cf 💄 Optimize thought input space to avoid input cover message 2025-11-16 11:22:47 +08:00
9a75228e38 Multi model support in thought 2025-11-16 02:45:02 +08:00
a9fd75cc45 Thinking billing check 2025-11-16 01:18:20 +08:00
a713b30d93 🐛 Fix bugs 2025-11-16 00:52:17 +08:00
e516f0a862 🐛 Bug fixes 2025-11-16 00:34:10 +08:00
429b966c4b 🐛 Fix wrong tool call progress status 2025-11-15 23:22:33 +08:00
f14da0d3a2 💫 Add tool call calling hint animation 2025-11-15 23:22:07 +08:00
d201182bd2 ♻️ Turn thought into a Tab 2025-11-15 23:08:31 +08:00
6f6422c15e 💄 Optimize thought function call style 2025-11-15 23:02:25 +08:00
9f6ae639ee 🐛 Fix publisher member management missing service id to use sphere API 2025-11-15 22:40:27 +08:00
35f4d7d885 ♻️ Updated the thought rendering 2025-11-15 22:16:29 +08:00
a9c8f49797 💄 Optimize thoughts 2025-11-15 21:15:41 +08:00
5e9341a19c ♻️ Refactored the thinking 2025-11-15 17:10:36 +08:00
645a6dca93 ♻️ Refactor the thought insight to support new API 2025-11-15 16:59:22 +08:00
ea8e7ead2d 💄 Add go to previous path action in path nav in file list 2025-11-15 16:25:24 +08:00
5f2f083d72 ♻️ Fixes and optimizations in file list 2025-11-15 16:20:05 +08:00
5cf40e27de 💄 Optimized the waterfall file list style 2025-11-15 16:05:42 +08:00
1ab7295918 💄 Optimize waterfall file list design 2025-11-15 15:54:13 +08:00
07f191171c Waterfall layout in files (w.i.p) 2025-11-15 15:42:09 +08:00
4a5dac248e ♻️ Dedicated file viewer widget 2025-11-15 15:08:49 +08:00
3b983a6444 ♻️ Refactored the file detail 2025-11-15 15:04:01 +08:00
4607b77355 ♻️ Better file icons 2025-11-15 13:36:00 +08:00
7957e4894a File list drag and drop 2025-11-15 13:22:05 +08:00
f94f80c375 👽 Update the indexed file api calls 2025-11-15 03:06:41 +08:00
74fa2215a6 Unindexed files 2025-11-15 02:59:20 +08:00
0d11435feb ⬆️ Upgrade dependecies 2025-11-15 01:44:10 +08:00
e22598b0a6 🔨 Preview of the watchOS app main screen 2025-11-14 21:41:04 +08:00
84cfe643f5 👽 Adopt the new folder system (w.i.p) 2025-11-14 01:04:15 +08:00
05ac04e9a2 Enchaned file detail screen 2025-11-13 01:44:55 +08:00
66f283d6e8 Renders file folders in drive 2025-11-13 01:31:58 +08:00
c779c7523c FIle index 2025-11-12 22:09:22 +08:00
ac7cb29afe ♻️ Improved the files screen 2025-11-11 00:49:07 +08:00
935aa77223 ♻️ Turn the file screen into a tab 2025-11-11 00:39:49 +08:00
24e5b3b824 Account tab icon shows pfp 2025-11-11 00:39:35 +08:00
0391893b32 🐛 Bug fixes in upload task tracking 2025-11-11 00:00:09 +08:00
b8d24876c8 🚚 Rename upload task to drive task 2025-11-10 01:56:22 +08:00
0493661f9a 🐛 Fixes and optimizations 2025-11-10 01:49:38 +08:00
b40afde00f 💫 Animated the upload overlay 2025-11-10 01:40:28 +08:00
78a4022531 💄 Optimize upload overlay styling 2025-11-10 01:27:06 +08:00
8a291c80b7 Upload tasks overlay 2025-11-10 01:11:43 +08:00
1395d65b76 ♻️ Refactored publisher creation into sheet 2025-11-09 21:18:34 +08:00
eb4942e0ed 💄 Optimize account card style when no background 2025-11-09 14:28:53 +08:00
f254cfa81e ♻️ Refactored the captcha 2025-11-09 14:21:33 +08:00
4927795260 ♻️ Refactored the upload files according to new backend tasks 2025-11-09 14:09:26 +08:00
e4019dadc8 💄 Optimize file upload prograss indicates 2025-11-09 01:59:24 +08:00
5e7d77e1a1 🐛 Fix share sheet error 2025-11-08 20:05:18 +08:00
bfcbed035c ♻️ Refactored file uploading 2025-11-08 20:04:54 +08:00
193 changed files with 30529 additions and 5853 deletions

View File

@@ -136,6 +136,7 @@
"reactionNegative": "Negative",
"reactionNeutral": "Neutral",
"customReaction": "Custom Reaction",
"customReactionHint": "Custom Reaction allow you to use user uploaded stickers as the symbol of the reaction for the post. Exclusive for Stellar Program members.",
"customReactions": "Custom Reactions",
"stickerPlaceholder": "Sticker Placeholder",
"reactionAttitude": "Reaction Attitude",
@@ -179,6 +180,7 @@
"noFortuneData": "No fortune data available for this month.",
"creatorHub": "Creator Hub",
"creatorHubDescription": "Manage posts, analytics, and more.",
"publicationSites": "Publication Sites",
"developerPortal": "Developer Portal",
"developerPortalDescription": "Build with Solar Network™.",
"statusCreateHint": "What's on your mind? Add a status.",
@@ -1087,6 +1089,7 @@
"levelingStage10": "Immortal",
"levelingStage11": "Divine",
"levelingStage12": "Transcendent",
"uploadTasks": "Upload Tasks",
"uploadAttachment": "Upload Attachment",
"attachmentPreview": "Attachment Preview",
"selectPool": "Select Pool",
@@ -1301,7 +1304,9 @@
"thoughtInputHint": "Ask sn-chan anything...",
"thoughtNewConversation": "Start New Conversation",
"thoughtParseError": "Failed to parse AI response",
"thoughtFunctionCall": "Function Call",
"thoughtFunctionCall": "Use {}",
"thoughtFunctionCallBegin": "Calling tool {}",
"thoughtFunctionCallFinish": "{} responded",
"aiThought": "AI Thought",
"aiThoughtTitle": "Let sn-chan think",
"postReferenceUnavailable": "Referenced post is unavailable",
@@ -1320,5 +1325,151 @@
"popularity": "Popularity",
"descendingOrder": "Descending Order",
"selectDate": "Select Date",
"pinnedPosts": "Pinned Posts"
}
"pinnedPosts": "Pinned Posts",
"thoughtUnpaidHint": "Thinking unavaiable due to unpaid orders",
"more": "More",
"collapse": "Collapse",
"pollConfirmDiscard": "Are you sure you want to leave? All the poll data you're editing will not be saved.",
"discard": "Discard",
"fund": "Fund",
"fundsRecent": "Recent Funds",
"fundCreateNew": "Create New",
"fundCreateNewHint": "Create a new fund for your message. Select recipients and amount.",
"amountOfSplits": "Amount of Splits",
"enterNumberOfSplits": "Enter Splits Amount",
"orCreateWith": "Or\ncreate with",
"unindexedFiles": "Unindexed files",
"folder": "Folder",
"clearCompleted": "Clear Completed",
"contentCantEmpty": "Content cannot be empty",
"features": "Features",
"unnamed": "Unnamed",
"fundEnvelopeLoadFailed": "Failed to load fund envelope",
"fundEnvelope": "Fund Envelope",
"fundEnvelopeRemaining": "Remaining: {} {}",
"fundEnvelopeSplit": "Split: {}",
"fundEnvelopeSplitEvenly": "Evenly",
"fundEnvelopeSplitRandomly": "Randomly",
"fundEnvelopeClaimSuccess": "Fund claimed successfully!",
"fundEnvelopeStatusCreated": "Created",
"fundEnvelopeStatusPartial": "Partially Claimed",
"fundEnvelopeStatusCompleted": "Fully Claimed",
"fundEnvelopeStatusExpired": "Expired",
"fundEnvelopeStatusUnknown": "Unknown",
"fundEnvelopeRecipients": "Recipients ({}/{} claimed)",
"fundEnvelopeExpiredDaysAgo": {
"one": "Expired {} day ago",
"other": "Expired {} days ago"
},
"fundEnvelopeExpiresSoon": "Expires soon",
"fundEnvelopeExpiresInHours": {
"one": "Expires in {} hour",
"other": "Expires in {} hours"
},
"fundEnvelopeExpiresInDays": {
"one": "Expires in {} day",
"other": "Expires in {} days"
},
"fundEnvelopeRemainingWithSplits": "{} {} / {} splits",
"fundEnvelopeUnknownUser": "Unknown User",
"deleteSite": "Delete Site",
"deleteSiteConfirm": "Are you sure you want to delete this site?",
"siteDeletedSuccess": "Site deleted successfully",
"siteSlug": "Slug",
"siteSlugHint": "my-site",
"siteSlugRequired": "Please enter a slug",
"siteSlugInvalid": "Slug can only contain lowercase letters, numbers, and dashes",
"siteName": "Site Name",
"siteNameHint": "My Publication Site",
"siteNameRequired": "Please enter a site name",
"siteMode": "Mode",
"siteModeFullyManaged": "Fully Managed",
"siteModeSelfManaged": "Self-Managed",
"editPublicationSite": "Edit Publication Site",
"deletePublicationSite": "Delete Publication Site",
"publicationSiteSavedSuccess": "Publication site saved successfully",
"publicationSiteDeleteConfirm": "Are you sure you want to delete this publication site? This action cannot be undone.",
"publicationSiteDeletedSuccess": "Publication site deleted successfully",
"newPublicationSite": "New Publication Site",
"siteDetails": "Site Details",
"siteInformation": "Site Information",
"siteDomain": "Domain",
"siteCreated": "Created",
"siteUpdated": "Updated",
"failedToLoadSite": "Failed to load site",
"sitePages": "Pages",
"noPagesYet": "No pages yet",
"createFirstPage": "Create your first page to get started",
"failedToLoadPages": "Failed to load pages",
"fileManagement": "File Management",
"siteFiles": "Files",
"siteFolder": "Folder",
"siteRoot": "Root",
"noFilesUploadedYet": "No files uploaded yet",
"uploadFirstFile": "Upload your first file to get started",
"failedToLoadFiles": "Failed to load files",
"noFilesFoundInFolder": "No files found in the selected folder",
"fileActions": "File Actions",
"purgeFiles": "Purge Files",
"purgeFilesDescription": "Remove all uploaded files from the site",
"deploySite": "Deploy Site",
"deploySiteDescription": "Upload and deploy a new version from ZIP archive",
"confirmPurge": "Confirm Purge",
"purgeFilesConfirm": "This will permanently delete all files uploaded to this site. This action cannot be undone. Are you sure you want to continue?",
"purgeAllFiles": "Purge All Files",
"allFilesPurgedSuccess": "All files purged successfully",
"failedToPurgeFiles": "Failed to purge files: {}",
"siteDeployedSuccess": "Site deployed successfully",
"failedToDeploySite": "Failed to deploy site: {}",
"createPage": "Create Page",
"editPage": "Edit Page",
"pageType": "Page Type",
"htmlPage": "HTML Page",
"redirectPage": "Redirect Page",
"pageTypeRequired": "Please select a page type",
"pagePath": "Page Path",
"pagePathHint": "/about, /contact, etc.",
"pagePathRequired": "Please enter a page path",
"pagePathInvalid": "Page path can only contain letters, numbers, hyphens, underscores, and slashes",
"pagePathMustStartWithSlash": "Page path must start with /",
"pagePathNoConsecutiveSlashes": "Page path cannot have consecutive slashes",
"pageTitle": "Page Title",
"pageTitleHint": "About Us, Contact, etc.",
"pageTitleRequired": "Please enter a page title",
"pageContentHtml": "Page Content (HTML)",
"pageContentHint": "<h1>Hello World</h1><p>This is my page content...</p>",
"pageContentRequired": "Please enter HTML content for the page",
"redirectTarget": "Redirect Target",
"redirectTargetHint": "/new-page, https://example.com, etc.",
"redirectTargetRequired": "Please enter a redirect target",
"redirectTargetInvalid": "Target must be a relative path (/) or absolute URL (http/https)",
"deletePage": "Delete Page",
"deletePageConfirm": "Are you sure you want to delete this page?",
"savePage": "Save Page",
"pageCreatedSuccess": "Page created successfully",
"pageUpdatedSuccess": "Page updated successfully",
"pageDeletedSuccess": "Page deleted successfully",
"uploadFiles": "Upload Files",
"uploadPath": "Upload Path",
"uploadPathHint": "/ (root) or /assets/images/",
"uploadPathRequired": "Please enter an upload path",
"uploadPathMustStartWithSlash": "Path must start with /",
"uploadPathNoSpaces": "Path cannot contain spaces",
"uploadPathNoConsecutiveSlashes": "Path cannot have consecutive slashes",
"percentCompleted": "{}% completed",
"filesToUpload": "{} files to upload",
"fileSizeKb": "Size: {} KB",
"uploadingEllipsis": "Uploading...",
"uploadFilesCount": {
"one": "Upload {} File",
"other": "Upload {} Files"
},
"allUploadsCompleted": "All uploads completed",
"someUploadsFailed": "Some uploads failed",
"uploadingInProgress": "Uploading in progress",
"readyToUpload": "Ready to upload",
"allFilesUploadedSuccess": "All files uploaded successfully",
"lotteryLastNumberSpecial": "The last selected number will be your special number.",
"lotteryMultiplierRequired": "Please enter a multiplier",
"lotteryMultiplierRange": "Multiplier must be between 1 and 10"
}

View File

@@ -251,10 +251,10 @@
"translatorBadgeName": "翻译者",
"translatorBadgeDescription": "协助将 Solar Network 翻译成不同语言",
"wallet": "钱包",
"walletCurrencyPoints": "新太阳点",
"walletCurrencyPoints": "源能点",
"walletCurrencyShortPoints": "NSP",
"walletCurrencyGolds": "太阳币",
"walletCurrencyShortGolds": "TSD",
"walletCurrencyGolds": "星辰碎片",
"walletCurrencyShortGolds": "SHD",
"retry": "重试",
"creatorHubUnselectedHint": "选择/创建一个发布者以开始使用。",
"relationships": "关系",
@@ -1090,5 +1090,6 @@
"thoughtNewConversation": "开始新对话",
"thoughtParseError": "解析 AI 响应失败",
"aiThought": "寻思",
"aiThoughtTitle": "让 SN 酱寻思寻思"
"aiThoughtTitle": "让 SN 酱寻思寻思",
"thoughtUnpaidHint": "寻思因为有未支付的订单而被禁用"
}

View File

@@ -1,3 +1,6 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- drift: true
- provider: true
- shared_preferences: true

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,5 @@
PODS:
- Alamofire (5.10.2)
- app_links (6.4.1):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- croppy (0.0.1):
@@ -52,18 +50,18 @@ PODS:
- Firebase/Messaging (12.4.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.4.0)
- firebase_analytics (12.0.3):
- firebase_analytics (12.0.4):
- firebase_core
- FirebaseAnalytics (= 12.4.0)
- Flutter
- firebase_core (4.2.0):
- firebase_core (4.2.1):
- Firebase/CoreOnly (= 12.4.0)
- Flutter
- firebase_crashlytics (5.0.3):
- firebase_crashlytics (5.0.5):
- Firebase/Crashlytics (= 12.4.0)
- firebase_core
- Flutter
- firebase_messaging (16.0.3):
- firebase_messaging (16.0.4):
- Firebase/Messaging (= 12.4.0)
- firebase_core
- Flutter
@@ -142,15 +140,13 @@ PODS:
- Flutter
- flutter_native_splash (2.4.3):
- Flutter
- flutter_platform_alert (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_timezone (0.0.1):
- Flutter
- flutter_udid (0.0.1):
- Flutter
- SAMKeychain
- KeychainAccess
- flutter_webrtc (1.2.0):
- Flutter
- WebRTC-SDK (= 137.7151.04)
@@ -218,7 +214,8 @@ PODS:
- Flutter
- irondash_engine_context (0.0.1):
- Flutter
- Kingfisher (8.6.1)
- KeychainAccess (4.2.2)
- Kingfisher (8.6.2)
- KingfisherWebP (1.7.2):
- Kingfisher (~> 8.0)
- libwebp (>= 1.1.0)
@@ -252,24 +249,24 @@ PODS:
- nanopb/encode (3.30910.0)
- native_exif (0.0.1):
- Flutter
- objective_c (0.0.1):
- Flutter
- OrderedSet (6.0.3)
- package_info_plus (0.4.5):
- Flutter
- pasteboard (0.0.1):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- pointer_interceptor_ios (0.0.1):
- Flutter
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
- protocol_handler_ios (0.0.1):
- Flutter
- receive_sharing_intent (1.8.1):
- Flutter
- record_ios (1.1.0):
- Flutter
- SAMKeychain (1.5.3)
- SDWebImage (5.21.3):
- SDWebImage/Core (= 5.21.3)
- SDWebImage/Core (5.21.3)
@@ -315,15 +312,12 @@ PODS:
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
- volume_controller (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
- WebRTC-SDK (137.7151.04)
DEPENDENCIES:
- Alamofire
- app_links (from `.symlinks/plugins/app_links/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
@@ -339,7 +333,6 @@ DEPENDENCIES:
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_platform_alert (from `.symlinks/plugins/flutter_platform_alert/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
@@ -354,10 +347,11 @@ DEPENDENCIES:
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- native_exif (from `.symlinks/plugins/native_exif/ios`)
- objective_c (from `.symlinks/plugins/objective_c/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -368,7 +362,6 @@ DEPENDENCIES:
- 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`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
@@ -390,6 +383,7 @@ SPEC REPOS:
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
- KeychainAccess
- Kingfisher
- KingfisherWebP
- libwebp
@@ -397,15 +391,12 @@ SPEC REPOS:
- OrderedSet
- PromisesObjC
- PromisesSwift
- SAMKeychain
- SDWebImage
- sqlite3
- SwiftyGif
- WebRTC-SDK
EXTERNAL SOURCES:
app_links:
:path: ".symlinks/plugins/app_links/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
croppy:
@@ -436,8 +427,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_platform_alert:
:path: ".symlinks/plugins/flutter_platform_alert/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_timezone:
@@ -462,14 +451,16 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/media_kit_video/ios"
native_exif:
:path: ".symlinks/plugins/native_exif/ios"
objective_c:
:path: ".symlinks/plugins/objective_c/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
pasteboard:
:path: ".symlinks/plugins/pasteboard/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
pointer_interceptor_ios:
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
protocol_handler_ios:
:path: ".symlinks/plugins/protocol_handler_ios/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
record_ios:
@@ -490,14 +481,11 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
volume_controller:
:path: ".symlinks/plugins/volume_controller/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
@@ -506,10 +494,10 @@ SPEC CHECKSUMS:
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
firebase_analytics: 1d024068b1d4707d5ba7a42a12976ddf3316d835
firebase_core: 744984dbbed8b3036abf34f0b98d80f130a7e464
firebase_crashlytics: f3a9a4338ab99b67042f64e9e22e1bf349cb44ed
firebase_messaging: 82c70650c426a0a14873e1acdb9ec2b443c4e8b4
firebase_analytics: 67fbdd9f3c04e55048024f3da21cfc36f05e56cf
firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594
firebase_crashlytics: c039028126cb45e32f4c217aa392408b0963d081
firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde
FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f
FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018
@@ -525,10 +513,9 @@ SPEC CHECKSUMS:
flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_platform_alert: bf3b5fcd4ac14bd637e20527e9c471633071afd3
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
flutter_udid: 92a5d31fe0526b7b6002a2318df702e12e7eb300
flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22
gal: baecd024ebfd13c441269ca7404792a7152fde89
GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1
@@ -537,7 +524,8 @@ SPEC CHECKSUMS:
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
Kingfisher: 7ac7a7288653787a54206b11a3c74f49ab650f1f
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
Kingfisher: 23d18f54677d973b713e54ce6a8f5eef6e7056ba
KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
livekit_client: 86c8af579274e4b7a215185a8080db2d4e176f40
@@ -546,16 +534,16 @@ SPEC CHECKSUMS:
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
native_exif: 0eb73d3d5b3ca892719228df8d2d1b13d1ae396c
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
protocol_handler_ios: 59f23ee71f3ec602d67902ca7f669a80957888d5
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
@@ -567,7 +555,6 @@ SPEC CHECKSUMS:
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e

View File

@@ -48,3 +48,11 @@ struct ContentView: View {
}
}
}
// --- Placeholder Implementations for Preview ---
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

View File

@@ -2,17 +2,19 @@ import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:island/database/message.dart';
import 'package:island/database/draft.dart';
import 'package:island/models/account.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/post.dart';
part 'drift_db.g.dart';
// Define the database
@DriftDatabase(tables: [ChatMessages, PostDrafts])
@DriftDatabase(tables: [ChatRooms, ChatMembers, ChatMessages, PostDrafts])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 7;
int get schemaVersion => 8;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -55,6 +57,11 @@ class AppDatabase extends _$AppDatabase {
}
}
}
if (from < 8) {
// Add new tables for separate sender and room data
await m.createTable(chatRooms);
await m.createTable(chatMembers);
}
},
);
@@ -153,6 +160,7 @@ class AppDatabase extends _$AppDatabase {
String roomId,
String query, {
bool? withAttachments,
Future<SnAccount?> Function(String accountId)? fetchAccount,
}) async {
var selectStatement = select(chatMessages)
..where((m) => m.roomId.equals(roomId));
@@ -178,7 +186,11 @@ class AppDatabase extends _$AppDatabase {
await (selectStatement
..orderBy([(m) => OrderingTerm.desc(m.createdAt)]))
.get();
return messages.map((msg) => companionToMessage(msg)).toList();
final messageFutures =
messages
.map((msg) => companionToMessage(msg, fetchAccount: fetchAccount))
.toList();
return await Future.wait(messageFutures);
}
// Convert between Drift and model objects
@@ -206,12 +218,88 @@ class AppDatabase extends _$AppDatabase {
);
}
LocalChatMessage companionToMessage(ChatMessage dbMessage) {
Future<LocalChatMessage> companionToMessage(
ChatMessage dbMessage, {
Future<SnAccount?> Function(String accountId)? fetchAccount,
}) async {
final data = jsonDecode(dbMessage.data);
SnChatMember? sender;
try {
final senderRow =
await (select(chatMembers)
..where((m) => m.id.equals(dbMessage.senderId))).getSingle();
SnAccount senderAccount;
senderAccount = SnAccount.fromJson(senderRow.account);
sender = SnChatMember(
id: senderRow.id,
chatRoomId: senderRow.chatRoomId,
accountId: senderRow.accountId,
account: senderAccount,
nick: senderRow.nick,
role: senderRow.role,
notify: senderRow.notify,
joinedAt: senderRow.joinedAt,
breakUntil: senderRow.breakUntil,
timeoutUntil: senderRow.timeoutUntil,
isBot: senderRow.isBot,
status: null,
lastTyped: senderRow.lastTyped,
createdAt: senderRow.createdAt,
updatedAt: senderRow.updatedAt,
deletedAt: senderRow.deletedAt,
chatRoom: null,
);
} catch (err) {
// Fallback to dummy sender with senderId as display name
sender = SnChatMember(
id: 'unknown',
chatRoomId: dbMessage.roomId,
accountId: dbMessage.senderId,
account: SnAccount(
id: 'unknown',
name: 'unknown',
nick: dbMessage.senderId, // Show the ID instead of Unknown
profile: SnAccountProfile(
picture: null,
id: 'unknown',
experience: 0,
level: 1,
levelingProgress: 0.0,
background: null,
verification: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
),
language: '',
isSuperuser: false,
automatedId: null,
perkSubscription: null,
deletedAt: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
nick: dbMessage.senderId, // Show the senderId as fallback
role: 0,
notify: 0,
joinedAt: null,
breakUntil: null,
timeoutUntil: null,
isBot: false,
status: null,
lastTyped: null,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
deletedAt: null,
chatRoom: null,
);
}
return LocalChatMessage(
id: dbMessage.id,
roomId: dbMessage.roomId,
senderId: dbMessage.senderId,
sender: sender,
data: data,
createdAt: dbMessage.createdAt,
status: dbMessage.status,
@@ -231,6 +319,85 @@ class AppDatabase extends _$AppDatabase {
);
}
ChatRoomsCompanion companionFromRoom(SnChatRoom room) {
return ChatRoomsCompanion(
id: Value(room.id),
name: Value(room.name),
description: Value(room.description),
type: Value(room.type),
isPublic: Value(room.isPublic),
isCommunity: Value(room.isCommunity),
picture: Value(room.picture?.toJson()),
background: Value(room.background?.toJson()),
realmId: Value(room.realmId),
createdAt: Value(room.createdAt),
updatedAt: Value(room.updatedAt),
deletedAt: Value(room.deletedAt),
);
}
ChatMembersCompanion companionFromMember(SnChatMember member) {
return ChatMembersCompanion(
id: Value(member.id),
chatRoomId: Value(member.chatRoomId),
accountId: Value(member.accountId),
account: Value(member.account.toJson()),
nick: Value(member.nick),
role: Value(member.role),
notify: Value(member.notify),
joinedAt: Value(member.joinedAt),
breakUntil: Value(member.breakUntil),
timeoutUntil: Value(member.timeoutUntil),
isBot: Value(member.isBot),
status: Value(
member.status == null ? null : jsonEncode(member.status!.toJson()),
),
lastTyped: Value(member.lastTyped),
createdAt: Value(member.createdAt),
updatedAt: Value(member.updatedAt),
deletedAt: Value(member.deletedAt),
);
}
Future<void> saveChatRooms(List<SnChatRoom> rooms) async {
await transaction(() async {
// 1. Identify rooms to remove
final remoteRoomIds = rooms.map((r) => r.id).toSet();
final currentRooms = await select(chatRooms).get();
final currentRoomIds = currentRooms.map((r) => r.id).toSet();
final idsToRemove = currentRoomIds.difference(remoteRoomIds);
if (idsToRemove.isNotEmpty) {
final idsList = idsToRemove.toList();
// Remove messages
await (delete(chatMessages)..where((t) => t.roomId.isIn(idsList))).go();
// Remove members
await (delete(chatMembers)
..where((t) => t.chatRoomId.isIn(idsList))).go();
// Remove rooms
await (delete(chatRooms)..where((t) => t.id.isIn(idsList))).go();
}
// 2. Upsert remote rooms
await batch((batch) {
for (final room in rooms) {
batch.insert(
chatRooms,
companionFromRoom(room),
mode: InsertMode.insertOrReplace,
);
for (final member in room.members ?? []) {
batch.insert(
chatMembers,
companionFromMember(member),
mode: InsertMode.insertOrReplace,
);
}
}
});
});
}
// Methods for post drafts
Future<List<SnPost>> getAllPostDrafts() async {
final drafts = await select(postDrafts).get();
@@ -276,4 +443,19 @@ class AppDatabase extends _$AppDatabase {
return await (select(postDrafts)
..where((tbl) => tbl.id.equals(id))).getSingleOrNull();
}
Future<void> saveMember(SnChatMember member) async {
await into(
chatMembers,
).insert(companionFromMember(member), mode: InsertMode.insertOrReplace);
}
Future<int> saveMessageWithSender(LocalChatMessage message) async {
// First save the sender if it exists
if (message.sender != null) {
await saveMember(message.sender!);
}
// Then save the message
return await saveMessage(messageToCompanion(message));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,616 @@
// dart format width=80
import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1;
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
// GENERATED BY drift_dev, DO NOT MODIFY.
final class Schema7 extends i0.VersionedSchema {
Schema7({required super.database}) : super(version: 7);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
chatRooms,
chatMembers,
chatMessages,
postDrafts,
];
late final Shape0 chatRooms = Shape0(
source: i0.VersionedTable(
entityName: 'chat_rooms',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
_column_4,
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
_column_10,
_column_11,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape1 chatMembers = Shape1(
source: i0.VersionedTable(
entityName: 'chat_members',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_12,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_22,
_column_23,
_column_9,
_column_10,
_column_11,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape2 chatMessages = Shape2(
source: i0.VersionedTable(
entityName: 'chat_messages',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_24,
_column_25,
_column_26,
_column_27,
_column_28,
_column_9,
_column_29,
_column_30,
_column_31,
_column_11,
_column_32,
_column_33,
_column_34,
_column_35,
_column_36,
_column_37,
_column_38,
_column_39,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 postDrafts = Shape3(
source: i0.VersionedTable(
entityName: 'post_drafts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_40,
_column_2,
_column_26,
_column_41,
_column_42,
_column_43,
_column_44,
],
attachedDatabase: database,
),
alias: null,
);
}
class Shape0 extends i0.VersionedTable {
Shape0({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get description =>
columnsByName['description']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<bool> get isPublic =>
columnsByName['is_public']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get isCommunity =>
columnsByName['is_community']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get picture =>
columnsByName['picture']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get background =>
columnsByName['background']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get realmId =>
columnsByName['realm_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_0(String aliasedName) =>
i1.GeneratedColumn<String>(
'id',
aliasedName,
false,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
i1.GeneratedColumn<String>(
'name',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<String> _column_2(String aliasedName) =>
i1.GeneratedColumn<String>(
'description',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<int> _column_3(String aliasedName) =>
i1.GeneratedColumn<int>(
'type',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i1.GeneratedColumn<bool> _column_4(String aliasedName) =>
i1.GeneratedColumn<bool>(
'is_public',
aliasedName,
true,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_public" IN (0, 1))',
),
defaultValue: const CustomExpression('0'),
);
i1.GeneratedColumn<bool> _column_5(String aliasedName) =>
i1.GeneratedColumn<bool>(
'is_community',
aliasedName,
true,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_community" IN (0, 1))',
),
defaultValue: const CustomExpression('0'),
);
i1.GeneratedColumn<String> _column_6(String aliasedName) =>
i1.GeneratedColumn<String>(
'picture',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<String> _column_7(String aliasedName) =>
i1.GeneratedColumn<String>(
'background',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<String> _column_8(String aliasedName) =>
i1.GeneratedColumn<String>(
'realm_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<DateTime> _column_9(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: i1.DriftSqlType.dateTime,
);
i1.GeneratedColumn<DateTime> _column_10(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'updated_at',
aliasedName,
false,
type: i1.DriftSqlType.dateTime,
);
i1.GeneratedColumn<DateTime> _column_11(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'deleted_at',
aliasedName,
true,
type: i1.DriftSqlType.dateTime,
);
class Shape1 extends i0.VersionedTable {
Shape1({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get chatRoomId =>
columnsByName['chat_room_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get accountId =>
columnsByName['account_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get account =>
columnsByName['account']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get nick =>
columnsByName['nick']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get role =>
columnsByName['role']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get notify =>
columnsByName['notify']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get joinedAt =>
columnsByName['joined_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get breakUntil =>
columnsByName['break_until']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get timeoutUntil =>
columnsByName['timeout_until']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<bool> get isBot =>
columnsByName['is_bot']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get status =>
columnsByName['status']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get lastTyped =>
columnsByName['last_typed']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<String> _column_12(String aliasedName) =>
i1.GeneratedColumn<String>(
'chat_room_id',
aliasedName,
false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES chat_rooms (id)',
),
);
i1.GeneratedColumn<String> _column_13(String aliasedName) =>
i1.GeneratedColumn<String>(
'account_id',
aliasedName,
false,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<String> _column_14(String aliasedName) =>
i1.GeneratedColumn<String>(
'account',
aliasedName,
false,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<String> _column_15(String aliasedName) =>
i1.GeneratedColumn<String>(
'nick',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<int> _column_16(String aliasedName) =>
i1.GeneratedColumn<int>(
'role',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i1.GeneratedColumn<int> _column_17(String aliasedName) =>
i1.GeneratedColumn<int>(
'notify',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i1.GeneratedColumn<DateTime> _column_18(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'joined_at',
aliasedName,
true,
type: i1.DriftSqlType.dateTime,
);
i1.GeneratedColumn<DateTime> _column_19(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'break_until',
aliasedName,
true,
type: i1.DriftSqlType.dateTime,
);
i1.GeneratedColumn<DateTime> _column_20(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'timeout_until',
aliasedName,
true,
type: i1.DriftSqlType.dateTime,
);
i1.GeneratedColumn<bool> _column_21(String aliasedName) =>
i1.GeneratedColumn<bool>(
'is_bot',
aliasedName,
false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_bot" IN (0, 1))',
),
);
i1.GeneratedColumn<String> _column_22(String aliasedName) =>
i1.GeneratedColumn<String>(
'status',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<DateTime> _column_23(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'last_typed',
aliasedName,
true,
type: i1.DriftSqlType.dateTime,
);
class Shape2 extends i0.VersionedTable {
Shape2({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get roomId =>
columnsByName['room_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get senderId =>
columnsByName['sender_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get nonce =>
columnsByName['nonce']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get data =>
columnsByName['data']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get status =>
columnsByName['status']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<bool> get isDeleted =>
columnsByName['is_deleted']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get type =>
columnsByName['type']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get meta =>
columnsByName['meta']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get membersMentioned =>
columnsByName['members_mentioned']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get editedAt =>
columnsByName['edited_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get attachments =>
columnsByName['attachments']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get reactions =>
columnsByName['reactions']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get repliedMessageId =>
columnsByName['replied_message_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get forwardedMessageId =>
columnsByName['forwarded_message_id']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_24(String aliasedName) =>
i1.GeneratedColumn<String>(
'room_id',
aliasedName,
false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES chat_rooms (id)',
),
);
i1.GeneratedColumn<String> _column_25(String aliasedName) =>
i1.GeneratedColumn<String>(
'sender_id',
aliasedName,
false,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES chat_members (id)',
),
);
i1.GeneratedColumn<String> _column_26(String aliasedName) =>
i1.GeneratedColumn<String>(
'content',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<String> _column_27(String aliasedName) =>
i1.GeneratedColumn<String>(
'nonce',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<String> _column_28(String aliasedName) =>
i1.GeneratedColumn<String>(
'data',
aliasedName,
false,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<int> _column_29(String aliasedName) =>
i1.GeneratedColumn<int>(
'status',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i1.GeneratedColumn<bool> _column_30(String aliasedName) =>
i1.GeneratedColumn<bool>(
'is_deleted',
aliasedName,
true,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_deleted" IN (0, 1))',
),
defaultValue: const CustomExpression('0'),
);
i1.GeneratedColumn<DateTime> _column_31(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'updated_at',
aliasedName,
true,
type: i1.DriftSqlType.dateTime,
);
i1.GeneratedColumn<String> _column_32(String aliasedName) =>
i1.GeneratedColumn<String>(
'type',
aliasedName,
false,
type: i1.DriftSqlType.string,
defaultValue: const CustomExpression('\'text\''),
);
i1.GeneratedColumn<String> _column_33(String aliasedName) =>
i1.GeneratedColumn<String>(
'meta',
aliasedName,
false,
type: i1.DriftSqlType.string,
defaultValue: const CustomExpression('\'{}\''),
);
i1.GeneratedColumn<String> _column_34(String aliasedName) =>
i1.GeneratedColumn<String>(
'members_mentioned',
aliasedName,
false,
type: i1.DriftSqlType.string,
defaultValue: const CustomExpression('\'[]\''),
);
i1.GeneratedColumn<DateTime> _column_35(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'edited_at',
aliasedName,
true,
type: i1.DriftSqlType.dateTime,
);
i1.GeneratedColumn<String> _column_36(String aliasedName) =>
i1.GeneratedColumn<String>(
'attachments',
aliasedName,
false,
type: i1.DriftSqlType.string,
defaultValue: const CustomExpression('\'[]\''),
);
i1.GeneratedColumn<String> _column_37(String aliasedName) =>
i1.GeneratedColumn<String>(
'reactions',
aliasedName,
false,
type: i1.DriftSqlType.string,
defaultValue: const CustomExpression('\'[]\''),
);
i1.GeneratedColumn<String> _column_38(String aliasedName) =>
i1.GeneratedColumn<String>(
'replied_message_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<String> _column_39(String aliasedName) =>
i1.GeneratedColumn<String>(
'forwarded_message_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
class Shape3 extends i0.VersionedTable {
Shape3({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get title =>
columnsByName['title']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get description =>
columnsByName['description']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get visibility =>
columnsByName['visibility']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get lastModified =>
columnsByName['last_modified']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get postData =>
columnsByName['post_data']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_40(String aliasedName) =>
i1.GeneratedColumn<String>(
'title',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
i1.GeneratedColumn<int> _column_41(String aliasedName) =>
i1.GeneratedColumn<int>(
'visibility',
aliasedName,
false,
type: i1.DriftSqlType.int,
defaultValue: const CustomExpression('0'),
);
i1.GeneratedColumn<int> _column_42(String aliasedName) =>
i1.GeneratedColumn<int>(
'type',
aliasedName,
false,
type: i1.DriftSqlType.int,
defaultValue: const CustomExpression('0'),
);
i1.GeneratedColumn<DateTime> _column_43(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'last_modified',
aliasedName,
false,
type: i1.DriftSqlType.dateTime,
);
i1.GeneratedColumn<String> _column_44(String aliasedName) =>
i1.GeneratedColumn<String>(
'post_data',
aliasedName,
false,
type: i1.DriftSqlType.string,
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
case 6:
final schema = Schema7(database: database);
final migrator = i1.Migrator(database, schema);
await from6To7(migrator, schema);
return 7;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
};
}
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(from6To7: from6To7),
);

View File

@@ -36,10 +36,52 @@ class ListMapConverter
String toSql(List<Map<String, dynamic>> value) => json.encode(value);
}
class ChatRooms extends Table {
TextColumn get id => text()();
TextColumn get name => text().nullable()();
TextColumn get description => text().nullable()();
IntColumn get type => integer()();
BoolColumn get isPublic =>
boolean().nullable().withDefault(const Constant(false))();
BoolColumn get isCommunity =>
boolean().nullable().withDefault(const Constant(false))();
TextColumn get picture => text().map(const MapConverter()).nullable()();
TextColumn get background => text().map(const MapConverter()).nullable()();
TextColumn get realmId => text().nullable()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get deletedAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}
class ChatMembers extends Table {
TextColumn get id => text()();
TextColumn get chatRoomId => text().references(ChatRooms, #id)();
TextColumn get accountId => text()();
TextColumn get account => text().map(const MapConverter())();
TextColumn get nick => text().nullable()();
IntColumn get role => integer()();
IntColumn get notify => integer()();
DateTimeColumn get joinedAt => dateTime().nullable()();
DateTimeColumn get breakUntil => dateTime().nullable()();
DateTimeColumn get timeoutUntil => dateTime().nullable()();
BoolColumn get isBot => boolean()();
TextColumn get status => text().nullable()();
DateTimeColumn get lastTyped => dateTime().nullable()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get deletedAt => dateTime().nullable()();
@override
Set<Column> get primaryKey => {id};
}
class ChatMessages extends Table {
TextColumn get id => text()();
TextColumn get roomId => text()();
TextColumn get senderId => text()();
TextColumn get roomId => text().references(ChatRooms, #id)();
TextColumn get senderId => text().references(ChatMembers, #id)();
TextColumn get content => text().nullable()();
TextColumn get nonce => text().nullable()();
TextColumn get data => text()();
@@ -72,6 +114,7 @@ class LocalChatMessage {
final String id;
final String roomId;
final String senderId;
final SnChatMember? sender;
final Map<String, dynamic> data;
final DateTime createdAt;
MessageStatus status;
@@ -94,6 +137,7 @@ class LocalChatMessage {
required this.id,
required this.roomId,
required this.senderId,
required this.sender,
required this.data,
required this.createdAt,
required this.nonce,
@@ -114,7 +158,12 @@ class LocalChatMessage {
});
SnChatMessage toRemoteMessage() {
return SnChatMessage.fromJson(data);
if (sender == null) {
throw Exception('Cannot create remote message without sender');
}
final msgData = Map<String, dynamic>.from(data);
msgData['sender'] = sender!.toJson();
return SnChatMessage.fromJson(msgData);
}
static LocalChatMessage fromRemoteMessage(
@@ -122,11 +171,26 @@ class LocalChatMessage {
MessageStatus status, {
String? nonce,
}) {
final jsonData = message.toJson();
jsonData.remove('sender');
// Ensure proper defaults for collections to prevent type cast errors
if (jsonData['meta'] == null) jsonData['meta'] = <String, dynamic>{};
if (jsonData['members_mentioned'] == null) {
jsonData['members_mentioned'] = <String>[];
}
if (jsonData['attachments'] == null) {
jsonData['attachments'] = <Map<String, dynamic>>[];
}
if (jsonData['reactions'] == null) {
jsonData['reactions'] = <Map<String, dynamic>>[];
}
final msgData = Map<String, dynamic>.from(jsonData);
return LocalChatMessage(
id: message.id,
roomId: message.chatRoomId,
senderId: message.senderId,
data: message.toJson(),
sender: message.sender,
data: msgData,
createdAt: message.createdAt,
status: status,
nonce: nonce ?? message.nonce,

View File

@@ -59,6 +59,8 @@ void main() async {
try {
await EasyLocalization.ensureInitialized();
// Disable logs
EasyLocalization.logger.enableBuildModes = [];
if (kIsWeb || !Platform.isLinux) {
await Firebase.initializeApp(

View File

@@ -1,4 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/activity.dart';
import 'package:island/models/auth.dart';
import 'package:island/models/file.dart';
import 'package:island/models/wallet.dart';
@@ -263,3 +264,15 @@ sealed class SnSocialCreditRecord with _$SnSocialCreditRecord {
factory SnSocialCreditRecord.fromJson(Map<String, dynamic> json) =>
_$SnSocialCreditRecordFromJson(json);
}
@freezed
sealed class SnFriendOverviewItem with _$SnFriendOverviewItem {
const factory SnFriendOverviewItem({
required SnAccount account,
required SnAccountStatus status,
required List<SnPresenceActivity> activities,
}) = _SnFriendOverviewItem;
factory SnFriendOverviewItem.fromJson(Map<String, dynamic> json) =>
_$SnFriendOverviewItemFromJson(json);
}

View File

@@ -3912,4 +3912,309 @@ as DateTime?,
}
/// @nodoc
mixin _$SnFriendOverviewItem {
SnAccount get account; SnAccountStatus get status; List<SnPresenceActivity> get activities;
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnFriendOverviewItemCopyWith<SnFriendOverviewItem> get copyWith => _$SnFriendOverviewItemCopyWithImpl<SnFriendOverviewItem>(this as SnFriendOverviewItem, _$identity);
/// Serializes this SnFriendOverviewItem to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnFriendOverviewItem&&(identical(other.account, account) || other.account == account)&&(identical(other.status, status) || other.status == status)&&const DeepCollectionEquality().equals(other.activities, activities));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,account,status,const DeepCollectionEquality().hash(activities));
@override
String toString() {
return 'SnFriendOverviewItem(account: $account, status: $status, activities: $activities)';
}
}
/// @nodoc
abstract mixin class $SnFriendOverviewItemCopyWith<$Res> {
factory $SnFriendOverviewItemCopyWith(SnFriendOverviewItem value, $Res Function(SnFriendOverviewItem) _then) = _$SnFriendOverviewItemCopyWithImpl;
@useResult
$Res call({
SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities
});
$SnAccountCopyWith<$Res> get account;$SnAccountStatusCopyWith<$Res> get status;
}
/// @nodoc
class _$SnFriendOverviewItemCopyWithImpl<$Res>
implements $SnFriendOverviewItemCopyWith<$Res> {
_$SnFriendOverviewItemCopyWithImpl(this._self, this._then);
final SnFriendOverviewItem _self;
final $Res Function(SnFriendOverviewItem) _then;
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? account = null,Object? status = null,Object? activities = null,}) {
return _then(_self.copyWith(
account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as SnAccountStatus,activities: null == activities ? _self.activities : activities // ignore: cast_nullable_to_non_nullable
as List<SnPresenceActivity>,
));
}
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
return _then(_self.copyWith(account: value));
});
}/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountStatusCopyWith<$Res> get status {
return $SnAccountStatusCopyWith<$Res>(_self.status, (value) {
return _then(_self.copyWith(status: value));
});
}
}
/// Adds pattern-matching-related methods to [SnFriendOverviewItem].
extension SnFriendOverviewItemPatterns on SnFriendOverviewItem {
/// 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( _SnFriendOverviewItem value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnFriendOverviewItem() 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( _SnFriendOverviewItem value) $default,){
final _that = this;
switch (_that) {
case _SnFriendOverviewItem():
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( _SnFriendOverviewItem value)? $default,){
final _that = this;
switch (_that) {
case _SnFriendOverviewItem() 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( SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnFriendOverviewItem() when $default != null:
return $default(_that.account,_that.status,_that.activities);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( SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities) $default,) {final _that = this;
switch (_that) {
case _SnFriendOverviewItem():
return $default(_that.account,_that.status,_that.activities);}
}
/// 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( SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities)? $default,) {final _that = this;
switch (_that) {
case _SnFriendOverviewItem() when $default != null:
return $default(_that.account,_that.status,_that.activities);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SnFriendOverviewItem implements SnFriendOverviewItem {
const _SnFriendOverviewItem({required this.account, required this.status, required final List<SnPresenceActivity> activities}): _activities = activities;
factory _SnFriendOverviewItem.fromJson(Map<String, dynamic> json) => _$SnFriendOverviewItemFromJson(json);
@override final SnAccount account;
@override final SnAccountStatus status;
final List<SnPresenceActivity> _activities;
@override List<SnPresenceActivity> get activities {
if (_activities is EqualUnmodifiableListView) return _activities;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_activities);
}
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnFriendOverviewItemCopyWith<_SnFriendOverviewItem> get copyWith => __$SnFriendOverviewItemCopyWithImpl<_SnFriendOverviewItem>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnFriendOverviewItemToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnFriendOverviewItem&&(identical(other.account, account) || other.account == account)&&(identical(other.status, status) || other.status == status)&&const DeepCollectionEquality().equals(other._activities, _activities));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,account,status,const DeepCollectionEquality().hash(_activities));
@override
String toString() {
return 'SnFriendOverviewItem(account: $account, status: $status, activities: $activities)';
}
}
/// @nodoc
abstract mixin class _$SnFriendOverviewItemCopyWith<$Res> implements $SnFriendOverviewItemCopyWith<$Res> {
factory _$SnFriendOverviewItemCopyWith(_SnFriendOverviewItem value, $Res Function(_SnFriendOverviewItem) _then) = __$SnFriendOverviewItemCopyWithImpl;
@override @useResult
$Res call({
SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities
});
@override $SnAccountCopyWith<$Res> get account;@override $SnAccountStatusCopyWith<$Res> get status;
}
/// @nodoc
class __$SnFriendOverviewItemCopyWithImpl<$Res>
implements _$SnFriendOverviewItemCopyWith<$Res> {
__$SnFriendOverviewItemCopyWithImpl(this._self, this._then);
final _SnFriendOverviewItem _self;
final $Res Function(_SnFriendOverviewItem) _then;
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? account = null,Object? status = null,Object? activities = null,}) {
return _then(_SnFriendOverviewItem(
account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as SnAccountStatus,activities: null == activities ? _self._activities : activities // ignore: cast_nullable_to_non_nullable
as List<SnPresenceActivity>,
));
}
/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
return _then(_self.copyWith(account: value));
});
}/// Create a copy of SnFriendOverviewItem
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountStatusCopyWith<$Res> get status {
return $SnAccountStatusCopyWith<$Res>(_self.status, (value) {
return _then(_self.copyWith(status: value));
});
}
}
// dart format on

View File

@@ -449,3 +449,22 @@ Map<String, dynamic> _$SnSocialCreditRecordToJson(
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnFriendOverviewItem _$SnFriendOverviewItemFromJson(
Map<String, dynamic> json,
) => _SnFriendOverviewItem(
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
status: SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
activities:
(json['activities'] as List<dynamic>)
.map((e) => SnPresenceActivity.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$SnFriendOverviewItemToJson(
_SnFriendOverviewItem instance,
) => <String, dynamic>{
'account': instance.account.toJson(),
'status': instance.status.toJson(),
'activities': instance.activities.map((e) => e.toJson()).toList(),
};

View File

@@ -0,0 +1,57 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/file.dart';
part 'drive_task.freezed.dart';
part 'drive_task.g.dart';
enum DriveTaskStatus {
pending,
inProgress,
paused,
completed,
failed,
expired,
cancelled,
}
@freezed
sealed class DriveTask with _$DriveTask {
const DriveTask._();
const factory DriveTask({
required String id,
required String taskId,
required String fileName,
required String contentType,
required int fileSize,
required int uploadedBytes,
required int totalChunks,
required int uploadedChunks,
required DriveTaskStatus status,
required DateTime createdAt,
required DateTime updatedAt,
required String type, // Task type (e.g., 'FileUpload')
double? transmissionProgress, // Local file upload progress (0.0-1.0)
String? errorMessage,
String? statusMessage,
SnCloudFile? result,
String? poolId,
String? bundleId,
String? encryptPassword,
String? expiredAt,
}) = _DriveTask;
factory DriveTask.fromJson(Map<String, dynamic> json) =>
_$DriveTaskFromJson(json);
double get progress => totalChunks > 0 ? uploadedChunks / totalChunks : 0.0;
Duration get estimatedTimeRemaining {
if (uploadedBytes == 0 || fileSize == 0) return Duration.zero;
final remainingBytes = fileSize - uploadedBytes;
final uploadRate =
uploadedBytes / createdAt.difference(DateTime.now()).inSeconds.abs();
if (uploadRate == 0) return Duration.zero;
return Duration(seconds: (remainingBytes / uploadRate).round());
}
}

View File

@@ -0,0 +1,356 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'drive_task.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$DriveTask {
String get id; String get taskId; String get fileName; String get contentType; int get fileSize; int get uploadedBytes; int get totalChunks; int get uploadedChunks; DriveTaskStatus get status; DateTime get createdAt; DateTime get updatedAt; String get type;// Task type (e.g., 'FileUpload')
double? get transmissionProgress;// Local file upload progress (0.0-1.0)
String? get errorMessage; String? get statusMessage; SnCloudFile? get result; String? get poolId; String? get bundleId; String? get encryptPassword; String? get expiredAt;
/// Create a copy of DriveTask
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$DriveTaskCopyWith<DriveTask> get copyWith => _$DriveTaskCopyWithImpl<DriveTask>(this as DriveTask, _$identity);
/// Serializes this DriveTask to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is DriveTask&&(identical(other.id, id) || other.id == id)&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.fileName, fileName) || other.fileName == fileName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.fileSize, fileSize) || other.fileSize == fileSize)&&(identical(other.uploadedBytes, uploadedBytes) || other.uploadedBytes == uploadedBytes)&&(identical(other.totalChunks, totalChunks) || other.totalChunks == totalChunks)&&(identical(other.uploadedChunks, uploadedChunks) || other.uploadedChunks == uploadedChunks)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.type, type) || other.type == type)&&(identical(other.transmissionProgress, transmissionProgress) || other.transmissionProgress == transmissionProgress)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.statusMessage, statusMessage) || other.statusMessage == statusMessage)&&(identical(other.result, result) || other.result == result)&&(identical(other.poolId, poolId) || other.poolId == poolId)&&(identical(other.bundleId, bundleId) || other.bundleId == bundleId)&&(identical(other.encryptPassword, encryptPassword) || other.encryptPassword == encryptPassword)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hashAll([runtimeType,id,taskId,fileName,contentType,fileSize,uploadedBytes,totalChunks,uploadedChunks,status,createdAt,updatedAt,type,transmissionProgress,errorMessage,statusMessage,result,poolId,bundleId,encryptPassword,expiredAt]);
@override
String toString() {
return 'DriveTask(id: $id, taskId: $taskId, fileName: $fileName, contentType: $contentType, fileSize: $fileSize, uploadedBytes: $uploadedBytes, totalChunks: $totalChunks, uploadedChunks: $uploadedChunks, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, type: $type, transmissionProgress: $transmissionProgress, errorMessage: $errorMessage, statusMessage: $statusMessage, result: $result, poolId: $poolId, bundleId: $bundleId, encryptPassword: $encryptPassword, expiredAt: $expiredAt)';
}
}
/// @nodoc
abstract mixin class $DriveTaskCopyWith<$Res> {
factory $DriveTaskCopyWith(DriveTask value, $Res Function(DriveTask) _then) = _$DriveTaskCopyWithImpl;
@useResult
$Res call({
String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, DriveTaskStatus status, DateTime createdAt, DateTime updatedAt, String type, double? transmissionProgress, String? errorMessage, String? statusMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt
});
$SnCloudFileCopyWith<$Res>? get result;
}
/// @nodoc
class _$DriveTaskCopyWithImpl<$Res>
implements $DriveTaskCopyWith<$Res> {
_$DriveTaskCopyWithImpl(this._self, this._then);
final DriveTask _self;
final $Res Function(DriveTask) _then;
/// Create a copy of DriveTask
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? taskId = null,Object? fileName = null,Object? contentType = null,Object? fileSize = null,Object? uploadedBytes = null,Object? totalChunks = null,Object? uploadedChunks = null,Object? status = null,Object? createdAt = null,Object? updatedAt = null,Object? type = null,Object? transmissionProgress = freezed,Object? errorMessage = freezed,Object? statusMessage = freezed,Object? result = freezed,Object? poolId = freezed,Object? bundleId = freezed,Object? encryptPassword = freezed,Object? expiredAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable
as String,fileName: null == fileName ? _self.fileName : fileName // ignore: cast_nullable_to_non_nullable
as String,contentType: null == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
as String,fileSize: null == fileSize ? _self.fileSize : fileSize // ignore: cast_nullable_to_non_nullable
as int,uploadedBytes: null == uploadedBytes ? _self.uploadedBytes : uploadedBytes // ignore: cast_nullable_to_non_nullable
as int,totalChunks: null == totalChunks ? _self.totalChunks : totalChunks // ignore: cast_nullable_to_non_nullable
as int,uploadedChunks: null == uploadedChunks ? _self.uploadedChunks : uploadedChunks // ignore: cast_nullable_to_non_nullable
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as DriveTaskStatus,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,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,transmissionProgress: freezed == transmissionProgress ? _self.transmissionProgress : transmissionProgress // ignore: cast_nullable_to_non_nullable
as double?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String?,statusMessage: freezed == statusMessage ? _self.statusMessage : statusMessage // ignore: cast_nullable_to_non_nullable
as String?,result: freezed == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,poolId: freezed == poolId ? _self.poolId : poolId // ignore: cast_nullable_to_non_nullable
as String?,bundleId: freezed == bundleId ? _self.bundleId : bundleId // ignore: cast_nullable_to_non_nullable
as String?,encryptPassword: freezed == encryptPassword ? _self.encryptPassword : encryptPassword // ignore: cast_nullable_to_non_nullable
as String?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as String?,
));
}
/// Create a copy of DriveTask
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileCopyWith<$Res>? get result {
if (_self.result == null) {
return null;
}
return $SnCloudFileCopyWith<$Res>(_self.result!, (value) {
return _then(_self.copyWith(result: value));
});
}
}
/// Adds pattern-matching-related methods to [DriveTask].
extension DriveTaskPatterns on DriveTask {
/// 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( _DriveTask value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _DriveTask() 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( _DriveTask value) $default,){
final _that = this;
switch (_that) {
case _DriveTask():
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( _DriveTask value)? $default,){
final _that = this;
switch (_that) {
case _DriveTask() 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( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, DriveTaskStatus status, DateTime createdAt, DateTime updatedAt, String type, double? transmissionProgress, String? errorMessage, String? statusMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _DriveTask() when $default != null:
return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.type,_that.transmissionProgress,_that.errorMessage,_that.statusMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);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( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, DriveTaskStatus status, DateTime createdAt, DateTime updatedAt, String type, double? transmissionProgress, String? errorMessage, String? statusMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt) $default,) {final _that = this;
switch (_that) {
case _DriveTask():
return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.type,_that.transmissionProgress,_that.errorMessage,_that.statusMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);}
}
/// 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( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, DriveTaskStatus status, DateTime createdAt, DateTime updatedAt, String type, double? transmissionProgress, String? errorMessage, String? statusMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt)? $default,) {final _that = this;
switch (_that) {
case _DriveTask() when $default != null:
return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.type,_that.transmissionProgress,_that.errorMessage,_that.statusMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _DriveTask extends DriveTask {
const _DriveTask({required this.id, required this.taskId, required this.fileName, required this.contentType, required this.fileSize, required this.uploadedBytes, required this.totalChunks, required this.uploadedChunks, required this.status, required this.createdAt, required this.updatedAt, required this.type, this.transmissionProgress, this.errorMessage, this.statusMessage, this.result, this.poolId, this.bundleId, this.encryptPassword, this.expiredAt}): super._();
factory _DriveTask.fromJson(Map<String, dynamic> json) => _$DriveTaskFromJson(json);
@override final String id;
@override final String taskId;
@override final String fileName;
@override final String contentType;
@override final int fileSize;
@override final int uploadedBytes;
@override final int totalChunks;
@override final int uploadedChunks;
@override final DriveTaskStatus status;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final String type;
// Task type (e.g., 'FileUpload')
@override final double? transmissionProgress;
// Local file upload progress (0.0-1.0)
@override final String? errorMessage;
@override final String? statusMessage;
@override final SnCloudFile? result;
@override final String? poolId;
@override final String? bundleId;
@override final String? encryptPassword;
@override final String? expiredAt;
/// Create a copy of DriveTask
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$DriveTaskCopyWith<_DriveTask> get copyWith => __$DriveTaskCopyWithImpl<_DriveTask>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$DriveTaskToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _DriveTask&&(identical(other.id, id) || other.id == id)&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.fileName, fileName) || other.fileName == fileName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.fileSize, fileSize) || other.fileSize == fileSize)&&(identical(other.uploadedBytes, uploadedBytes) || other.uploadedBytes == uploadedBytes)&&(identical(other.totalChunks, totalChunks) || other.totalChunks == totalChunks)&&(identical(other.uploadedChunks, uploadedChunks) || other.uploadedChunks == uploadedChunks)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.type, type) || other.type == type)&&(identical(other.transmissionProgress, transmissionProgress) || other.transmissionProgress == transmissionProgress)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.statusMessage, statusMessage) || other.statusMessage == statusMessage)&&(identical(other.result, result) || other.result == result)&&(identical(other.poolId, poolId) || other.poolId == poolId)&&(identical(other.bundleId, bundleId) || other.bundleId == bundleId)&&(identical(other.encryptPassword, encryptPassword) || other.encryptPassword == encryptPassword)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hashAll([runtimeType,id,taskId,fileName,contentType,fileSize,uploadedBytes,totalChunks,uploadedChunks,status,createdAt,updatedAt,type,transmissionProgress,errorMessage,statusMessage,result,poolId,bundleId,encryptPassword,expiredAt]);
@override
String toString() {
return 'DriveTask(id: $id, taskId: $taskId, fileName: $fileName, contentType: $contentType, fileSize: $fileSize, uploadedBytes: $uploadedBytes, totalChunks: $totalChunks, uploadedChunks: $uploadedChunks, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, type: $type, transmissionProgress: $transmissionProgress, errorMessage: $errorMessage, statusMessage: $statusMessage, result: $result, poolId: $poolId, bundleId: $bundleId, encryptPassword: $encryptPassword, expiredAt: $expiredAt)';
}
}
/// @nodoc
abstract mixin class _$DriveTaskCopyWith<$Res> implements $DriveTaskCopyWith<$Res> {
factory _$DriveTaskCopyWith(_DriveTask value, $Res Function(_DriveTask) _then) = __$DriveTaskCopyWithImpl;
@override @useResult
$Res call({
String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, DriveTaskStatus status, DateTime createdAt, DateTime updatedAt, String type, double? transmissionProgress, String? errorMessage, String? statusMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt
});
@override $SnCloudFileCopyWith<$Res>? get result;
}
/// @nodoc
class __$DriveTaskCopyWithImpl<$Res>
implements _$DriveTaskCopyWith<$Res> {
__$DriveTaskCopyWithImpl(this._self, this._then);
final _DriveTask _self;
final $Res Function(_DriveTask) _then;
/// Create a copy of DriveTask
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? taskId = null,Object? fileName = null,Object? contentType = null,Object? fileSize = null,Object? uploadedBytes = null,Object? totalChunks = null,Object? uploadedChunks = null,Object? status = null,Object? createdAt = null,Object? updatedAt = null,Object? type = null,Object? transmissionProgress = freezed,Object? errorMessage = freezed,Object? statusMessage = freezed,Object? result = freezed,Object? poolId = freezed,Object? bundleId = freezed,Object? encryptPassword = freezed,Object? expiredAt = freezed,}) {
return _then(_DriveTask(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable
as String,fileName: null == fileName ? _self.fileName : fileName // ignore: cast_nullable_to_non_nullable
as String,contentType: null == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
as String,fileSize: null == fileSize ? _self.fileSize : fileSize // ignore: cast_nullable_to_non_nullable
as int,uploadedBytes: null == uploadedBytes ? _self.uploadedBytes : uploadedBytes // ignore: cast_nullable_to_non_nullable
as int,totalChunks: null == totalChunks ? _self.totalChunks : totalChunks // ignore: cast_nullable_to_non_nullable
as int,uploadedChunks: null == uploadedChunks ? _self.uploadedChunks : uploadedChunks // ignore: cast_nullable_to_non_nullable
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as DriveTaskStatus,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,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,transmissionProgress: freezed == transmissionProgress ? _self.transmissionProgress : transmissionProgress // ignore: cast_nullable_to_non_nullable
as double?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
as String?,statusMessage: freezed == statusMessage ? _self.statusMessage : statusMessage // ignore: cast_nullable_to_non_nullable
as String?,result: freezed == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,poolId: freezed == poolId ? _self.poolId : poolId // ignore: cast_nullable_to_non_nullable
as String?,bundleId: freezed == bundleId ? _self.bundleId : bundleId // ignore: cast_nullable_to_non_nullable
as String?,encryptPassword: freezed == encryptPassword ? _self.encryptPassword : encryptPassword // ignore: cast_nullable_to_non_nullable
as String?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as String?,
));
}
/// Create a copy of DriveTask
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileCopyWith<$Res>? get result {
if (_self.result == null) {
return null;
}
return $SnCloudFileCopyWith<$Res>(_self.result!, (value) {
return _then(_self.copyWith(result: value));
});
}
}
// dart format on

View File

@@ -0,0 +1,67 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'drive_task.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_DriveTask _$DriveTaskFromJson(Map<String, dynamic> json) => _DriveTask(
id: json['id'] as String,
taskId: json['task_id'] as String,
fileName: json['file_name'] as String,
contentType: json['content_type'] as String,
fileSize: (json['file_size'] as num).toInt(),
uploadedBytes: (json['uploaded_bytes'] as num).toInt(),
totalChunks: (json['total_chunks'] as num).toInt(),
uploadedChunks: (json['uploaded_chunks'] as num).toInt(),
status: $enumDecode(_$DriveTaskStatusEnumMap, json['status']),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
type: json['type'] as String,
transmissionProgress: (json['transmission_progress'] as num?)?.toDouble(),
errorMessage: json['error_message'] as String?,
statusMessage: json['status_message'] as String?,
result:
json['result'] == null
? null
: SnCloudFile.fromJson(json['result'] as Map<String, dynamic>),
poolId: json['pool_id'] as String?,
bundleId: json['bundle_id'] as String?,
encryptPassword: json['encrypt_password'] as String?,
expiredAt: json['expired_at'] as String?,
);
Map<String, dynamic> _$DriveTaskToJson(_DriveTask instance) =>
<String, dynamic>{
'id': instance.id,
'task_id': instance.taskId,
'file_name': instance.fileName,
'content_type': instance.contentType,
'file_size': instance.fileSize,
'uploaded_bytes': instance.uploadedBytes,
'total_chunks': instance.totalChunks,
'uploaded_chunks': instance.uploadedChunks,
'status': _$DriveTaskStatusEnumMap[instance.status]!,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'type': instance.type,
'transmission_progress': instance.transmissionProgress,
'error_message': instance.errorMessage,
'status_message': instance.statusMessage,
'result': instance.result?.toJson(),
'pool_id': instance.poolId,
'bundle_id': instance.bundleId,
'encrypt_password': instance.encryptPassword,
'expired_at': instance.expiredAt,
};
const _$DriveTaskStatusEnumMap = {
DriveTaskStatus.pending: 'pending',
DriveTaskStatus.inProgress: 'inProgress',
DriveTaskStatus.paused: 'paused',
DriveTaskStatus.completed: 'completed',
DriveTaskStatus.failed: 'failed',
DriveTaskStatus.expired: 'expired',
DriveTaskStatus.cancelled: 'cancelled',
};

View File

@@ -60,3 +60,19 @@ sealed class SnCloudFile with _$SnCloudFile {
factory SnCloudFile.fromJson(Map<String, dynamic> json) =>
_$SnCloudFileFromJson(json);
}
@freezed
sealed class SnCloudFileIndex with _$SnCloudFileIndex {
const factory SnCloudFileIndex({
required String id,
required String path,
required String fileId,
required SnCloudFile file,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnCloudFileIndex;
factory SnCloudFileIndex.fromJson(Map<String, dynamic> json) =>
_$SnCloudFileIndexFromJson(json);
}

View File

@@ -622,4 +622,297 @@ $SnFilePoolCopyWith<$Res>? get pool {
}
}
/// @nodoc
mixin _$SnCloudFileIndex {
String get id; String get path; String get fileId; SnCloudFile get file; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnCloudFileIndex
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnCloudFileIndexCopyWith<SnCloudFileIndex> get copyWith => _$SnCloudFileIndexCopyWithImpl<SnCloudFileIndex>(this as SnCloudFileIndex, _$identity);
/// Serializes this SnCloudFileIndex to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFileIndex&&(identical(other.id, id) || other.id == id)&&(identical(other.path, path) || other.path == path)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(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)
@override
int get hashCode => Object.hash(runtimeType,id,path,fileId,file,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnCloudFileIndex(id: $id, path: $path, fileId: $fileId, file: $file, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class $SnCloudFileIndexCopyWith<$Res> {
factory $SnCloudFileIndexCopyWith(SnCloudFileIndex value, $Res Function(SnCloudFileIndex) _then) = _$SnCloudFileIndexCopyWithImpl;
@useResult
$Res call({
String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
$SnCloudFileCopyWith<$Res> get file;
}
/// @nodoc
class _$SnCloudFileIndexCopyWithImpl<$Res>
implements $SnCloudFileIndexCopyWith<$Res> {
_$SnCloudFileIndexCopyWithImpl(this._self, this._then);
final SnCloudFileIndex _self;
final $Res Function(SnCloudFileIndex) _then;
/// Create a copy of SnCloudFileIndex
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? path = null,Object? fileId = null,Object? file = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
as String,file: null == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
as SnCloudFile,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
/// Create a copy of SnCloudFileIndex
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileCopyWith<$Res> get file {
return $SnCloudFileCopyWith<$Res>(_self.file, (value) {
return _then(_self.copyWith(file: value));
});
}
}
/// Adds pattern-matching-related methods to [SnCloudFileIndex].
extension SnCloudFileIndexPatterns on SnCloudFileIndex {
/// 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( _SnCloudFileIndex value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnCloudFileIndex() 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( _SnCloudFileIndex value) $default,){
final _that = this;
switch (_that) {
case _SnCloudFileIndex():
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( _SnCloudFileIndex value)? $default,){
final _that = this;
switch (_that) {
case _SnCloudFileIndex() 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( String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnCloudFileIndex() when $default != null:
return $default(_that.id,_that.path,_that.fileId,_that.file,_that.createdAt,_that.updatedAt,_that.deletedAt);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( String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _SnCloudFileIndex():
return $default(_that.id,_that.path,_that.fileId,_that.file,_that.createdAt,_that.updatedAt,_that.deletedAt);}
}
/// 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( String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _SnCloudFileIndex() when $default != null:
return $default(_that.id,_that.path,_that.fileId,_that.file,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SnCloudFileIndex implements SnCloudFileIndex {
const _SnCloudFileIndex({required this.id, required this.path, required this.fileId, required this.file, required this.createdAt, required this.updatedAt, required this.deletedAt});
factory _SnCloudFileIndex.fromJson(Map<String, dynamic> json) => _$SnCloudFileIndexFromJson(json);
@override final String id;
@override final String path;
@override final String fileId;
@override final SnCloudFile file;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final DateTime? deletedAt;
/// Create a copy of SnCloudFileIndex
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnCloudFileIndexCopyWith<_SnCloudFileIndex> get copyWith => __$SnCloudFileIndexCopyWithImpl<_SnCloudFileIndex>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnCloudFileIndexToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFileIndex&&(identical(other.id, id) || other.id == id)&&(identical(other.path, path) || other.path == path)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(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)
@override
int get hashCode => Object.hash(runtimeType,id,path,fileId,file,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnCloudFileIndex(id: $id, path: $path, fileId: $fileId, file: $file, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class _$SnCloudFileIndexCopyWith<$Res> implements $SnCloudFileIndexCopyWith<$Res> {
factory _$SnCloudFileIndexCopyWith(_SnCloudFileIndex value, $Res Function(_SnCloudFileIndex) _then) = __$SnCloudFileIndexCopyWithImpl;
@override @useResult
$Res call({
String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@override $SnCloudFileCopyWith<$Res> get file;
}
/// @nodoc
class __$SnCloudFileIndexCopyWithImpl<$Res>
implements _$SnCloudFileIndexCopyWith<$Res> {
__$SnCloudFileIndexCopyWithImpl(this._self, this._then);
final _SnCloudFileIndex _self;
final $Res Function(_SnCloudFileIndex) _then;
/// Create a copy of SnCloudFileIndex
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? path = null,Object? fileId = null,Object? file = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnCloudFileIndex(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
as String,file: null == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
as SnCloudFile,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
/// Create a copy of SnCloudFileIndex
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileCopyWith<$Res> get file {
return $SnCloudFileCopyWith<$Res>(_self.file, (value) {
return _then(_self.copyWith(file: value));
});
}
}
// dart format on

View File

@@ -78,3 +78,28 @@ Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) =>
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnCloudFileIndex _$SnCloudFileIndexFromJson(Map<String, dynamic> json) =>
_SnCloudFileIndex(
id: json['id'] as String,
path: json['path'] as String,
fileId: json['file_id'] as String,
file: SnCloudFile.fromJson(json['file'] 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> _$SnCloudFileIndexToJson(_SnCloudFileIndex instance) =>
<String, dynamic>{
'id': instance.id,
'path': instance.path,
'file_id': instance.fileId,
'file': instance.file.toJson(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};

View File

@@ -0,0 +1,12 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/file.dart';
part 'file_list_item.freezed.dart';
@freezed
sealed class FileListItem with _$FileListItem {
const factory FileListItem.file(SnCloudFileIndex fileIndex) = FileItem;
const factory FileListItem.folder(String folderName) = FolderItem;
const factory FileListItem.unindexedFile(SnCloudFile file) =
UnindexedFileItem;
}

View File

@@ -0,0 +1,396 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'file_list_item.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$FileListItem {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is FileListItem);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'FileListItem()';
}
}
/// @nodoc
class $FileListItemCopyWith<$Res> {
$FileListItemCopyWith(FileListItem _, $Res Function(FileListItem) __);
}
/// Adds pattern-matching-related methods to [FileListItem].
extension FileListItemPatterns on FileListItem {
/// 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( FileItem value)? file,TResult Function( FolderItem value)? folder,TResult Function( UnindexedFileItem value)? unindexedFile,required TResult orElse(),}){
final _that = this;
switch (_that) {
case FileItem() when file != null:
return file(_that);case FolderItem() when folder != null:
return folder(_that);case UnindexedFileItem() when unindexedFile != null:
return unindexedFile(_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?>({required TResult Function( FileItem value) file,required TResult Function( FolderItem value) folder,required TResult Function( UnindexedFileItem value) unindexedFile,}){
final _that = this;
switch (_that) {
case FileItem():
return file(_that);case FolderItem():
return folder(_that);case UnindexedFileItem():
return unindexedFile(_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( FileItem value)? file,TResult? Function( FolderItem value)? folder,TResult? Function( UnindexedFileItem value)? unindexedFile,}){
final _that = this;
switch (_that) {
case FileItem() when file != null:
return file(_that);case FolderItem() when folder != null:
return folder(_that);case UnindexedFileItem() when unindexedFile != null:
return unindexedFile(_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( SnCloudFileIndex fileIndex)? file,TResult Function( String folderName)? folder,TResult Function( SnCloudFile file)? unindexedFile,required TResult orElse(),}) {final _that = this;
switch (_that) {
case FileItem() when file != null:
return file(_that.fileIndex);case FolderItem() when folder != null:
return folder(_that.folderName);case UnindexedFileItem() when unindexedFile != null:
return unindexedFile(_that.file);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?>({required TResult Function( SnCloudFileIndex fileIndex) file,required TResult Function( String folderName) folder,required TResult Function( SnCloudFile file) unindexedFile,}) {final _that = this;
switch (_that) {
case FileItem():
return file(_that.fileIndex);case FolderItem():
return folder(_that.folderName);case UnindexedFileItem():
return unindexedFile(_that.file);}
}
/// 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( SnCloudFileIndex fileIndex)? file,TResult? Function( String folderName)? folder,TResult? Function( SnCloudFile file)? unindexedFile,}) {final _that = this;
switch (_that) {
case FileItem() when file != null:
return file(_that.fileIndex);case FolderItem() when folder != null:
return folder(_that.folderName);case UnindexedFileItem() when unindexedFile != null:
return unindexedFile(_that.file);case _:
return null;
}
}
}
/// @nodoc
class FileItem implements FileListItem {
const FileItem(this.fileIndex);
final SnCloudFileIndex fileIndex;
/// Create a copy of FileListItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$FileItemCopyWith<FileItem> get copyWith => _$FileItemCopyWithImpl<FileItem>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is FileItem&&(identical(other.fileIndex, fileIndex) || other.fileIndex == fileIndex));
}
@override
int get hashCode => Object.hash(runtimeType,fileIndex);
@override
String toString() {
return 'FileListItem.file(fileIndex: $fileIndex)';
}
}
/// @nodoc
abstract mixin class $FileItemCopyWith<$Res> implements $FileListItemCopyWith<$Res> {
factory $FileItemCopyWith(FileItem value, $Res Function(FileItem) _then) = _$FileItemCopyWithImpl;
@useResult
$Res call({
SnCloudFileIndex fileIndex
});
$SnCloudFileIndexCopyWith<$Res> get fileIndex;
}
/// @nodoc
class _$FileItemCopyWithImpl<$Res>
implements $FileItemCopyWith<$Res> {
_$FileItemCopyWithImpl(this._self, this._then);
final FileItem _self;
final $Res Function(FileItem) _then;
/// Create a copy of FileListItem
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? fileIndex = null,}) {
return _then(FileItem(
null == fileIndex ? _self.fileIndex : fileIndex // ignore: cast_nullable_to_non_nullable
as SnCloudFileIndex,
));
}
/// Create a copy of FileListItem
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileIndexCopyWith<$Res> get fileIndex {
return $SnCloudFileIndexCopyWith<$Res>(_self.fileIndex, (value) {
return _then(_self.copyWith(fileIndex: value));
});
}
}
/// @nodoc
class FolderItem implements FileListItem {
const FolderItem(this.folderName);
final String folderName;
/// Create a copy of FileListItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$FolderItemCopyWith<FolderItem> get copyWith => _$FolderItemCopyWithImpl<FolderItem>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is FolderItem&&(identical(other.folderName, folderName) || other.folderName == folderName));
}
@override
int get hashCode => Object.hash(runtimeType,folderName);
@override
String toString() {
return 'FileListItem.folder(folderName: $folderName)';
}
}
/// @nodoc
abstract mixin class $FolderItemCopyWith<$Res> implements $FileListItemCopyWith<$Res> {
factory $FolderItemCopyWith(FolderItem value, $Res Function(FolderItem) _then) = _$FolderItemCopyWithImpl;
@useResult
$Res call({
String folderName
});
}
/// @nodoc
class _$FolderItemCopyWithImpl<$Res>
implements $FolderItemCopyWith<$Res> {
_$FolderItemCopyWithImpl(this._self, this._then);
final FolderItem _self;
final $Res Function(FolderItem) _then;
/// Create a copy of FileListItem
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? folderName = null,}) {
return _then(FolderItem(
null == folderName ? _self.folderName : folderName // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
class UnindexedFileItem implements FileListItem {
const UnindexedFileItem(this.file);
final SnCloudFile file;
/// Create a copy of FileListItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$UnindexedFileItemCopyWith<UnindexedFileItem> get copyWith => _$UnindexedFileItemCopyWithImpl<UnindexedFileItem>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is UnindexedFileItem&&(identical(other.file, file) || other.file == file));
}
@override
int get hashCode => Object.hash(runtimeType,file);
@override
String toString() {
return 'FileListItem.unindexedFile(file: $file)';
}
}
/// @nodoc
abstract mixin class $UnindexedFileItemCopyWith<$Res> implements $FileListItemCopyWith<$Res> {
factory $UnindexedFileItemCopyWith(UnindexedFileItem value, $Res Function(UnindexedFileItem) _then) = _$UnindexedFileItemCopyWithImpl;
@useResult
$Res call({
SnCloudFile file
});
$SnCloudFileCopyWith<$Res> get file;
}
/// @nodoc
class _$UnindexedFileItemCopyWithImpl<$Res>
implements $UnindexedFileItemCopyWith<$Res> {
_$UnindexedFileItemCopyWithImpl(this._self, this._then);
final UnindexedFileItem _self;
final $Res Function(UnindexedFileItem) _then;
/// Create a copy of FileListItem
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? file = null,}) {
return _then(UnindexedFileItem(
null == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
as SnCloudFile,
));
}
/// Create a copy of FileListItem
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileCopyWith<$Res> get file {
return $SnCloudFileCopyWith<$Res>(_self.file, (value) {
return _then(_self.copyWith(file: value));
});
}
}
// dart format on

19
lib/models/folder.dart Normal file
View File

@@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'folder.freezed.dart';
part 'folder.g.dart';
@freezed
sealed class SnCloudFolder with _$SnCloudFolder {
const factory SnCloudFolder({
required String id,
required String name,
required String? parentFolderId,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
}) = _SnCloudFolder;
factory SnCloudFolder.fromJson(Map<String, dynamic> json) =>
_$SnCloudFolderFromJson(json);
}

View File

@@ -0,0 +1,286 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'folder.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnCloudFolder {
String get id; String get name; String? get parentFolderId; String get accountId; DateTime get createdAt; DateTime get updatedAt;
/// Create a copy of SnCloudFolder
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnCloudFolderCopyWith<SnCloudFolder> get copyWith => _$SnCloudFolderCopyWithImpl<SnCloudFolder>(this as SnCloudFolder, _$identity);
/// Serializes this SnCloudFolder to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFolder&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.parentFolderId, parentFolderId) || other.parentFolderId == parentFolderId)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,parentFolderId,accountId,createdAt,updatedAt);
@override
String toString() {
return 'SnCloudFolder(id: $id, name: $name, parentFolderId: $parentFolderId, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt)';
}
}
/// @nodoc
abstract mixin class $SnCloudFolderCopyWith<$Res> {
factory $SnCloudFolderCopyWith(SnCloudFolder value, $Res Function(SnCloudFolder) _then) = _$SnCloudFolderCopyWithImpl;
@useResult
$Res call({
String id, String name, String? parentFolderId, String accountId, DateTime createdAt, DateTime updatedAt
});
}
/// @nodoc
class _$SnCloudFolderCopyWithImpl<$Res>
implements $SnCloudFolderCopyWith<$Res> {
_$SnCloudFolderCopyWithImpl(this._self, this._then);
final SnCloudFolder _self;
final $Res Function(SnCloudFolder) _then;
/// Create a copy of SnCloudFolder
/// 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? parentFolderId = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,}) {
return _then(_self.copyWith(
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,parentFolderId: freezed == parentFolderId ? _self.parentFolderId : parentFolderId // ignore: cast_nullable_to_non_nullable
as String?,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 DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
));
}
}
/// Adds pattern-matching-related methods to [SnCloudFolder].
extension SnCloudFolderPatterns on SnCloudFolder {
/// 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( _SnCloudFolder value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnCloudFolder() 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( _SnCloudFolder value) $default,){
final _that = this;
switch (_that) {
case _SnCloudFolder():
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( _SnCloudFolder value)? $default,){
final _that = this;
switch (_that) {
case _SnCloudFolder() 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( String id, String name, String? parentFolderId, String accountId, DateTime createdAt, DateTime updatedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnCloudFolder() when $default != null:
return $default(_that.id,_that.name,_that.parentFolderId,_that.accountId,_that.createdAt,_that.updatedAt);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( String id, String name, String? parentFolderId, String accountId, DateTime createdAt, DateTime updatedAt) $default,) {final _that = this;
switch (_that) {
case _SnCloudFolder():
return $default(_that.id,_that.name,_that.parentFolderId,_that.accountId,_that.createdAt,_that.updatedAt);}
}
/// 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( String id, String name, String? parentFolderId, String accountId, DateTime createdAt, DateTime updatedAt)? $default,) {final _that = this;
switch (_that) {
case _SnCloudFolder() when $default != null:
return $default(_that.id,_that.name,_that.parentFolderId,_that.accountId,_that.createdAt,_that.updatedAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SnCloudFolder implements SnCloudFolder {
const _SnCloudFolder({required this.id, required this.name, required this.parentFolderId, required this.accountId, required this.createdAt, required this.updatedAt});
factory _SnCloudFolder.fromJson(Map<String, dynamic> json) => _$SnCloudFolderFromJson(json);
@override final String id;
@override final String name;
@override final String? parentFolderId;
@override final String accountId;
@override final DateTime createdAt;
@override final DateTime updatedAt;
/// Create a copy of SnCloudFolder
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnCloudFolderCopyWith<_SnCloudFolder> get copyWith => __$SnCloudFolderCopyWithImpl<_SnCloudFolder>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnCloudFolderToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFolder&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.parentFolderId, parentFolderId) || other.parentFolderId == parentFolderId)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,parentFolderId,accountId,createdAt,updatedAt);
@override
String toString() {
return 'SnCloudFolder(id: $id, name: $name, parentFolderId: $parentFolderId, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt)';
}
}
/// @nodoc
abstract mixin class _$SnCloudFolderCopyWith<$Res> implements $SnCloudFolderCopyWith<$Res> {
factory _$SnCloudFolderCopyWith(_SnCloudFolder value, $Res Function(_SnCloudFolder) _then) = __$SnCloudFolderCopyWithImpl;
@override @useResult
$Res call({
String id, String name, String? parentFolderId, String accountId, DateTime createdAt, DateTime updatedAt
});
}
/// @nodoc
class __$SnCloudFolderCopyWithImpl<$Res>
implements _$SnCloudFolderCopyWith<$Res> {
__$SnCloudFolderCopyWithImpl(this._self, this._then);
final _SnCloudFolder _self;
final $Res Function(_SnCloudFolder) _then;
/// Create a copy of SnCloudFolder
/// 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? parentFolderId = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,}) {
return _then(_SnCloudFolder(
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,parentFolderId: freezed == parentFolderId ? _self.parentFolderId : parentFolderId // ignore: cast_nullable_to_non_nullable
as String?,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 DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
));
}
}
// dart format on

27
lib/models/folder.g.dart Normal file
View File

@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'folder.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnCloudFolder _$SnCloudFolderFromJson(Map<String, dynamic> json) =>
_SnCloudFolder(
id: json['id'] as String,
name: json['name'] as String,
parentFolderId: json['parent_folder_id'] as String?,
accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
Map<String, dynamic> _$SnCloudFolderToJson(_SnCloudFolder instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'parent_folder_id': instance.parentFolderId,
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
};

View File

@@ -46,6 +46,18 @@ sealed class SnPoll with _$SnPoll {
}) = _SnPoll;
factory SnPoll.fromJson(Map<String, dynamic> json) => _$SnPollFromJson(json);
factory SnPoll.fromPollWithStats(SnPollWithStats pollWithStats) => SnPoll(
id: pollWithStats.id,
questions: pollWithStats.questions,
title: pollWithStats.title,
description: pollWithStats.description,
endedAt: pollWithStats.endedAt,
publisherId: pollWithStats.publisherId,
createdAt: pollWithStats.createdAt,
updatedAt: pollWithStats.updatedAt,
deletedAt: pollWithStats.deletedAt,
);
}
@freezed

View File

@@ -0,0 +1,41 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'publication_site.freezed.dart';
part 'publication_site.g.dart';
@freezed
sealed class SnPublicationSite with _$SnPublicationSite {
const factory SnPublicationSite({
required String id,
required String slug,
required String name,
String? description,
int? mode,
required String publisherId,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
required List<SnPublicationPage> pages,
}) = _SnPublicationSite;
factory SnPublicationSite.fromJson(Map<String, dynamic> json) =>
_$SnPublicationSiteFromJson(json);
}
@freezed
sealed class SnPublicationPage with _$SnPublicationPage {
const factory SnPublicationPage({
required String id,
String? preset,
String? path,
Map<String, dynamic>? config,
required String siteId,
required DateTime createdAt,
required DateTime updatedAt,
}) = _SnPublicationPage;
factory SnPublicationPage.fromJson(Map<String, dynamic> json) =>
_$SnPublicationPageFromJson(json);
}
enum PublicationPagePreset { landing, profile, posts, custom }

View File

@@ -0,0 +1,587 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'publication_site.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnPublicationSite {
String get id; String get slug; String get name; String? get description; int? get mode; String get publisherId; String get accountId; DateTime get createdAt; DateTime get updatedAt; List<SnPublicationPage> get pages;
/// Create a copy of SnPublicationSite
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnPublicationSiteCopyWith<SnPublicationSite> get copyWith => _$SnPublicationSiteCopyWithImpl<SnPublicationSite>(this as SnPublicationSite, _$identity);
/// Serializes this SnPublicationSite to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPublicationSite&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.mode, mode) || other.mode == mode)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&const DeepCollectionEquality().equals(other.pages, pages));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,slug,name,description,mode,publisherId,accountId,createdAt,updatedAt,const DeepCollectionEquality().hash(pages));
@override
String toString() {
return 'SnPublicationSite(id: $id, slug: $slug, name: $name, description: $description, mode: $mode, publisherId: $publisherId, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, pages: $pages)';
}
}
/// @nodoc
abstract mixin class $SnPublicationSiteCopyWith<$Res> {
factory $SnPublicationSiteCopyWith(SnPublicationSite value, $Res Function(SnPublicationSite) _then) = _$SnPublicationSiteCopyWithImpl;
@useResult
$Res call({
String id, String slug, String name, String? description, int? mode, String publisherId, String accountId, DateTime createdAt, DateTime updatedAt, List<SnPublicationPage> pages
});
}
/// @nodoc
class _$SnPublicationSiteCopyWithImpl<$Res>
implements $SnPublicationSiteCopyWith<$Res> {
_$SnPublicationSiteCopyWithImpl(this._self, this._then);
final SnPublicationSite _self;
final $Res Function(SnPublicationSite) _then;
/// Create a copy of SnPublicationSite
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? slug = null,Object? name = null,Object? description = freezed,Object? mode = freezed,Object? publisherId = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? pages = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,slug: null == slug ? _self.slug : slug // 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?,mode: freezed == mode ? _self.mode : mode // ignore: cast_nullable_to_non_nullable
as int?,publisherId: null == publisherId ? _self.publisherId : publisherId // ignore: cast_nullable_to_non_nullable
as String,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 DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,pages: null == pages ? _self.pages : pages // ignore: cast_nullable_to_non_nullable
as List<SnPublicationPage>,
));
}
}
/// Adds pattern-matching-related methods to [SnPublicationSite].
extension SnPublicationSitePatterns on SnPublicationSite {
/// 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( _SnPublicationSite value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnPublicationSite() 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( _SnPublicationSite value) $default,){
final _that = this;
switch (_that) {
case _SnPublicationSite():
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( _SnPublicationSite value)? $default,){
final _that = this;
switch (_that) {
case _SnPublicationSite() 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( String id, String slug, String name, String? description, int? mode, String publisherId, String accountId, DateTime createdAt, DateTime updatedAt, List<SnPublicationPage> pages)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnPublicationSite() when $default != null:
return $default(_that.id,_that.slug,_that.name,_that.description,_that.mode,_that.publisherId,_that.accountId,_that.createdAt,_that.updatedAt,_that.pages);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( String id, String slug, String name, String? description, int? mode, String publisherId, String accountId, DateTime createdAt, DateTime updatedAt, List<SnPublicationPage> pages) $default,) {final _that = this;
switch (_that) {
case _SnPublicationSite():
return $default(_that.id,_that.slug,_that.name,_that.description,_that.mode,_that.publisherId,_that.accountId,_that.createdAt,_that.updatedAt,_that.pages);}
}
/// 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( String id, String slug, String name, String? description, int? mode, String publisherId, String accountId, DateTime createdAt, DateTime updatedAt, List<SnPublicationPage> pages)? $default,) {final _that = this;
switch (_that) {
case _SnPublicationSite() when $default != null:
return $default(_that.id,_that.slug,_that.name,_that.description,_that.mode,_that.publisherId,_that.accountId,_that.createdAt,_that.updatedAt,_that.pages);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SnPublicationSite implements SnPublicationSite {
const _SnPublicationSite({required this.id, required this.slug, required this.name, this.description, this.mode, required this.publisherId, required this.accountId, required this.createdAt, required this.updatedAt, required final List<SnPublicationPage> pages}): _pages = pages;
factory _SnPublicationSite.fromJson(Map<String, dynamic> json) => _$SnPublicationSiteFromJson(json);
@override final String id;
@override final String slug;
@override final String name;
@override final String? description;
@override final int? mode;
@override final String publisherId;
@override final String accountId;
@override final DateTime createdAt;
@override final DateTime updatedAt;
final List<SnPublicationPage> _pages;
@override List<SnPublicationPage> get pages {
if (_pages is EqualUnmodifiableListView) return _pages;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_pages);
}
/// Create a copy of SnPublicationSite
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnPublicationSiteCopyWith<_SnPublicationSite> get copyWith => __$SnPublicationSiteCopyWithImpl<_SnPublicationSite>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnPublicationSiteToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPublicationSite&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.mode, mode) || other.mode == mode)&&(identical(other.publisherId, publisherId) || other.publisherId == publisherId)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&const DeepCollectionEquality().equals(other._pages, _pages));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,slug,name,description,mode,publisherId,accountId,createdAt,updatedAt,const DeepCollectionEquality().hash(_pages));
@override
String toString() {
return 'SnPublicationSite(id: $id, slug: $slug, name: $name, description: $description, mode: $mode, publisherId: $publisherId, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, pages: $pages)';
}
}
/// @nodoc
abstract mixin class _$SnPublicationSiteCopyWith<$Res> implements $SnPublicationSiteCopyWith<$Res> {
factory _$SnPublicationSiteCopyWith(_SnPublicationSite value, $Res Function(_SnPublicationSite) _then) = __$SnPublicationSiteCopyWithImpl;
@override @useResult
$Res call({
String id, String slug, String name, String? description, int? mode, String publisherId, String accountId, DateTime createdAt, DateTime updatedAt, List<SnPublicationPage> pages
});
}
/// @nodoc
class __$SnPublicationSiteCopyWithImpl<$Res>
implements _$SnPublicationSiteCopyWith<$Res> {
__$SnPublicationSiteCopyWithImpl(this._self, this._then);
final _SnPublicationSite _self;
final $Res Function(_SnPublicationSite) _then;
/// Create a copy of SnPublicationSite
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? slug = null,Object? name = null,Object? description = freezed,Object? mode = freezed,Object? publisherId = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? pages = null,}) {
return _then(_SnPublicationSite(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,slug: null == slug ? _self.slug : slug // 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?,mode: freezed == mode ? _self.mode : mode // ignore: cast_nullable_to_non_nullable
as int?,publisherId: null == publisherId ? _self.publisherId : publisherId // ignore: cast_nullable_to_non_nullable
as String,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 DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,pages: null == pages ? _self._pages : pages // ignore: cast_nullable_to_non_nullable
as List<SnPublicationPage>,
));
}
}
/// @nodoc
mixin _$SnPublicationPage {
String get id; String? get preset; String? get path; Map<String, dynamic>? get config; String get siteId; DateTime get createdAt; DateTime get updatedAt;
/// Create a copy of SnPublicationPage
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnPublicationPageCopyWith<SnPublicationPage> get copyWith => _$SnPublicationPageCopyWithImpl<SnPublicationPage>(this as SnPublicationPage, _$identity);
/// Serializes this SnPublicationPage to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPublicationPage&&(identical(other.id, id) || other.id == id)&&(identical(other.preset, preset) || other.preset == preset)&&(identical(other.path, path) || other.path == path)&&const DeepCollectionEquality().equals(other.config, config)&&(identical(other.siteId, siteId) || other.siteId == siteId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,preset,path,const DeepCollectionEquality().hash(config),siteId,createdAt,updatedAt);
@override
String toString() {
return 'SnPublicationPage(id: $id, preset: $preset, path: $path, config: $config, siteId: $siteId, createdAt: $createdAt, updatedAt: $updatedAt)';
}
}
/// @nodoc
abstract mixin class $SnPublicationPageCopyWith<$Res> {
factory $SnPublicationPageCopyWith(SnPublicationPage value, $Res Function(SnPublicationPage) _then) = _$SnPublicationPageCopyWithImpl;
@useResult
$Res call({
String id, String? preset, String? path, Map<String, dynamic>? config, String siteId, DateTime createdAt, DateTime updatedAt
});
}
/// @nodoc
class _$SnPublicationPageCopyWithImpl<$Res>
implements $SnPublicationPageCopyWith<$Res> {
_$SnPublicationPageCopyWithImpl(this._self, this._then);
final SnPublicationPage _self;
final $Res Function(SnPublicationPage) _then;
/// Create a copy of SnPublicationPage
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? preset = freezed,Object? path = freezed,Object? config = freezed,Object? siteId = null,Object? createdAt = null,Object? updatedAt = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,preset: freezed == preset ? _self.preset : preset // ignore: cast_nullable_to_non_nullable
as String?,path: freezed == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
as String?,config: freezed == config ? _self.config : config // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,siteId: null == siteId ? _self.siteId : siteId // 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,
));
}
}
/// Adds pattern-matching-related methods to [SnPublicationPage].
extension SnPublicationPagePatterns on SnPublicationPage {
/// 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( _SnPublicationPage value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnPublicationPage() 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( _SnPublicationPage value) $default,){
final _that = this;
switch (_that) {
case _SnPublicationPage():
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( _SnPublicationPage value)? $default,){
final _that = this;
switch (_that) {
case _SnPublicationPage() 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( String id, String? preset, String? path, Map<String, dynamic>? config, String siteId, DateTime createdAt, DateTime updatedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnPublicationPage() when $default != null:
return $default(_that.id,_that.preset,_that.path,_that.config,_that.siteId,_that.createdAt,_that.updatedAt);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( String id, String? preset, String? path, Map<String, dynamic>? config, String siteId, DateTime createdAt, DateTime updatedAt) $default,) {final _that = this;
switch (_that) {
case _SnPublicationPage():
return $default(_that.id,_that.preset,_that.path,_that.config,_that.siteId,_that.createdAt,_that.updatedAt);}
}
/// 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( String id, String? preset, String? path, Map<String, dynamic>? config, String siteId, DateTime createdAt, DateTime updatedAt)? $default,) {final _that = this;
switch (_that) {
case _SnPublicationPage() when $default != null:
return $default(_that.id,_that.preset,_that.path,_that.config,_that.siteId,_that.createdAt,_that.updatedAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SnPublicationPage implements SnPublicationPage {
const _SnPublicationPage({required this.id, this.preset, this.path, final Map<String, dynamic>? config, required this.siteId, required this.createdAt, required this.updatedAt}): _config = config;
factory _SnPublicationPage.fromJson(Map<String, dynamic> json) => _$SnPublicationPageFromJson(json);
@override final String id;
@override final String? preset;
@override final String? path;
final Map<String, dynamic>? _config;
@override Map<String, dynamic>? get config {
final value = _config;
if (value == null) return null;
if (_config is EqualUnmodifiableMapView) return _config;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(value);
}
@override final String siteId;
@override final DateTime createdAt;
@override final DateTime updatedAt;
/// Create a copy of SnPublicationPage
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnPublicationPageCopyWith<_SnPublicationPage> get copyWith => __$SnPublicationPageCopyWithImpl<_SnPublicationPage>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnPublicationPageToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPublicationPage&&(identical(other.id, id) || other.id == id)&&(identical(other.preset, preset) || other.preset == preset)&&(identical(other.path, path) || other.path == path)&&const DeepCollectionEquality().equals(other._config, _config)&&(identical(other.siteId, siteId) || other.siteId == siteId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,preset,path,const DeepCollectionEquality().hash(_config),siteId,createdAt,updatedAt);
@override
String toString() {
return 'SnPublicationPage(id: $id, preset: $preset, path: $path, config: $config, siteId: $siteId, createdAt: $createdAt, updatedAt: $updatedAt)';
}
}
/// @nodoc
abstract mixin class _$SnPublicationPageCopyWith<$Res> implements $SnPublicationPageCopyWith<$Res> {
factory _$SnPublicationPageCopyWith(_SnPublicationPage value, $Res Function(_SnPublicationPage) _then) = __$SnPublicationPageCopyWithImpl;
@override @useResult
$Res call({
String id, String? preset, String? path, Map<String, dynamic>? config, String siteId, DateTime createdAt, DateTime updatedAt
});
}
/// @nodoc
class __$SnPublicationPageCopyWithImpl<$Res>
implements _$SnPublicationPageCopyWith<$Res> {
__$SnPublicationPageCopyWithImpl(this._self, this._then);
final _SnPublicationPage _self;
final $Res Function(_SnPublicationPage) _then;
/// Create a copy of SnPublicationPage
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? preset = freezed,Object? path = freezed,Object? config = freezed,Object? siteId = null,Object? createdAt = null,Object? updatedAt = null,}) {
return _then(_SnPublicationPage(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,preset: freezed == preset ? _self.preset : preset // ignore: cast_nullable_to_non_nullable
as String?,path: freezed == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
as String?,config: freezed == config ? _self._config : config // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,siteId: null == siteId ? _self.siteId : siteId // 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,
));
}
}
// dart format on

View File

@@ -0,0 +1,60 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'publication_site.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnPublicationSite _$SnPublicationSiteFromJson(Map<String, dynamic> json) =>
_SnPublicationSite(
id: json['id'] as String,
slug: json['slug'] as String,
name: json['name'] as String,
description: json['description'] as String?,
mode: (json['mode'] as num?)?.toInt(),
publisherId: json['publisher_id'] as String,
accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
pages:
(json['pages'] as List<dynamic>)
.map((e) => SnPublicationPage.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$SnPublicationSiteToJson(_SnPublicationSite instance) =>
<String, dynamic>{
'id': instance.id,
'slug': instance.slug,
'name': instance.name,
'description': instance.description,
'mode': instance.mode,
'publisher_id': instance.publisherId,
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'pages': instance.pages.map((e) => e.toJson()).toList(),
};
_SnPublicationPage _$SnPublicationPageFromJson(Map<String, dynamic> json) =>
_SnPublicationPage(
id: json['id'] as String,
preset: json['preset'] as String?,
path: json['path'] as String?,
config: json['config'] as Map<String, dynamic>?,
siteId: json['site_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
Map<String, dynamic> _$SnPublicationPageToJson(_SnPublicationPage instance) =>
<String, dynamic>{
'id': instance.id,
'preset': instance.preset,
'path': instance.path,
'config': instance.config,
'site_id': instance.siteId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
};

23
lib/models/reference.dart Normal file
View File

@@ -0,0 +1,23 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/file.dart';
part 'reference.freezed.dart';
part 'reference.g.dart';
@freezed
sealed class Reference with _$Reference {
const factory Reference({
required String id,
@JsonKey(name: 'file_id') required String fileId,
SnCloudFile? file,
required String usage,
@JsonKey(name: 'resource_id') required String resourceId,
@JsonKey(name: 'expired_at') DateTime? expiredAt,
@JsonKey(name: 'created_at') required DateTime createdAt,
@JsonKey(name: 'updated_at') required DateTime updatedAt,
@JsonKey(name: 'deleted_at') DateTime? deletedAt,
}) = _Reference;
factory Reference.fromJson(Map<String, dynamic> json) =>
_$ReferenceFromJson(json);
}

View File

@@ -0,0 +1,319 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'reference.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$Reference {
String get id;@JsonKey(name: 'file_id') String get fileId; SnCloudFile? get file; String get usage;@JsonKey(name: 'resource_id') String get resourceId;@JsonKey(name: 'expired_at') DateTime? get expiredAt;@JsonKey(name: 'created_at') DateTime get createdAt;@JsonKey(name: 'updated_at') DateTime get updatedAt;@JsonKey(name: 'deleted_at') DateTime? get deletedAt;
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ReferenceCopyWith<Reference> get copyWith => _$ReferenceCopyWithImpl<Reference>(this as Reference, _$identity);
/// Serializes this Reference to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Reference&&(identical(other.id, id) || other.id == id)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.usage, usage) || other.usage == usage)&&(identical(other.resourceId, resourceId) || other.resourceId == resourceId)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(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)
@override
int get hashCode => Object.hash(runtimeType,id,fileId,file,usage,resourceId,expiredAt,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'Reference(id: $id, fileId: $fileId, file: $file, usage: $usage, resourceId: $resourceId, expiredAt: $expiredAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class $ReferenceCopyWith<$Res> {
factory $ReferenceCopyWith(Reference value, $Res Function(Reference) _then) = _$ReferenceCopyWithImpl;
@useResult
$Res call({
String id,@JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage,@JsonKey(name: 'resource_id') String resourceId,@JsonKey(name: 'expired_at') DateTime? expiredAt,@JsonKey(name: 'created_at') DateTime createdAt,@JsonKey(name: 'updated_at') DateTime updatedAt,@JsonKey(name: 'deleted_at') DateTime? deletedAt
});
$SnCloudFileCopyWith<$Res>? get file;
}
/// @nodoc
class _$ReferenceCopyWithImpl<$Res>
implements $ReferenceCopyWith<$Res> {
_$ReferenceCopyWithImpl(this._self, this._then);
final Reference _self;
final $Res Function(Reference) _then;
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? fileId = null,Object? file = freezed,Object? usage = null,Object? resourceId = null,Object? expiredAt = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
as String,file: freezed == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable
as String,resourceId: null == resourceId ? _self.resourceId : resourceId // ignore: cast_nullable_to_non_nullable
as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileCopyWith<$Res>? get file {
if (_self.file == null) {
return null;
}
return $SnCloudFileCopyWith<$Res>(_self.file!, (value) {
return _then(_self.copyWith(file: value));
});
}
}
/// Adds pattern-matching-related methods to [Reference].
extension ReferencePatterns on Reference {
/// 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( _Reference value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _Reference() 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( _Reference value) $default,){
final _that = this;
switch (_that) {
case _Reference():
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( _Reference value)? $default,){
final _that = this;
switch (_that) {
case _Reference() 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( String id, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Reference() when $default != null:
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);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( String id, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _Reference():
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);}
}
/// 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( String id, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _Reference() when $default != null:
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _Reference implements Reference {
const _Reference({required this.id, @JsonKey(name: 'file_id') required this.fileId, this.file, required this.usage, @JsonKey(name: 'resource_id') required this.resourceId, @JsonKey(name: 'expired_at') this.expiredAt, @JsonKey(name: 'created_at') required this.createdAt, @JsonKey(name: 'updated_at') required this.updatedAt, @JsonKey(name: 'deleted_at') this.deletedAt});
factory _Reference.fromJson(Map<String, dynamic> json) => _$ReferenceFromJson(json);
@override final String id;
@override@JsonKey(name: 'file_id') final String fileId;
@override final SnCloudFile? file;
@override final String usage;
@override@JsonKey(name: 'resource_id') final String resourceId;
@override@JsonKey(name: 'expired_at') final DateTime? expiredAt;
@override@JsonKey(name: 'created_at') final DateTime createdAt;
@override@JsonKey(name: 'updated_at') final DateTime updatedAt;
@override@JsonKey(name: 'deleted_at') final DateTime? deletedAt;
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ReferenceCopyWith<_Reference> get copyWith => __$ReferenceCopyWithImpl<_Reference>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ReferenceToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Reference&&(identical(other.id, id) || other.id == id)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.usage, usage) || other.usage == usage)&&(identical(other.resourceId, resourceId) || other.resourceId == resourceId)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(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)
@override
int get hashCode => Object.hash(runtimeType,id,fileId,file,usage,resourceId,expiredAt,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'Reference(id: $id, fileId: $fileId, file: $file, usage: $usage, resourceId: $resourceId, expiredAt: $expiredAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class _$ReferenceCopyWith<$Res> implements $ReferenceCopyWith<$Res> {
factory _$ReferenceCopyWith(_Reference value, $Res Function(_Reference) _then) = __$ReferenceCopyWithImpl;
@override @useResult
$Res call({
String id,@JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage,@JsonKey(name: 'resource_id') String resourceId,@JsonKey(name: 'expired_at') DateTime? expiredAt,@JsonKey(name: 'created_at') DateTime createdAt,@JsonKey(name: 'updated_at') DateTime updatedAt,@JsonKey(name: 'deleted_at') DateTime? deletedAt
});
@override $SnCloudFileCopyWith<$Res>? get file;
}
/// @nodoc
class __$ReferenceCopyWithImpl<$Res>
implements _$ReferenceCopyWith<$Res> {
__$ReferenceCopyWithImpl(this._self, this._then);
final _Reference _self;
final $Res Function(_Reference) _then;
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? fileId = null,Object? file = freezed,Object? usage = null,Object? resourceId = null,Object? expiredAt = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_Reference(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
as String,file: freezed == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable
as String,resourceId: null == resourceId ? _self.resourceId : resourceId // ignore: cast_nullable_to_non_nullable
as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
/// Create a copy of Reference
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnCloudFileCopyWith<$Res>? get file {
if (_self.file == null) {
return null;
}
return $SnCloudFileCopyWith<$Res>(_self.file!, (value) {
return _then(_self.copyWith(file: value));
});
}
}
// dart format on

View File

@@ -0,0 +1,41 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'reference.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_Reference _$ReferenceFromJson(Map<String, dynamic> json) => _Reference(
id: json['id'] as String,
fileId: json['file_id'] as String,
file:
json['file'] == null
? null
: SnCloudFile.fromJson(json['file'] as Map<String, dynamic>),
usage: json['usage'] as String,
resourceId: json['resource_id'] as String,
expiredAt:
json['expired_at'] == null
? null
: DateTime.parse(json['expired_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> _$ReferenceToJson(_Reference instance) =>
<String, dynamic>{
'id': instance.id,
'file_id': instance.fileId,
'file': instance.file?.toJson(),
'usage': instance.usage,
'resource_id': instance.resourceId,
'expired_at': instance.expiredAt?.toIso8601String(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};

25
lib/models/site_file.dart Normal file
View File

@@ -0,0 +1,25 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'site_file.freezed.dart';
part 'site_file.g.dart';
@freezed
sealed class SnSiteFileEntry with _$SnSiteFileEntry {
const factory SnSiteFileEntry({
required bool isDirectory,
required String relativePath,
required int size, // Size in bytes (0 for directories)
required DateTime modified, // ISO 8601 timestamp
}) = _SnSiteFileEntry;
factory SnSiteFileEntry.fromJson(Map<String, dynamic> json) =>
_$SnSiteFileEntryFromJson(json);
}
@freezed
sealed class SnFileContent with _$SnFileContent {
const factory SnFileContent({required String content}) = _SnFileContent;
factory SnFileContent.fromJson(Map<String, dynamic> json) =>
_$SnFileContentFromJson(json);
}

View File

@@ -0,0 +1,539 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'site_file.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnSiteFileEntry {
bool get isDirectory; String get relativePath; int get size;// Size in bytes (0 for directories)
DateTime get modified;
/// Create a copy of SnSiteFileEntry
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnSiteFileEntryCopyWith<SnSiteFileEntry> get copyWith => _$SnSiteFileEntryCopyWithImpl<SnSiteFileEntry>(this as SnSiteFileEntry, _$identity);
/// Serializes this SnSiteFileEntry to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnSiteFileEntry&&(identical(other.isDirectory, isDirectory) || other.isDirectory == isDirectory)&&(identical(other.relativePath, relativePath) || other.relativePath == relativePath)&&(identical(other.size, size) || other.size == size)&&(identical(other.modified, modified) || other.modified == modified));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,isDirectory,relativePath,size,modified);
@override
String toString() {
return 'SnSiteFileEntry(isDirectory: $isDirectory, relativePath: $relativePath, size: $size, modified: $modified)';
}
}
/// @nodoc
abstract mixin class $SnSiteFileEntryCopyWith<$Res> {
factory $SnSiteFileEntryCopyWith(SnSiteFileEntry value, $Res Function(SnSiteFileEntry) _then) = _$SnSiteFileEntryCopyWithImpl;
@useResult
$Res call({
bool isDirectory, String relativePath, int size, DateTime modified
});
}
/// @nodoc
class _$SnSiteFileEntryCopyWithImpl<$Res>
implements $SnSiteFileEntryCopyWith<$Res> {
_$SnSiteFileEntryCopyWithImpl(this._self, this._then);
final SnSiteFileEntry _self;
final $Res Function(SnSiteFileEntry) _then;
/// Create a copy of SnSiteFileEntry
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isDirectory = null,Object? relativePath = null,Object? size = null,Object? modified = null,}) {
return _then(_self.copyWith(
isDirectory: null == isDirectory ? _self.isDirectory : isDirectory // ignore: cast_nullable_to_non_nullable
as bool,relativePath: null == relativePath ? _self.relativePath : relativePath // ignore: cast_nullable_to_non_nullable
as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable
as int,modified: null == modified ? _self.modified : modified // ignore: cast_nullable_to_non_nullable
as DateTime,
));
}
}
/// Adds pattern-matching-related methods to [SnSiteFileEntry].
extension SnSiteFileEntryPatterns on SnSiteFileEntry {
/// 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( _SnSiteFileEntry value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnSiteFileEntry() 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( _SnSiteFileEntry value) $default,){
final _that = this;
switch (_that) {
case _SnSiteFileEntry():
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( _SnSiteFileEntry value)? $default,){
final _that = this;
switch (_that) {
case _SnSiteFileEntry() 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( bool isDirectory, String relativePath, int size, DateTime modified)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnSiteFileEntry() when $default != null:
return $default(_that.isDirectory,_that.relativePath,_that.size,_that.modified);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( bool isDirectory, String relativePath, int size, DateTime modified) $default,) {final _that = this;
switch (_that) {
case _SnSiteFileEntry():
return $default(_that.isDirectory,_that.relativePath,_that.size,_that.modified);}
}
/// 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( bool isDirectory, String relativePath, int size, DateTime modified)? $default,) {final _that = this;
switch (_that) {
case _SnSiteFileEntry() when $default != null:
return $default(_that.isDirectory,_that.relativePath,_that.size,_that.modified);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SnSiteFileEntry implements SnSiteFileEntry {
const _SnSiteFileEntry({required this.isDirectory, required this.relativePath, required this.size, required this.modified});
factory _SnSiteFileEntry.fromJson(Map<String, dynamic> json) => _$SnSiteFileEntryFromJson(json);
@override final bool isDirectory;
@override final String relativePath;
@override final int size;
// Size in bytes (0 for directories)
@override final DateTime modified;
/// Create a copy of SnSiteFileEntry
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnSiteFileEntryCopyWith<_SnSiteFileEntry> get copyWith => __$SnSiteFileEntryCopyWithImpl<_SnSiteFileEntry>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnSiteFileEntryToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnSiteFileEntry&&(identical(other.isDirectory, isDirectory) || other.isDirectory == isDirectory)&&(identical(other.relativePath, relativePath) || other.relativePath == relativePath)&&(identical(other.size, size) || other.size == size)&&(identical(other.modified, modified) || other.modified == modified));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,isDirectory,relativePath,size,modified);
@override
String toString() {
return 'SnSiteFileEntry(isDirectory: $isDirectory, relativePath: $relativePath, size: $size, modified: $modified)';
}
}
/// @nodoc
abstract mixin class _$SnSiteFileEntryCopyWith<$Res> implements $SnSiteFileEntryCopyWith<$Res> {
factory _$SnSiteFileEntryCopyWith(_SnSiteFileEntry value, $Res Function(_SnSiteFileEntry) _then) = __$SnSiteFileEntryCopyWithImpl;
@override @useResult
$Res call({
bool isDirectory, String relativePath, int size, DateTime modified
});
}
/// @nodoc
class __$SnSiteFileEntryCopyWithImpl<$Res>
implements _$SnSiteFileEntryCopyWith<$Res> {
__$SnSiteFileEntryCopyWithImpl(this._self, this._then);
final _SnSiteFileEntry _self;
final $Res Function(_SnSiteFileEntry) _then;
/// Create a copy of SnSiteFileEntry
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isDirectory = null,Object? relativePath = null,Object? size = null,Object? modified = null,}) {
return _then(_SnSiteFileEntry(
isDirectory: null == isDirectory ? _self.isDirectory : isDirectory // ignore: cast_nullable_to_non_nullable
as bool,relativePath: null == relativePath ? _self.relativePath : relativePath // ignore: cast_nullable_to_non_nullable
as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable
as int,modified: null == modified ? _self.modified : modified // ignore: cast_nullable_to_non_nullable
as DateTime,
));
}
}
/// @nodoc
mixin _$SnFileContent {
String get content;
/// Create a copy of SnFileContent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnFileContentCopyWith<SnFileContent> get copyWith => _$SnFileContentCopyWithImpl<SnFileContent>(this as SnFileContent, _$identity);
/// Serializes this SnFileContent to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnFileContent&&(identical(other.content, content) || other.content == content));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,content);
@override
String toString() {
return 'SnFileContent(content: $content)';
}
}
/// @nodoc
abstract mixin class $SnFileContentCopyWith<$Res> {
factory $SnFileContentCopyWith(SnFileContent value, $Res Function(SnFileContent) _then) = _$SnFileContentCopyWithImpl;
@useResult
$Res call({
String content
});
}
/// @nodoc
class _$SnFileContentCopyWithImpl<$Res>
implements $SnFileContentCopyWith<$Res> {
_$SnFileContentCopyWithImpl(this._self, this._then);
final SnFileContent _self;
final $Res Function(SnFileContent) _then;
/// Create a copy of SnFileContent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? content = null,}) {
return _then(_self.copyWith(
content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [SnFileContent].
extension SnFileContentPatterns on SnFileContent {
/// 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( _SnFileContent value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnFileContent() 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( _SnFileContent value) $default,){
final _that = this;
switch (_that) {
case _SnFileContent():
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( _SnFileContent value)? $default,){
final _that = this;
switch (_that) {
case _SnFileContent() 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( String content)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnFileContent() when $default != null:
return $default(_that.content);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( String content) $default,) {final _that = this;
switch (_that) {
case _SnFileContent():
return $default(_that.content);}
}
/// 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( String content)? $default,) {final _that = this;
switch (_that) {
case _SnFileContent() when $default != null:
return $default(_that.content);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SnFileContent implements SnFileContent {
const _SnFileContent({required this.content});
factory _SnFileContent.fromJson(Map<String, dynamic> json) => _$SnFileContentFromJson(json);
@override final String content;
/// Create a copy of SnFileContent
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnFileContentCopyWith<_SnFileContent> get copyWith => __$SnFileContentCopyWithImpl<_SnFileContent>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnFileContentToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnFileContent&&(identical(other.content, content) || other.content == content));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,content);
@override
String toString() {
return 'SnFileContent(content: $content)';
}
}
/// @nodoc
abstract mixin class _$SnFileContentCopyWith<$Res> implements $SnFileContentCopyWith<$Res> {
factory _$SnFileContentCopyWith(_SnFileContent value, $Res Function(_SnFileContent) _then) = __$SnFileContentCopyWithImpl;
@override @useResult
$Res call({
String content
});
}
/// @nodoc
class __$SnFileContentCopyWithImpl<$Res>
implements _$SnFileContentCopyWith<$Res> {
__$SnFileContentCopyWithImpl(this._self, this._then);
final _SnFileContent _self;
final $Res Function(_SnFileContent) _then;
/// Create a copy of SnFileContent
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? content = null,}) {
return _then(_SnFileContent(
content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

View File

@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'site_file.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnSiteFileEntry _$SnSiteFileEntryFromJson(Map<String, dynamic> json) =>
_SnSiteFileEntry(
isDirectory: json['is_directory'] as bool,
relativePath: json['relative_path'] as String,
size: (json['size'] as num).toInt(),
modified: DateTime.parse(json['modified'] as String),
);
Map<String, dynamic> _$SnSiteFileEntryToJson(_SnSiteFileEntry instance) =>
<String, dynamic>{
'is_directory': instance.isDirectory,
'relative_path': instance.relativePath,
'size': instance.size,
'modified': instance.modified.toIso8601String(),
};
_SnFileContent _$SnFileContentFromJson(Map<String, dynamic> json) =>
_SnFileContent(content: json['content'] as String);
Map<String, dynamic> _$SnFileContentToJson(_SnFileContent instance) =>
<String, dynamic>{'content': instance.content};

View File

@@ -38,6 +38,31 @@ class ThinkingChunkTypeConverter
int toJson(ThinkingChunkType object) => object.value;
}
enum ThinkingMessagePartType {
text(0),
functionCall(1),
functionResult(2);
const ThinkingMessagePartType(this.value);
final int value;
static ThinkingMessagePartType fromValue(int value) {
return values.firstWhere((e) => e.value == value, orElse: () => text);
}
}
class ThinkingMessagePartTypeConverter
implements JsonConverter<ThinkingMessagePartType, int> {
const ThinkingMessagePartTypeConverter();
@override
ThinkingMessagePartType fromJson(int json) =>
ThinkingMessagePartType.fromValue(json);
@override
int toJson(ThinkingMessagePartType object) => object.value;
}
@freezed
sealed class StreamThinkingRequest with _$StreamThinkingRequest {
const factory StreamThinkingRequest({
@@ -46,6 +71,7 @@ sealed class StreamThinkingRequest with _$StreamThinkingRequest {
@Default([]) List<String> accpetProposals,
List<String>? attachedPosts,
List<Map<String, dynamic>>? attachedMessages,
@JsonKey(name: 'service_id') String? serviceId,
}) = _StreamThinkingRequest;
factory StreamThinkingRequest.fromJson(Map<String, dynamic> json) =>
@@ -77,6 +103,43 @@ sealed class SnThinkingChunk with _$SnThinkingChunk {
_$SnThinkingChunkFromJson(json);
}
@freezed
sealed class SnFunctionCall with _$SnFunctionCall {
const factory SnFunctionCall({
required String id,
required String name,
required String arguments,
}) = _SnFunctionCall;
factory SnFunctionCall.fromJson(Map<String, dynamic> json) =>
_$SnFunctionCallFromJson(json);
}
@freezed
sealed class SnFunctionResult with _$SnFunctionResult {
const factory SnFunctionResult({
required String callId,
required dynamic result,
required bool isError,
}) = _SnFunctionResult;
factory SnFunctionResult.fromJson(Map<String, dynamic> json) =>
_$SnFunctionResultFromJson(json);
}
@freezed
sealed class SnThinkingMessagePart with _$SnThinkingMessagePart {
const factory SnThinkingMessagePart({
@ThinkingMessagePartTypeConverter() required ThinkingMessagePartType type,
String? text,
SnFunctionCall? functionCall,
SnFunctionResult? functionResult,
}) = _SnThinkingMessagePart;
factory SnThinkingMessagePart.fromJson(Map<String, dynamic> json) =>
_$SnThinkingMessagePartFromJson(json);
}
@freezed
sealed class SnThinkingSequence with _$SnThinkingSequence {
const factory SnThinkingSequence({
@@ -98,9 +161,8 @@ sealed class SnThinkingSequence with _$SnThinkingSequence {
sealed class SnThinkingThought with _$SnThinkingThought {
const factory SnThinkingThought({
required String id,
String? content,
@Default([]) List<SnThinkingMessagePart> parts,
@Default([]) List<SnCloudFile> files,
@Default([]) List<SnThinkingChunk> chunks,
@ThinkingThoughtRoleConverter() required ThinkingThoughtRole role,
int? tokenCount,
String? modelName,
@@ -114,3 +176,26 @@ sealed class SnThinkingThought with _$SnThinkingThought {
factory SnThinkingThought.fromJson(Map<String, dynamic> json) =>
_$SnThinkingThoughtFromJson(json);
}
@freezed
sealed class ThoughtService with _$ThoughtService {
const factory ThoughtService({
@JsonKey(name: 'service_id') required String serviceId,
required double billingMultiplier,
required int perkLevel,
}) = _ThoughtService;
factory ThoughtService.fromJson(Map<String, dynamic> json) =>
_$ThoughtServiceFromJson(json);
}
@freezed
sealed class ThoughtServicesResponse with _$ThoughtServicesResponse {
const factory ThoughtServicesResponse({
@JsonKey(name: 'default_service') required String defaultService,
required List<ThoughtService> services,
}) = _ThoughtServicesResponse;
factory ThoughtServicesResponse.fromJson(Map<String, dynamic> json) =>
_$ThoughtServicesResponseFromJson(json);
}

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ _StreamThinkingRequest _$StreamThinkingRequestFromJson(
(json['attached_messages'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList(),
serviceId: json['service_id'] as String?,
);
Map<String, dynamic> _$StreamThinkingRequestToJson(
@@ -34,6 +35,7 @@ Map<String, dynamic> _$StreamThinkingRequestToJson(
'accpet_proposals': instance.accpetProposals,
'attached_posts': instance.attachedPosts,
'attached_messages': instance.attachedMessages,
'service_id': instance.serviceId,
};
_SnThinkingChunk _$SnThinkingChunkFromJson(Map<String, dynamic> json) =>
@@ -50,6 +52,64 @@ Map<String, dynamic> _$SnThinkingChunkToJson(_SnThinkingChunk instance) =>
'data': instance.data,
};
_SnFunctionCall _$SnFunctionCallFromJson(Map<String, dynamic> json) =>
_SnFunctionCall(
id: json['id'] as String,
name: json['name'] as String,
arguments: json['arguments'] as String,
);
Map<String, dynamic> _$SnFunctionCallToJson(_SnFunctionCall instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'arguments': instance.arguments,
};
_SnFunctionResult _$SnFunctionResultFromJson(Map<String, dynamic> json) =>
_SnFunctionResult(
callId: json['call_id'] as String,
result: json['result'],
isError: json['is_error'] as bool,
);
Map<String, dynamic> _$SnFunctionResultToJson(_SnFunctionResult instance) =>
<String, dynamic>{
'call_id': instance.callId,
'result': instance.result,
'is_error': instance.isError,
};
_SnThinkingMessagePart _$SnThinkingMessagePartFromJson(
Map<String, dynamic> json,
) => _SnThinkingMessagePart(
type: const ThinkingMessagePartTypeConverter().fromJson(
(json['type'] as num).toInt(),
),
text: json['text'] as String?,
functionCall:
json['function_call'] == null
? null
: SnFunctionCall.fromJson(
json['function_call'] as Map<String, dynamic>,
),
functionResult:
json['function_result'] == null
? null
: SnFunctionResult.fromJson(
json['function_result'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$SnThinkingMessagePartToJson(
_SnThinkingMessagePart instance,
) => <String, dynamic>{
'type': const ThinkingMessagePartTypeConverter().toJson(instance.type),
'text': instance.text,
'function_call': instance.functionCall?.toJson(),
'function_result': instance.functionResult?.toJson(),
};
_SnThinkingSequence _$SnThinkingSequenceFromJson(Map<String, dynamic> json) =>
_SnThinkingSequence(
id: json['id'] as String,
@@ -80,17 +140,19 @@ Map<String, dynamic> _$SnThinkingSequenceToJson(_SnThinkingSequence instance) =>
_SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
_SnThinkingThought(
id: json['id'] as String,
content: json['content'] as String?,
parts:
(json['parts'] as List<dynamic>?)
?.map(
(e) =>
SnThinkingMessagePart.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
files:
(json['files'] as List<dynamic>?)
?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
chunks:
(json['chunks'] as List<dynamic>?)
?.map((e) => SnThinkingChunk.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
role: const ThinkingThoughtRoleConverter().fromJson(
(json['role'] as num).toInt(),
),
@@ -114,9 +176,8 @@ _SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
<String, dynamic>{
'id': instance.id,
'content': instance.content,
'parts': instance.parts.map((e) => e.toJson()).toList(),
'files': instance.files.map((e) => e.toJson()).toList(),
'chunks': instance.chunks.map((e) => e.toJson()).toList(),
'role': const ThinkingThoughtRoleConverter().toJson(instance.role),
'token_count': instance.tokenCount,
'model_name': instance.modelName,
@@ -126,3 +187,34 @@ Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_ThoughtService _$ThoughtServiceFromJson(Map<String, dynamic> json) =>
_ThoughtService(
serviceId: json['service_id'] as String,
billingMultiplier: (json['billing_multiplier'] as num).toDouble(),
perkLevel: (json['perk_level'] as num).toInt(),
);
Map<String, dynamic> _$ThoughtServiceToJson(_ThoughtService instance) =>
<String, dynamic>{
'service_id': instance.serviceId,
'billing_multiplier': instance.billingMultiplier,
'perk_level': instance.perkLevel,
};
_ThoughtServicesResponse _$ThoughtServicesResponseFromJson(
Map<String, dynamic> json,
) => _ThoughtServicesResponse(
defaultService: json['default_service'] as String,
services:
(json['services'] as List<dynamic>)
.map((e) => ThoughtService.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$ThoughtServicesResponseToJson(
_ThoughtServicesResponse instance,
) => <String, dynamic>{
'default_service': instance.defaultService,
'services': instance.services.map((e) => e.toJson()).toList(),
};

View File

@@ -176,6 +176,8 @@ sealed class SnWalletFund with _$SnWalletFund {
required String id,
required String currency,
required double totalAmount,
required double remainingAmount,
required int amountOfSplits,
required int splitType, // 0: even, 1: random
required int
status, // 0: created, 1: partially claimed, 2: fully claimed, 3: expired
@@ -184,6 +186,7 @@ sealed class SnWalletFund with _$SnWalletFund {
required SnAccount? creatorAccount,
required DateTime expiredAt,
required List<SnWalletFundRecipient> recipients,
required bool isOpen,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,

View File

@@ -2553,9 +2553,9 @@ $SnWalletSubscriptionCopyWith<$Res>? get subscription {
/// @nodoc
mixin _$SnWalletFund {
String get id; String get currency; double get totalAmount; int get splitType;// 0: even, 1: random
String get id; String get currency; double get totalAmount; double get remainingAmount; int get amountOfSplits; int get splitType;// 0: even, 1: random
int get status;// 0: created, 1: partially claimed, 2: fully claimed, 3: expired
String? get message; String get creatorAccountId; SnAccount? get creatorAccount; DateTime get expiredAt; List<SnWalletFundRecipient> get recipients; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
String? get message; String get creatorAccountId; SnAccount? get creatorAccount; DateTime get expiredAt; List<SnWalletFundRecipient> get recipients; bool get isOpen; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnWalletFund
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -2568,16 +2568,16 @@ $SnWalletFundCopyWith<SnWalletFund> get copyWith => _$SnWalletFundCopyWithImpl<S
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other.recipients, recipients)&&(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 SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.remainingAmount, remainingAmount) || other.remainingAmount == remainingAmount)&&(identical(other.amountOfSplits, amountOfSplits) || other.amountOfSplits == amountOfSplits)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other.recipients, recipients)&&(identical(other.isOpen, isOpen) || other.isOpen == isOpen)&&(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)
@override
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(recipients),createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,remainingAmount,amountOfSplits,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(recipients),isOpen,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, remainingAmount: $remainingAmount, amountOfSplits: $amountOfSplits, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, isOpen: $isOpen, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -2588,7 +2588,7 @@ abstract mixin class $SnWalletFundCopyWith<$Res> {
factory $SnWalletFundCopyWith(SnWalletFund value, $Res Function(SnWalletFund) _then) = _$SnWalletFundCopyWithImpl;
@useResult
$Res call({
String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -2605,19 +2605,22 @@ class _$SnWalletFundCopyWithImpl<$Res>
/// Create a copy of SnWalletFund
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? remainingAmount = null,Object? amountOfSplits = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? isOpen = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
as String,totalAmount: null == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable
as double,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
as double,remainingAmount: null == remainingAmount ? _self.remainingAmount : remainingAmount // ignore: cast_nullable_to_non_nullable
as double,amountOfSplits: null == amountOfSplits ? _self.amountOfSplits : amountOfSplits // ignore: cast_nullable_to_non_nullable
as int,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as int,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String?,creatorAccountId: null == creatorAccountId ? _self.creatorAccountId : creatorAccountId // ignore: cast_nullable_to_non_nullable
as String,creatorAccount: freezed == creatorAccount ? _self.creatorAccount : creatorAccount // ignore: cast_nullable_to_non_nullable
as SnAccount?,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,recipients: null == recipients ? _self.recipients : recipients // ignore: cast_nullable_to_non_nullable
as List<SnWalletFundRecipient>,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as List<SnWalletFundRecipient>,isOpen: null == isOpen ? _self.isOpen : isOpen // ignore: cast_nullable_to_non_nullable
as bool,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
@@ -2714,10 +2717,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnWalletFund() when $default != null:
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return orElse();
}
@@ -2735,10 +2738,10 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _SnWalletFund():
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);}
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -2752,10 +2755,10 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _SnWalletFund() when $default != null:
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null;
}
@@ -2767,12 +2770,14 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
@JsonSerializable()
class _SnWalletFund implements SnWalletFund {
const _SnWalletFund({required this.id, required this.currency, required this.totalAmount, required this.splitType, required this.status, required this.message, required this.creatorAccountId, required this.creatorAccount, required this.expiredAt, required final List<SnWalletFundRecipient> recipients, required this.createdAt, required this.updatedAt, required this.deletedAt}): _recipients = recipients;
const _SnWalletFund({required this.id, required this.currency, required this.totalAmount, required this.remainingAmount, required this.amountOfSplits, required this.splitType, required this.status, required this.message, required this.creatorAccountId, required this.creatorAccount, required this.expiredAt, required final List<SnWalletFundRecipient> recipients, required this.isOpen, required this.createdAt, required this.updatedAt, required this.deletedAt}): _recipients = recipients;
factory _SnWalletFund.fromJson(Map<String, dynamic> json) => _$SnWalletFundFromJson(json);
@override final String id;
@override final String currency;
@override final double totalAmount;
@override final double remainingAmount;
@override final int amountOfSplits;
@override final int splitType;
// 0: even, 1: random
@override final int status;
@@ -2788,6 +2793,7 @@ class _SnWalletFund implements SnWalletFund {
return EqualUnmodifiableListView(_recipients);
}
@override final bool isOpen;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final DateTime? deletedAt;
@@ -2805,16 +2811,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other._recipients, _recipients)&&(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 _SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.remainingAmount, remainingAmount) || other.remainingAmount == remainingAmount)&&(identical(other.amountOfSplits, amountOfSplits) || other.amountOfSplits == amountOfSplits)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other._recipients, _recipients)&&(identical(other.isOpen, isOpen) || other.isOpen == isOpen)&&(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)
@override
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(_recipients),createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,remainingAmount,amountOfSplits,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(_recipients),isOpen,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, remainingAmount: $remainingAmount, amountOfSplits: $amountOfSplits, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, isOpen: $isOpen, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@@ -2825,7 +2831,7 @@ abstract mixin class _$SnWalletFundCopyWith<$Res> implements $SnWalletFundCopyWi
factory _$SnWalletFundCopyWith(_SnWalletFund value, $Res Function(_SnWalletFund) _then) = __$SnWalletFundCopyWithImpl;
@override @useResult
$Res call({
String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -2842,19 +2848,22 @@ class __$SnWalletFundCopyWithImpl<$Res>
/// Create a copy of SnWalletFund
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? remainingAmount = null,Object? amountOfSplits = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? isOpen = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnWalletFund(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
as String,totalAmount: null == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable
as double,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
as double,remainingAmount: null == remainingAmount ? _self.remainingAmount : remainingAmount // ignore: cast_nullable_to_non_nullable
as double,amountOfSplits: null == amountOfSplits ? _self.amountOfSplits : amountOfSplits // ignore: cast_nullable_to_non_nullable
as int,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as int,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String?,creatorAccountId: null == creatorAccountId ? _self.creatorAccountId : creatorAccountId // ignore: cast_nullable_to_non_nullable
as String,creatorAccount: freezed == creatorAccount ? _self.creatorAccount : creatorAccount // ignore: cast_nullable_to_non_nullable
as SnAccount?,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,recipients: null == recipients ? _self._recipients : recipients // ignore: cast_nullable_to_non_nullable
as List<SnWalletFundRecipient>,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as List<SnWalletFundRecipient>,isOpen: null == isOpen ? _self.isOpen : isOpen // ignore: cast_nullable_to_non_nullable
as bool,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,

View File

@@ -336,6 +336,8 @@ _SnWalletFund _$SnWalletFundFromJson(
id: json['id'] as String,
currency: json['currency'] as String,
totalAmount: (json['total_amount'] as num).toDouble(),
remainingAmount: (json['remaining_amount'] as num).toDouble(),
amountOfSplits: (json['amount_of_splits'] as num).toInt(),
splitType: (json['split_type'] as num).toInt(),
status: (json['status'] as num).toInt(),
message: json['message'] as String?,
@@ -349,6 +351,7 @@ _SnWalletFund _$SnWalletFundFromJson(
(json['recipients'] as List<dynamic>)
.map((e) => SnWalletFundRecipient.fromJson(e as Map<String, dynamic>))
.toList(),
isOpen: json['is_open'] as bool,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt:
@@ -362,6 +365,8 @@ Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
'id': instance.id,
'currency': instance.currency,
'total_amount': instance.totalAmount,
'remaining_amount': instance.remainingAmount,
'amount_of_splits': instance.amountOfSplits,
'split_type': instance.splitType,
'status': instance.status,
'message': instance.message,
@@ -369,6 +374,7 @@ Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
'creator_account': instance.creatorAccount?.toJson(),
'expired_at': instance.expiredAt.toIso8601String(),
'recipients': instance.recipients.map((e) => e.toJson()).toList(),
'is_open': instance.isOpen,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),

View File

@@ -212,8 +212,14 @@ class CallNotifier extends _$CallNotifier {
String? _roomId;
String? get roomId => _roomId;
Future<void> joinRoom(String roomId) async {
if (_roomId == roomId && _room != null) {
SnChatRoom? _chatRoom;
SnChatRoom? get chatRoom => _chatRoom;
Future<void> joinRoom(SnChatRoom room) async {
var roomId = room.id;
if (_roomId == roomId &&
_room != null &&
_room?.connectionState == lk.ConnectionState.connected) {
talker.info('[Call] Call skipped. Already has data');
return;
} else if (_room != null) {
@@ -223,6 +229,7 @@ class CallNotifier extends _$CallNotifier {
}
}
_roomId = roomId;
_chatRoom = room;
if (_room != null) {
await _room!.disconnect();
await _room!.dispose();
@@ -355,6 +362,7 @@ class CallNotifier extends _$CallNotifier {
sourceId: source.id,
maxFrameRate: 30.0,
captureScreenAudio: true,
useiOSBroadcastExtension: true,
),
);
await _localParticipant!.publishVideoTrack(track);

View File

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

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/network.dart';
@@ -6,6 +8,58 @@ import 'package:island/pods/chat/chat_subscribe.dart';
part 'chat_summary.g.dart';
@riverpod
class ChatUnreadCountNotifier extends _$ChatUnreadCountNotifier {
StreamSubscription<WebSocketPacket>? _subscription;
@override
Future<int> build() async {
// Subscribe to websocket events when this provider is built
_subscribeToWebSocket();
// Dispose the subscription when this provider is disposed
ref.onDispose(() {
_subscription?.cancel();
});
try {
final client = ref.read(apiClientProvider);
final response = await client.get('/sphere/chat/unread');
return (response.data as num).toInt();
} catch (_) {
return 0;
}
}
void _subscribeToWebSocket() {
final webSocketService = ref.read(websocketProvider);
_subscription = webSocketService.dataStream.listen((packet) {
if (packet.type == 'messages.new' && packet.data != null) {
final message = SnChatMessage.fromJson(packet.data!);
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
// Only increment if the message is not from the currently subscribed chat
if (message.chatRoomId != currentSubscribed) {
_incrementCounter();
}
}
});
}
Future<void> _incrementCounter() async {
final current = await future;
state = AsyncData(current + 1);
}
Future<void> decrement(int count) async {
final current = await future;
state = AsyncData(math.max(current - count, 0));
}
void clear() async {
state = AsyncData(0);
}
}
@riverpod
class ChatSummary extends _$ChatSummary {
@override
@@ -41,6 +95,14 @@ class ChatSummary extends _$ChatSummary {
state.whenData((summaries) {
final summary = summaries[chatId];
if (summary != null) {
// Decrement global unread count
final unreadToDecrement = summary.unreadCount;
if (unreadToDecrement > 0) {
ref
.read(chatUnreadCountNotifierProvider.notifier)
.decrement(unreadToDecrement);
}
state = AsyncData({
...summaries,
chatId: SnChatSummary(

View File

@@ -6,6 +6,24 @@ part of 'chat_summary.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatUnreadCountNotifierHash() =>
r'b8d93589dc37f772d4c3a07d9afd81c37026e57d';
/// See also [ChatUnreadCountNotifier].
@ProviderFor(ChatUnreadCountNotifier)
final chatUnreadCountNotifierProvider =
AutoDisposeAsyncNotifierProvider<ChatUnreadCountNotifier, int>.internal(
ChatUnreadCountNotifier.new,
name: r'chatUnreadCountNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$chatUnreadCountNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ChatUnreadCountNotifier = AutoDisposeAsyncNotifier<int>;
String _$chatSummaryHash() => r'33815a3bd81d20902b7063e8194fe336930df9b4';
/// See also [ChatSummary].

View File

@@ -3,10 +3,14 @@ import "package:dio/dio.dart";
import "package:drift/drift.dart" show Variable;
import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/database/drift_db.dart";
import "package:island/database/message.dart";
import "package:island/models/account.dart";
import "package:island/models/chat.dart";
import "package:island/models/file.dart";
import "package:island/models/poll.dart";
import "package:island/models/wallet.dart";
import "package:island/pods/database.dart";
import "package:island/pods/lifecycle.dart";
import "package:island/pods/network.dart";
@@ -17,6 +21,7 @@ import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:uuid/uuid.dart";
import "package:island/screens/chat/chat.dart";
import "package:island/pods/chat/chat_rooms.dart";
import "package:island/screens/account/profile.dart";
part 'messages_notifier.g.dart';
@@ -28,7 +33,7 @@ class MessagesNotifier extends _$MessagesNotifier {
late final SnChatMember _identity;
final Map<String, LocalChatMessage> _pendingMessages = {};
final Map<String, Map<int, double>> _fileUploadProgress = {};
final Map<String, Map<int, double?>> _fileUploadProgress = {};
int? _totalCount;
String? _searchQuery;
bool? _withLinks;
@@ -40,8 +45,11 @@ class MessagesNotifier extends _$MessagesNotifier {
bool _isSyncing = false;
bool _isJumping = false;
bool _isUpdatingState = false;
bool _allRemoteMessagesFetched = false;
DateTime? _lastPauseTime;
late final Future<SnAccount?> Function(String) _fetchAccount;
@override
FutureOr<List<LocalChatMessage>> build(String roomId) async {
_roomId = roomId;
@@ -50,6 +58,15 @@ class MessagesNotifier extends _$MessagesNotifier {
final room = await ref.watch(chatroomProvider(roomId).future);
final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
// Initialize fetch account method for corrupted data recovery
_fetchAccount = (String accountId) async {
try {
return await ref.watch(accountProvider(accountId).future);
} catch (_) {
return null;
}
};
if (room == null) {
throw Exception('Room not found');
}
@@ -130,6 +147,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_roomId,
searchQuery,
withAttachments: withAttachments,
fetchAccount: _fetchAccount,
);
} else {
final chatMessagesFromDb = await _database.getMessagesForRoom(
@@ -137,8 +155,16 @@ class MessagesNotifier extends _$MessagesNotifier {
offset: offset,
limit: take,
);
dbMessages =
chatMessagesFromDb.map(_database.companionToMessage).toList();
dbMessages = await Future.wait(
chatMessagesFromDb
.map(
(msg) => _database.companionToMessage(
msg,
fetchAccount: _fetchAccount,
),
)
.toList(),
);
}
List<LocalChatMessage> filteredMessages = dbMessages;
@@ -199,8 +225,14 @@ class MessagesNotifier extends _$MessagesNotifier {
offset: offset,
limit: take,
);
final dbMessages =
chatMessagesFromDb.map(_database.companionToMessage).toList();
final dbMessages = await Future.wait(
chatMessagesFromDb
.map(
(msg) =>
_database.companionToMessage(msg, fetchAccount: _fetchAccount),
)
.toList(),
);
// Always ensure unique messages to prevent duplicate keys
final uniqueMessages = <LocalChatMessage>[];
@@ -247,6 +279,7 @@ class MessagesNotifier extends _$MessagesNotifier {
}
if (offset >= _totalCount!) {
_allRemoteMessagesFetched = true;
return [];
}
@@ -268,7 +301,7 @@ class MessagesNotifier extends _$MessagesNotifier {
}).toList();
for (final message in messages) {
await _database.saveMessage(_database.messageToCompanion(message));
await _database.saveMessageWithSender(message);
if (message.nonce != null) {
_pendingMessages.removeWhere(
(_, pendingMsg) => pendingMsg.nonce == message.nonce,
@@ -276,6 +309,11 @@ class MessagesNotifier extends _$MessagesNotifier {
}
}
// Check if we've fetched all remote messages
if (offset + messages.length >= _totalCount!) {
_allRemoteMessagesFetched = true;
}
return messages;
}
@@ -285,6 +323,7 @@ class MessagesNotifier extends _$MessagesNotifier {
return;
}
_isSyncing = true;
_allRemoteMessagesFetched = false;
talker.log('Starting message sync');
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
@@ -297,7 +336,10 @@ class MessagesNotifier extends _$MessagesNotifier {
final lastMessage =
dbMessages.isEmpty
? null
: _database.companionToMessage(dbMessages.first);
: await _database.companionToMessage(
dbMessages.first,
fetchAccount: _fetchAccount,
);
if (lastMessage == null) {
talker.log('No local messages, fetching from network');
@@ -309,19 +351,48 @@ class MessagesNotifier extends _$MessagesNotifier {
return;
}
final resp = await _apiClient.post(
'/sphere/chat/${_room.id}/sync',
data: {
'last_sync_timestamp':
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch,
},
);
// Sync with pagination support using timestamp-based cursor
int? totalMessages;
int syncedCount = 0;
int lastSyncTimestamp =
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch;
final response = MessageSyncResponse.fromJson(resp.data);
talker.log('Sync response: ${response.messages.length} changes');
for (final message in response.messages) {
await receiveMessage(message);
}
do {
final resp = await _apiClient.post(
'/sphere/chat/${_room.id}/sync',
data: {'last_sync_timestamp': lastSyncTimestamp},
);
// Read total count from header on first request
if (totalMessages == null) {
totalMessages = int.parse(
resp.headers['x-total']?.firstOrNull ?? '0',
);
talker.log('Total messages to sync: $totalMessages');
}
final response = MessageSyncResponse.fromJson(resp.data);
final messagesCount = response.messages.length;
talker.log(
'Sync page: synced=$syncedCount/$totalMessages, count=$messagesCount',
);
for (final message in response.messages) {
await receiveMessage(message);
}
syncedCount += messagesCount;
// Update cursor to the last message's createdAt for next page
if (response.messages.isNotEmpty) {
lastSyncTimestamp =
response.messages.last.createdAt.millisecondsSinceEpoch;
}
// Continue if there are more messages to fetch
} while (syncedCount < totalMessages);
talker.log('Sync complete: synced $syncedCount messages');
} catch (err, stackTrace) {
talker.log(
'Error syncing messages',
@@ -360,14 +431,35 @@ class MessagesNotifier extends _$MessagesNotifier {
withAttachments: _withAttachments,
);
if (localMessages.isNotEmpty) {
// If we have local messages AND we've fetched all remote messages, return local
if (localMessages.isNotEmpty && _allRemoteMessagesFetched) {
return localMessages;
}
// If we haven't fetched all remote messages, check remote even if we have local
// OR if we have no local messages at all
if (_searchQuery == null || _searchQuery!.isEmpty) {
return await _fetchAndCacheMessages(offset: offset, take: take);
final remoteMessages = await _fetchAndCacheMessages(
offset: offset,
take: take,
);
// If we got remote messages, re-fetch from cache to get merged result
if (remoteMessages.isNotEmpty) {
return await _getCachedMessages(
offset: offset,
take: take,
searchQuery: _searchQuery,
withLinks: _withLinks,
withAttachments: _withAttachments,
);
}
// No remote messages, return local (if any)
return localMessages;
} else {
return []; // If searching, and no local messages, don't fetch from network
// For search queries, return local only
return localMessages;
}
} catch (e) {
final localMessages = await _getCachedMessages(
@@ -387,6 +479,7 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> loadInitial() async {
talker.log('Loading initial messages');
_allRemoteMessagesFetched = false;
if (_searchQuery == null || _searchQuery!.isEmpty) {
syncMessages();
}
@@ -408,6 +501,7 @@ class MessagesNotifier extends _$MessagesNotifier {
if (!_hasMore || state is AsyncLoading) return;
talker.log('Loading more messages');
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
try {
final currentMessages = state.value ?? [];
final offset = currentMessages.length;
@@ -429,16 +523,23 @@ class MessagesNotifier extends _$MessagesNotifier {
stackTrace: stackTrace,
);
showErrorAlert(err);
} finally {
Future.microtask(
() => ref.read(isSyncingProvider.notifier).state = false,
);
}
}
Future<void> sendMessage(
WidgetRef ref,
String content,
List<UniversalFile> attachments, {
SnPoll? poll,
SnWalletFund? fund,
SnChatMessage? editingTo,
SnChatMessage? forwardingTo,
SnChatMessage? replyingTo,
Function(String, Map<int, double>)? onProgress,
Function(String, Map<int, double?>)? onProgress,
}) async {
final nonce = const Uuid().v4();
talker.log('Sending message with nonce $nonce');
@@ -461,7 +562,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages[localMessage.id] = localMessage;
_fileUploadProgress[localMessage.id] = {};
await _database.saveMessage(_database.messageToCompanion(localMessage));
await _database.saveMessageWithSender(localMessage);
final currentMessages = state.value ?? [];
state = AsyncValue.data([localMessage, ...currentMessages]);
@@ -471,10 +572,10 @@ class MessagesNotifier extends _$MessagesNotifier {
for (var idx = 0; idx < attachments.length; idx++) {
final cloudFile =
await FileUploader.createCloudFile(
ref: ref,
fileData: attachments[idx],
client: ref.read(apiClientProvider),
onProgress: (progress, _) {
_fileUploadProgress[localMessage.id]?[idx] = progress;
_fileUploadProgress[localMessage.id]?[idx] = progress ?? 0.0;
onProgress?.call(
localMessage.id,
_fileUploadProgress[localMessage.id] ?? {},
@@ -496,6 +597,8 @@ class MessagesNotifier extends _$MessagesNotifier {
'attachments_id': cloudAttachments.map((e) => e.id).toList(),
'replied_message_id': replyingTo?.id,
'forwarded_message_id': forwardingTo?.id,
'poll_id': poll?.id,
'fund_id': fund?.id,
'meta': {},
'nonce': nonce,
},
@@ -510,7 +613,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages.remove(localMessage.id);
await _database.deleteMessage(localMessage.id);
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
await _database.saveMessageWithSender(updatedMessage);
final currentMessages = state.value ?? [];
if (editingTo != null) {
@@ -592,7 +695,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages.remove(pendingMessageId);
await _database.deleteMessage(pendingMessageId);
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
await _database.saveMessageWithSender(updatedMessage);
final newMessages =
(state.value ?? []).map((m) {
@@ -649,7 +752,7 @@ class MessagesNotifier extends _$MessagesNotifier {
);
}
await _database.saveMessage(_database.messageToCompanion(localMessage));
await _database.saveMessageWithSender(localMessage);
final currentMessages = state.value ?? [];
final existingIndex = currentMessages.indexWhere(
@@ -746,7 +849,7 @@ class MessagesNotifier extends _$MessagesNotifier {
messageToUpdate.status,
);
await _database.saveMessage(_database.messageToCompanion(deletedMessage));
await _database.saveMessageWithSender(deletedMessage);
if (messageIndex != -1) {
final newList = [...currentMessages];
@@ -870,6 +973,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_searchQuery = null;
_withLinks = null;
_withAttachments = null;
_allRemoteMessagesFetched = false;
loadInitial();
}
@@ -880,7 +984,10 @@ class MessagesNotifier extends _$MessagesNotifier {
await (_database.select(_database.chatMessages)
..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull();
if (localMessage != null) {
return _database.companionToMessage(localMessage);
return _database.companionToMessage(
localMessage,
fetchAccount: _fetchAccount,
);
}
final response = await _apiClient.get(
@@ -892,7 +999,7 @@ class MessagesNotifier extends _$MessagesNotifier {
MessageStatus.sent,
);
await _database.saveMessage(_database.messageToCompanion(message));
await _database.saveMessageWithSender(message);
return message;
} catch (e) {
if (e is DioException) return null;

View File

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

198
lib/pods/file_list.dart Normal file
View File

@@ -0,0 +1,198 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/file_list_item.dart';
import 'package:island/pods/network.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
part 'file_list.g.dart';
@riverpod
class CloudFileListNotifier extends _$CloudFileListNotifier
with CursorPagingNotifierMixin<FileListItem> {
String _currentPath = '/';
String? _poolId;
String? _query;
String? _order;
bool _orderDesc = false;
void setPath(String path) {
_currentPath = path;
ref.invalidateSelf();
}
void setPool(String? poolId) {
_poolId = poolId;
ref.invalidateSelf();
}
void setQuery(String? query) {
_query = query;
ref.invalidateSelf();
}
void setOrder(String? order) {
_order = order;
ref.invalidateSelf();
}
void setOrderDesc(bool orderDesc) {
_orderDesc = orderDesc;
ref.invalidateSelf();
}
@override
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
@override
Future<CursorPagingData<FileListItem>> fetch({
required String? cursor,
}) async {
final client = ref.read(apiClientProvider);
final queryParameters = <String, String>{'path': _currentPath};
if (_poolId != null) {
queryParameters['pool'] = _poolId!;
}
if (_query != null) {
queryParameters['query'] = _query!;
}
if (_order != null) {
queryParameters['order'] = _order!;
}
queryParameters['orderDesc'] = _orderDesc.toString();
final response = await client.get(
'/drive/index/browse',
queryParameters: queryParameters,
);
final List<String> folders =
(response.data['folders'] as List).map((e) => e as String).toList();
final List<SnCloudFileIndex> files =
(response.data['files'] as List)
.map((e) => SnCloudFileIndex.fromJson(e as Map<String, dynamic>))
.toList();
final List<FileListItem> items = [
...folders.map((folderName) => FileListItem.folder(folderName)),
...files.map((file) => FileListItem.file(file)),
];
// The new API returns all files in the path, no pagination
return CursorPagingData(items: items, hasMore: false, nextCursor: null);
}
}
@riverpod
Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/drive/billing/usage');
return response.data;
}
@riverpod
class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
with CursorPagingNotifierMixin<FileListItem> {
String? _poolId;
bool _recycled = false;
String? _query;
String? _order;
bool _orderDesc = false;
void setPool(String? poolId) {
_poolId = poolId;
ref.invalidateSelf();
}
void setRecycled(bool recycled) {
_recycled = recycled;
ref.invalidateSelf();
}
void setQuery(String? query) {
_query = query;
ref.invalidateSelf();
}
void setOrder(String? order) {
_order = order;
ref.invalidateSelf();
}
void setOrderDesc(bool orderDesc) {
_orderDesc = orderDesc;
ref.invalidateSelf();
}
@override
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
@override
Future<CursorPagingData<FileListItem>> fetch({
required String? cursor,
}) async {
final client = ref.read(apiClientProvider);
final offset = cursor != null ? int.tryParse(cursor) ?? 0 : 0;
const take = 50; // Default page size
final queryParameters = <String, String>{
'take': take.toString(),
'offset': offset.toString(),
};
if (_poolId != null) {
queryParameters['pool'] = _poolId!;
}
if (_recycled) {
queryParameters['recycled'] = _recycled.toString();
}
if (_query != null) {
queryParameters['query'] = _query!;
}
if (_order != null) {
queryParameters['order'] = _order!;
}
queryParameters['orderDesc'] = _orderDesc.toString();
final response = await client.get(
'/drive/index/unindexed',
queryParameters: queryParameters,
);
final total = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0;
final List<SnCloudFile> files =
(response.data as List)
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList();
final List<FileListItem> items =
files.map((file) => FileListItem.unindexedFile(file)).toList();
final hasMore = offset + take < total;
final nextCursor = hasMore ? (offset + take).toString() : null;
return CursorPagingData(
items: items,
hasMore: hasMore,
nextCursor: nextCursor,
);
}
}
@riverpod
Future<Map<String, dynamic>?> billingQuota(Ref ref) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/drive/billing/quota');
return response.data;
}

View File

@@ -45,13 +45,13 @@ final billingQuotaProvider =
// ignore: unused_element
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
String _$cloudFileListNotifierHash() =>
r'22c45a8ea23147a3835ba870ad2f0bb833f853ea';
r'533dfa86f920b60cf7491fb4aeb95ece19e428af';
/// See also [CloudFileListNotifier].
@ProviderFor(CloudFileListNotifier)
final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
CloudFileListNotifier,
CursorPagingData<SnCloudFile>
CursorPagingData<FileListItem>
>.internal(
CloudFileListNotifier.new,
name: r'cloudFileListNotifierProvider',
@@ -64,6 +64,27 @@ final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
);
typedef _$CloudFileListNotifier =
AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFile>>;
AutoDisposeAsyncNotifier<CursorPagingData<FileListItem>>;
String _$unindexedFileListNotifierHash() =>
r'afa487d7b956b71b21ca1b073a01364a34ede1d5';
/// See also [UnindexedFileListNotifier].
@ProviderFor(UnindexedFileListNotifier)
final unindexedFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
UnindexedFileListNotifier,
CursorPagingData<FileListItem>
>.internal(
UnindexedFileListNotifier.new,
name: r'unindexedFileListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$unindexedFileListNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$UnindexedFileListNotifier =
AutoDisposeAsyncNotifier<CursorPagingData<FileListItem>>;
// 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,16 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/models/reference.dart';
import 'package:island/pods/network.dart';
part 'file_references.g.dart';
@riverpod
Future<List<Reference>> fileReferences(Ref ref, String fileId) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/drive/files/$fileId/references');
final list = response.data as List<dynamic>;
return list
.map((json) => Reference.fromJson(json as Map<String, dynamic>))
.toList();
}

View File

@@ -0,0 +1,153 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'file_references.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$fileReferencesHash() => r'd66c678c221f61978bdb242b98e6dbe31d0c204b';
/// 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 [fileReferences].
@ProviderFor(fileReferences)
const fileReferencesProvider = FileReferencesFamily();
/// See also [fileReferences].
class FileReferencesFamily extends Family<AsyncValue<List<Reference>>> {
/// See also [fileReferences].
const FileReferencesFamily();
/// See also [fileReferences].
FileReferencesProvider call(String fileId) {
return FileReferencesProvider(fileId);
}
@override
FileReferencesProvider getProviderOverride(
covariant FileReferencesProvider provider,
) {
return call(provider.fileId);
}
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'fileReferencesProvider';
}
/// See also [fileReferences].
class FileReferencesProvider
extends AutoDisposeFutureProvider<List<Reference>> {
/// See also [fileReferences].
FileReferencesProvider(String fileId)
: this._internal(
(ref) => fileReferences(ref as FileReferencesRef, fileId),
from: fileReferencesProvider,
name: r'fileReferencesProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$fileReferencesHash,
dependencies: FileReferencesFamily._dependencies,
allTransitiveDependencies:
FileReferencesFamily._allTransitiveDependencies,
fileId: fileId,
);
FileReferencesProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.fileId,
}) : super.internal();
final String fileId;
@override
Override overrideWith(
FutureOr<List<Reference>> Function(FileReferencesRef provider) create,
) {
return ProviderOverride(
origin: this,
override: FileReferencesProvider._internal(
(ref) => create(ref as FileReferencesRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
fileId: fileId,
),
);
}
@override
AutoDisposeFutureProviderElement<List<Reference>> createElement() {
return _FileReferencesProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is FileReferencesProvider && other.fileId == fileId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, fileId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin FileReferencesRef on AutoDisposeFutureProviderRef<List<Reference>> {
/// The parameter `fileId` of this provider.
String get fileId;
}
class _FileReferencesProviderElement
extends AutoDisposeFutureProviderElement<List<Reference>>
with FileReferencesRef {
_FileReferencesProviderElement(super.provider);
@override
String get fileId => (origin as FileReferencesProvider).fileId;
}
// 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

159
lib/pods/site_files.dart Normal file
View File

@@ -0,0 +1,159 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:http_parser/http_parser.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/site_file.dart';
import 'package:island/pods/network.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'site_files.g.dart';
@riverpod
Future<List<SnSiteFileEntry>> siteFiles(
Ref ref, {
required String siteId,
String? path,
}) async {
final apiClient = ref.watch(apiClientProvider);
final queryParams = path != null ? {'path': path} : null;
final resp = await apiClient.get(
'/zone/sites/$siteId/files',
queryParameters: queryParams,
);
final data = resp.data as List<dynamic>;
return data.map((json) => SnSiteFileEntry.fromJson(json)).toList();
}
@riverpod
Future<SnFileContent> siteFileContent(
Ref ref, {
required String siteId,
required String relativePath,
}) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get(
'/zone/sites/$siteId/files/content/$relativePath',
);
final content =
resp.data is String
? resp.data
: SnFileContent.fromJson(resp.data).content;
return SnFileContent(content: content);
}
@riverpod
Future<String> siteFileContentRaw(
Ref ref, {
required String siteId,
required String relativePath,
}) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get(
'/zone/sites/$siteId/files/content/$relativePath',
);
return resp.data is String ? resp.data : resp.data['content'] as String;
}
class SiteFilesNotifier
extends
AutoDisposeFamilyAsyncNotifier<
List<SnSiteFileEntry>,
({String siteId, String? path})
> {
@override
Future<List<SnSiteFileEntry>> build(
({String siteId, String? path}) arg,
) async {
return fetchFiles();
}
Future<List<SnSiteFileEntry>> fetchFiles() async {
try {
final apiClient = ref.read(apiClientProvider);
final queryParams = arg.path != null ? {'path': arg.path} : null;
final resp = await apiClient.get(
'/zone/sites/${arg.siteId}/files',
queryParameters: queryParams,
);
final data = resp.data as List<dynamic>;
return data.map((json) => SnSiteFileEntry.fromJson(json)).toList();
} catch (e) {
rethrow;
}
}
Future<void> uploadFile(File file, String filePath) async {
state = const AsyncValue.loading();
try {
final apiClient = ref.read(apiClientProvider);
// Create multipart form data
final formData = FormData.fromMap({
'filePath': filePath,
'file': await MultipartFile.fromFile(
file.path,
filename: file.path.split('/').last,
contentType: MediaType('application', 'octet-stream'),
),
});
await apiClient.post(
'/zone/sites/${arg.siteId}/files/upload',
data: formData,
);
// Refresh the files list
ref.invalidate(siteFilesProvider(siteId: arg.siteId, path: arg.path));
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;
}
}
Future<void> updateFileContent(String relativePath, String newContent) async {
state = const AsyncValue.loading();
try {
final apiClient = ref.read(apiClientProvider);
await apiClient.put(
'/zone/sites/${arg.siteId}/files/edit/$relativePath',
data: {'new_content': newContent},
);
// Refresh the files list
ref.invalidate(siteFilesProvider(siteId: arg.siteId, path: arg.path));
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;
}
}
Future<void> deleteFile(String relativePath) async {
state = const AsyncValue.loading();
try {
final apiClient = ref.read(apiClientProvider);
await apiClient.delete(
'/zone/sites/${arg.siteId}/files/delete/$relativePath',
);
// Refresh the files list
ref.invalidate(siteFilesProvider(siteId: arg.siteId, path: arg.path));
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;
}
}
Future<void> createDirectory(String directoryPath) async {
// For directories, we upload a dummy file first then delete it or create through upload
// Actually, according to API docs, directories are created when uploading files to them
// So we'll just invalidate to refresh the list
ref.invalidate(siteFilesProvider(siteId: arg.siteId, path: arg.path));
}
}
final siteFilesNotifierProvider = AsyncNotifierProvider.autoDispose.family<
SiteFilesNotifier,
List<SnSiteFileEntry>,
({String siteId, String? path})
>(SiteFilesNotifier.new);

451
lib/pods/site_files.g.dart Normal file
View File

@@ -0,0 +1,451 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'site_files.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$siteFilesHash() => r'd4029e6c160edcd454eb39ef1c19427b7f95a8d8';
/// 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 [siteFiles].
@ProviderFor(siteFiles)
const siteFilesProvider = SiteFilesFamily();
/// See also [siteFiles].
class SiteFilesFamily extends Family<AsyncValue<List<SnSiteFileEntry>>> {
/// See also [siteFiles].
const SiteFilesFamily();
/// See also [siteFiles].
SiteFilesProvider call({required String siteId, String? path}) {
return SiteFilesProvider(siteId: siteId, path: path);
}
@override
SiteFilesProvider getProviderOverride(covariant SiteFilesProvider provider) {
return call(siteId: provider.siteId, path: provider.path);
}
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'siteFilesProvider';
}
/// See also [siteFiles].
class SiteFilesProvider
extends AutoDisposeFutureProvider<List<SnSiteFileEntry>> {
/// See also [siteFiles].
SiteFilesProvider({required String siteId, String? path})
: this._internal(
(ref) => siteFiles(ref as SiteFilesRef, siteId: siteId, path: path),
from: siteFilesProvider,
name: r'siteFilesProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$siteFilesHash,
dependencies: SiteFilesFamily._dependencies,
allTransitiveDependencies: SiteFilesFamily._allTransitiveDependencies,
siteId: siteId,
path: path,
);
SiteFilesProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.siteId,
required this.path,
}) : super.internal();
final String siteId;
final String? path;
@override
Override overrideWith(
FutureOr<List<SnSiteFileEntry>> Function(SiteFilesRef provider) create,
) {
return ProviderOverride(
origin: this,
override: SiteFilesProvider._internal(
(ref) => create(ref as SiteFilesRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
siteId: siteId,
path: path,
),
);
}
@override
AutoDisposeFutureProviderElement<List<SnSiteFileEntry>> createElement() {
return _SiteFilesProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SiteFilesProvider &&
other.siteId == siteId &&
other.path == path;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, siteId.hashCode);
hash = _SystemHash.combine(hash, path.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SiteFilesRef on AutoDisposeFutureProviderRef<List<SnSiteFileEntry>> {
/// The parameter `siteId` of this provider.
String get siteId;
/// The parameter `path` of this provider.
String? get path;
}
class _SiteFilesProviderElement
extends AutoDisposeFutureProviderElement<List<SnSiteFileEntry>>
with SiteFilesRef {
_SiteFilesProviderElement(super.provider);
@override
String get siteId => (origin as SiteFilesProvider).siteId;
@override
String? get path => (origin as SiteFilesProvider).path;
}
String _$siteFileContentHash() => r'b594ad4f8c54555e742ece94ee001092cb2f83d1';
/// See also [siteFileContent].
@ProviderFor(siteFileContent)
const siteFileContentProvider = SiteFileContentFamily();
/// See also [siteFileContent].
class SiteFileContentFamily extends Family<AsyncValue<SnFileContent>> {
/// See also [siteFileContent].
const SiteFileContentFamily();
/// See also [siteFileContent].
SiteFileContentProvider call({
required String siteId,
required String relativePath,
}) {
return SiteFileContentProvider(siteId: siteId, relativePath: relativePath);
}
@override
SiteFileContentProvider getProviderOverride(
covariant SiteFileContentProvider provider,
) {
return call(siteId: provider.siteId, relativePath: provider.relativePath);
}
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'siteFileContentProvider';
}
/// See also [siteFileContent].
class SiteFileContentProvider extends AutoDisposeFutureProvider<SnFileContent> {
/// See also [siteFileContent].
SiteFileContentProvider({
required String siteId,
required String relativePath,
}) : this._internal(
(ref) => siteFileContent(
ref as SiteFileContentRef,
siteId: siteId,
relativePath: relativePath,
),
from: siteFileContentProvider,
name: r'siteFileContentProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$siteFileContentHash,
dependencies: SiteFileContentFamily._dependencies,
allTransitiveDependencies:
SiteFileContentFamily._allTransitiveDependencies,
siteId: siteId,
relativePath: relativePath,
);
SiteFileContentProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.siteId,
required this.relativePath,
}) : super.internal();
final String siteId;
final String relativePath;
@override
Override overrideWith(
FutureOr<SnFileContent> Function(SiteFileContentRef provider) create,
) {
return ProviderOverride(
origin: this,
override: SiteFileContentProvider._internal(
(ref) => create(ref as SiteFileContentRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
siteId: siteId,
relativePath: relativePath,
),
);
}
@override
AutoDisposeFutureProviderElement<SnFileContent> createElement() {
return _SiteFileContentProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SiteFileContentProvider &&
other.siteId == siteId &&
other.relativePath == relativePath;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, siteId.hashCode);
hash = _SystemHash.combine(hash, relativePath.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SiteFileContentRef on AutoDisposeFutureProviderRef<SnFileContent> {
/// The parameter `siteId` of this provider.
String get siteId;
/// The parameter `relativePath` of this provider.
String get relativePath;
}
class _SiteFileContentProviderElement
extends AutoDisposeFutureProviderElement<SnFileContent>
with SiteFileContentRef {
_SiteFileContentProviderElement(super.provider);
@override
String get siteId => (origin as SiteFileContentProvider).siteId;
@override
String get relativePath => (origin as SiteFileContentProvider).relativePath;
}
String _$siteFileContentRawHash() =>
r'd0331c30698a9f4b90fe9b79273ff5914fa46616';
/// See also [siteFileContentRaw].
@ProviderFor(siteFileContentRaw)
const siteFileContentRawProvider = SiteFileContentRawFamily();
/// See also [siteFileContentRaw].
class SiteFileContentRawFamily extends Family<AsyncValue<String>> {
/// See also [siteFileContentRaw].
const SiteFileContentRawFamily();
/// See also [siteFileContentRaw].
SiteFileContentRawProvider call({
required String siteId,
required String relativePath,
}) {
return SiteFileContentRawProvider(
siteId: siteId,
relativePath: relativePath,
);
}
@override
SiteFileContentRawProvider getProviderOverride(
covariant SiteFileContentRawProvider provider,
) {
return call(siteId: provider.siteId, relativePath: provider.relativePath);
}
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'siteFileContentRawProvider';
}
/// See also [siteFileContentRaw].
class SiteFileContentRawProvider extends AutoDisposeFutureProvider<String> {
/// See also [siteFileContentRaw].
SiteFileContentRawProvider({
required String siteId,
required String relativePath,
}) : this._internal(
(ref) => siteFileContentRaw(
ref as SiteFileContentRawRef,
siteId: siteId,
relativePath: relativePath,
),
from: siteFileContentRawProvider,
name: r'siteFileContentRawProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$siteFileContentRawHash,
dependencies: SiteFileContentRawFamily._dependencies,
allTransitiveDependencies:
SiteFileContentRawFamily._allTransitiveDependencies,
siteId: siteId,
relativePath: relativePath,
);
SiteFileContentRawProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.siteId,
required this.relativePath,
}) : super.internal();
final String siteId;
final String relativePath;
@override
Override overrideWith(
FutureOr<String> Function(SiteFileContentRawRef provider) create,
) {
return ProviderOverride(
origin: this,
override: SiteFileContentRawProvider._internal(
(ref) => create(ref as SiteFileContentRawRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
siteId: siteId,
relativePath: relativePath,
),
);
}
@override
AutoDisposeFutureProviderElement<String> createElement() {
return _SiteFileContentRawProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SiteFileContentRawProvider &&
other.siteId == siteId &&
other.relativePath == relativePath;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, siteId.hashCode);
hash = _SystemHash.combine(hash, relativePath.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SiteFileContentRawRef on AutoDisposeFutureProviderRef<String> {
/// The parameter `siteId` of this provider.
String get siteId;
/// The parameter `relativePath` of this provider.
String get relativePath;
}
class _SiteFileContentRawProviderElement
extends AutoDisposeFutureProviderElement<String>
with SiteFileContentRawRef {
_SiteFileContentRawProviderElement(super.provider);
@override
String get siteId => (origin as SiteFileContentRawProvider).siteId;
@override
String get relativePath =>
(origin as SiteFileContentRawProvider).relativePath;
}
// 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

116
lib/pods/site_pages.dart Normal file
View File

@@ -0,0 +1,116 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/network.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'site_pages.g.dart';
@riverpod
Future<List<SnPublicationPage>> sitePages(
Ref ref,
String pubName,
String siteSlug,
) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/zone/sites/$pubName/$siteSlug/pages');
final data = resp.data as List<dynamic>;
return data.map((json) => SnPublicationPage.fromJson(json)).toList();
}
@riverpod
Future<SnPublicationPage> sitePage(Ref ref, String pageId) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/zone/sites/pages/$pageId');
return SnPublicationPage.fromJson(resp.data);
}
class SitePagesNotifier
extends
AutoDisposeFamilyAsyncNotifier<
List<SnPublicationPage>,
({String pubName, String siteSlug})
> {
@override
Future<List<SnPublicationPage>> build(
({String pubName, String siteSlug}) arg,
) async {
return fetchPages();
}
Future<List<SnPublicationPage>> fetchPages() async {
try {
final apiClient = ref.read(apiClientProvider);
final resp = await apiClient.get(
'/zone/sites/${arg.pubName}/${arg.siteSlug}/pages',
);
final data = resp.data as List<dynamic>;
return data.map((json) => SnPublicationPage.fromJson(json)).toList();
} catch (e) {
rethrow;
}
}
Future<SnPublicationPage?> createPage(Map<String, dynamic> pageData) async {
state = const AsyncValue.loading();
try {
final apiClient = ref.read(apiClientProvider);
final resp = await apiClient.post(
'/zone/sites/${arg.pubName}/${arg.siteSlug}/pages',
data: pageData,
);
final newPage = SnPublicationPage.fromJson(resp.data);
// Refresh the pages list
ref.invalidate(sitePagesProvider(arg.pubName, arg.siteSlug));
return newPage;
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;
}
}
Future<SnPublicationPage?> updatePage(
String pageId,
Map<String, dynamic> pageData,
) async {
state = const AsyncValue.loading();
try {
final apiClient = ref.read(apiClientProvider);
final resp = await apiClient.patch(
'/zone/sites/pages/$pageId',
data: pageData,
);
final updatedPage = SnPublicationPage.fromJson(resp.data);
// Refresh the pages list
ref.invalidate(sitePagesProvider(arg.pubName, arg.siteSlug));
return updatedPage;
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;
}
}
Future<void> deletePage(String pageId) async {
state = const AsyncValue.loading();
try {
final apiClient = ref.read(apiClientProvider);
await apiClient.delete('/zone/sites/pages/$pageId');
// Refresh the pages list
ref.invalidate(sitePagesProvider(arg.pubName, arg.siteSlug));
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;
}
}
}
final sitePagesNotifierProvider = AsyncNotifierProvider.autoDispose.family<
SitePagesNotifier,
List<SnPublicationPage>,
({String pubName, String siteSlug})
>(SitePagesNotifier.new);

280
lib/pods/site_pages.g.dart Normal file
View File

@@ -0,0 +1,280 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'site_pages.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$sitePagesHash() => r'5e084e9694ad665e9b238c6a747c6c6e99c5eb03';
/// 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 [sitePages].
@ProviderFor(sitePages)
const sitePagesProvider = SitePagesFamily();
/// See also [sitePages].
class SitePagesFamily extends Family<AsyncValue<List<SnPublicationPage>>> {
/// See also [sitePages].
const SitePagesFamily();
/// See also [sitePages].
SitePagesProvider call(String pubName, String siteSlug) {
return SitePagesProvider(pubName, siteSlug);
}
@override
SitePagesProvider getProviderOverride(covariant SitePagesProvider provider) {
return call(provider.pubName, provider.siteSlug);
}
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'sitePagesProvider';
}
/// See also [sitePages].
class SitePagesProvider
extends AutoDisposeFutureProvider<List<SnPublicationPage>> {
/// See also [sitePages].
SitePagesProvider(String pubName, String siteSlug)
: this._internal(
(ref) => sitePages(ref as SitePagesRef, pubName, siteSlug),
from: sitePagesProvider,
name: r'sitePagesProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$sitePagesHash,
dependencies: SitePagesFamily._dependencies,
allTransitiveDependencies: SitePagesFamily._allTransitiveDependencies,
pubName: pubName,
siteSlug: siteSlug,
);
SitePagesProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.pubName,
required this.siteSlug,
}) : super.internal();
final String pubName;
final String siteSlug;
@override
Override overrideWith(
FutureOr<List<SnPublicationPage>> Function(SitePagesRef provider) create,
) {
return ProviderOverride(
origin: this,
override: SitePagesProvider._internal(
(ref) => create(ref as SitePagesRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
pubName: pubName,
siteSlug: siteSlug,
),
);
}
@override
AutoDisposeFutureProviderElement<List<SnPublicationPage>> createElement() {
return _SitePagesProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SitePagesProvider &&
other.pubName == pubName &&
other.siteSlug == siteSlug;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, pubName.hashCode);
hash = _SystemHash.combine(hash, siteSlug.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SitePagesRef on AutoDisposeFutureProviderRef<List<SnPublicationPage>> {
/// The parameter `pubName` of this provider.
String get pubName;
/// The parameter `siteSlug` of this provider.
String get siteSlug;
}
class _SitePagesProviderElement
extends AutoDisposeFutureProviderElement<List<SnPublicationPage>>
with SitePagesRef {
_SitePagesProviderElement(super.provider);
@override
String get pubName => (origin as SitePagesProvider).pubName;
@override
String get siteSlug => (origin as SitePagesProvider).siteSlug;
}
String _$sitePageHash() => r'542f70c5b103fe34d7cf7eb0821d52f017022efc';
/// See also [sitePage].
@ProviderFor(sitePage)
const sitePageProvider = SitePageFamily();
/// See also [sitePage].
class SitePageFamily extends Family<AsyncValue<SnPublicationPage>> {
/// See also [sitePage].
const SitePageFamily();
/// See also [sitePage].
SitePageProvider call(String pageId) {
return SitePageProvider(pageId);
}
@override
SitePageProvider getProviderOverride(covariant SitePageProvider provider) {
return call(provider.pageId);
}
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'sitePageProvider';
}
/// See also [sitePage].
class SitePageProvider extends AutoDisposeFutureProvider<SnPublicationPage> {
/// See also [sitePage].
SitePageProvider(String pageId)
: this._internal(
(ref) => sitePage(ref as SitePageRef, pageId),
from: sitePageProvider,
name: r'sitePageProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$sitePageHash,
dependencies: SitePageFamily._dependencies,
allTransitiveDependencies: SitePageFamily._allTransitiveDependencies,
pageId: pageId,
);
SitePageProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.pageId,
}) : super.internal();
final String pageId;
@override
Override overrideWith(
FutureOr<SnPublicationPage> Function(SitePageRef provider) create,
) {
return ProviderOverride(
origin: this,
override: SitePageProvider._internal(
(ref) => create(ref as SitePageRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
pageId: pageId,
),
);
}
@override
AutoDisposeFutureProviderElement<SnPublicationPage> createElement() {
return _SitePageProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SitePageProvider && other.pageId == pageId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, pageId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SitePageRef on AutoDisposeFutureProviderRef<SnPublicationPage> {
/// The parameter `pageId` of this provider.
String get pageId;
}
class _SitePageProviderElement
extends AutoDisposeFutureProviderElement<SnPublicationPage>
with SitePageRef {
_SitePageProviderElement(super.provider);
@override
String get pageId => (origin as SitePageProvider).pageId;
}
// 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

88
lib/pods/sites.dart Normal file
View File

@@ -0,0 +1,88 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/network.dart';
class SiteNotifier
extends
AutoDisposeFamilyAsyncNotifier<
SnPublicationSite,
({String pubName, String? siteId})
> {
@override
FutureOr<SnPublicationSite> build(
({String pubName, String? siteId}) arg,
) async {
if (arg.siteId == null || arg.siteId!.isEmpty) {
return SnPublicationSite(
id: '',
slug: '',
name: '',
publisherId: arg.pubName,
accountId: '',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
pages: [],
);
}
try {
final client = ref.read(apiClientProvider);
final response = await client.get('/sphere/sites/${arg.siteId}');
return SnPublicationSite.fromJson(response.data);
} catch (e) {
rethrow;
}
}
Future<void> saveSite(SnPublicationSite site) async {
state = const AsyncValue.loading();
try {
final client = ref.read(apiClientProvider);
final url = '/sphere/sites';
final response =
site.id.isEmpty
? await client.post(url, data: site.toJson())
: await client.patch('$url/${site.id}', data: site.toJson());
state = AsyncValue.data(SnPublicationSite.fromJson(response.data));
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;
}
}
Future<void> deleteSite() async {
final siteId = arg.siteId;
if (siteId == null || siteId.isEmpty) return;
state = const AsyncValue.loading();
try {
final client = ref.read(apiClientProvider);
await client.delete('/sphere/sites/$siteId');
state = AsyncValue.data(
SnPublicationSite(
id: '',
slug: '',
name: '',
publisherId: arg.pubName,
accountId: '',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
pages: [],
),
);
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;
}
}
}
final siteNotifierProvider = AsyncNotifierProvider.autoDispose.family<
SiteNotifier,
SnPublicationSite,
({String pubName, String? siteId})
>(SiteNotifier.new);

537
lib/pods/upload_tasks.dart Normal file
View File

@@ -0,0 +1,537 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:cross_file/cross_file.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/drive_task.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/talker.dart';
final uploadTasksProvider =
StateNotifierProvider<UploadTasksNotifier, List<DriveTask>>(
(ref) => UploadTasksNotifier(ref),
);
class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
final Ref ref;
StreamSubscription? _websocketSubscription;
final Map<String, Map<String, dynamic>> _pendingUploads = {};
UploadTasksNotifier(this.ref) : super([]) {
_listenToWebSocket();
}
void _listenToWebSocket() {
final WebSocketService websocketService = ref.read(websocketProvider);
_websocketSubscription = websocketService.dataStream.listen(
_handleWebSocketPacket,
);
}
void _handleWebSocketPacket(dynamic packet) {
if (packet.type.startsWith('task.')) {
final data = packet.data;
if (data == null) return;
// Debug logging
talker.info(
'[UploadTasks] Received WebSocket packet: ${packet.type}, data: $data',
);
final taskId = data['task_id'] as String?;
if (taskId == null) return;
switch (packet.type) {
case 'task.created':
_handleTaskCreated(taskId, data);
break;
case 'task.progress':
_handleProgressUpdate(taskId, data);
break;
case 'task.completed':
_handleUploadCompleted(taskId, data);
break;
case 'task.failed':
_handleUploadFailed(taskId, data);
break;
}
}
}
void _handleTaskCreated(String taskId, Map<String, dynamic> data) {
talker.info('[UploadTasks] Handling task.created for taskId: $taskId');
// Check if task already exists (might have been created locally)
final existingTask =
state.where((task) => task.taskId == taskId).firstOrNull;
if (existingTask != null) {
talker.info('[UploadTasks] Task already exists, updating status');
// Task already exists, just update its status to confirm server creation
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
status: DriveTaskStatus.pending,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
return;
}
// Check if we have stored metadata for this task
final metadata = _pendingUploads[taskId];
talker.info('[UploadTasks] Metadata for taskId $taskId: $metadata');
if (metadata != null) {
talker.info('[UploadTasks] Creating task with full metadata');
// Create task with full metadata
final uploadTask = DriveTask(
id: DateTime.now().millisecondsSinceEpoch.toString(),
taskId: taskId,
fileName: metadata['file_name'] as String,
contentType: metadata['mime_type'] as String,
fileSize: metadata['file_size'] as int,
uploadedBytes: 0,
totalChunks: metadata['total_chunks'] as int,
uploadedChunks: 0,
status: DriveTaskStatus.pending,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
type: 'FileUpload',
poolId: metadata['pool_id'] as String?,
bundleId: metadata['bundleId'] as String?,
encryptPassword: metadata['encrypt_password'] as String?,
expiredAt: metadata['expired_at'] as String?,
);
state = [...state, uploadTask];
talker.info(
'[UploadTasks] Task created successfully. Total tasks: ${state.length}',
);
// Clean up stored metadata
_pendingUploads.remove(taskId);
} else {
talker.info('[UploadTasks] No metadata found, creating minimal task');
// Create minimal task if no metadata is stored
final params = data['parameters'];
final uploadTask = DriveTask(
id: DateTime.now().millisecondsSinceEpoch.toString(),
taskId: taskId,
fileName: params['file_name'] as String? ?? 'Unknown file',
contentType: params['content_type'],
fileSize: params['file_size'],
uploadedBytes:
(params['chunk_size'] as int) * (params['chunks_uploaded'] as int),
totalChunks: params['chunks_count'],
uploadedChunks: params['chunks_uploaded'],
status: DriveTaskStatus.pending,
createdAt: DateTime.tryParse(data['created_at']) ?? DateTime.now(),
updatedAt: DateTime.now(),
type: data['type'],
);
state = [...state, uploadTask];
talker.info(
'[UploadTasks] Minimal task created. Total tasks: ${state.length}',
);
}
}
void _handleProgressUpdate(String taskId, Map<String, dynamic> data) {
final progress = data['progress'] as num? ?? 0.0;
state =
state.map((task) {
if (task.taskId == taskId) {
final uploadedBytes = (progress / 100.0 * task.fileSize).toInt();
return task.copyWith(
statusMessage: data['status'],
uploadedBytes: uploadedBytes,
status: DriveTaskStatus.inProgress,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void _handleUploadCompleted(String taskId, Map<String, dynamic> data) {
final results = data['results'] as Map<String, dynamic>?;
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
status: DriveTaskStatus.completed,
uploadedChunks: task.totalChunks,
uploadedBytes: task.fileSize,
// Update file information from Results if available
fileName: results?['file_name'] as String? ?? task.fileName,
fileSize: results?['file_size'] as int? ?? task.fileSize,
contentType: results?['mime_type'] as String? ?? task.contentType,
result:
results?['file_info'] != null
? SnCloudFile.fromJson(results!['file_info'])
: null,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void _handleUploadFailed(String taskId, Map<String, dynamic> data) {
final errorMessage = data['error_message'] as String? ?? 'Upload failed';
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
status: DriveTaskStatus.failed,
errorMessage: errorMessage,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void addUploadTask(DriveTask task) {
state = [...state, task];
}
void storeUploadMetadata(
String taskId, {
required String fileName,
required String contentType,
required int fileSize,
required int totalChunks,
String? poolId,
String? bundleId,
String? encryptPassword,
String? expiredAt,
}) {
_pendingUploads[taskId] = {
'file_name': fileName,
'mime_type': contentType,
'file_size': fileSize,
'total_chunks': totalChunks,
'pool_id': poolId,
'bundleId': bundleId,
'encrypt_password': encryptPassword,
'expired_at': expiredAt,
};
}
void updateTaskStatus(
String taskId,
DriveTaskStatus status, {
String? errorMessage,
}) {
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
status: status,
errorMessage: errorMessage,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void updateTransmissionProgress(String taskId, double progress) {
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
transmissionProgress: progress,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void updateDownloadProgress(
String taskId,
int downloadedBytes,
int totalBytes,
) {
state =
state.map((task) {
if (task.taskId == taskId) {
return task.copyWith(
fileSize: totalBytes,
uploadedBytes: downloadedBytes,
updatedAt: DateTime.now(),
);
}
return task;
}).toList();
}
void removeTask(String taskId) {
state = state.where((task) => task.taskId != taskId).toList();
}
void clearCompletedTasks() {
state =
state
.where(
(task) =>
task.status != DriveTaskStatus.completed &&
task.status != DriveTaskStatus.failed &&
task.status != DriveTaskStatus.cancelled &&
task.status != DriveTaskStatus.expired,
)
.toList();
}
void clearAllTasks() {
state = [];
}
DriveTask? getTask(String taskId) {
return state.where((task) => task.taskId == taskId).firstOrNull;
}
List<DriveTask> getActiveTasks() {
return state
.where(
(task) =>
task.status == DriveTaskStatus.pending ||
task.status == DriveTaskStatus.inProgress ||
task.status == DriveTaskStatus.paused ||
task.status == DriveTaskStatus.completed,
)
.toList();
}
String addLocalDownloadTask(SnCloudFile item) {
final taskId =
'download-${item.id}-${DateTime.now().millisecondsSinceEpoch}';
final task = DriveTask(
id: taskId,
taskId: taskId,
fileName: item.name,
contentType: item.mimeType ?? '',
fileSize: 0,
uploadedBytes: 0,
totalChunks: 1,
uploadedChunks: 0,
status: DriveTaskStatus.inProgress,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
type: 'FileDownload',
);
state = [...state, task];
return taskId;
}
@override
void dispose() {
_websocketSubscription?.cancel();
super.dispose();
}
}
// Provider for the enhanced FileUploader that integrates with upload tasks
final enhancedFileUploaderProvider = Provider<EnhancedFileUploader>((ref) {
final dio = ref.watch(apiClientProvider);
return EnhancedFileUploader(dio, ref);
});
class EnhancedFileUploader extends FileUploader {
final Ref ref;
EnhancedFileUploader(super.client, this.ref);
/// Reads the next chunk from a stream subscription.
Future<Uint8List> _readNextChunkFromStream(
StreamSubscription<List<int>> subscription,
int size,
) async {
final completer = Completer<Uint8List>();
final buffer = <int>[];
int remaining = size;
void onData(List<int> data) {
buffer.addAll(data);
remaining -= data.length;
if (remaining <= 0) {
subscription.pause();
completer.complete(Uint8List.fromList(buffer.sublist(0, size)));
}
}
void onDone() {
if (!completer.isCompleted) {
completer.complete(Uint8List.fromList(buffer));
}
}
subscription.onData(onData);
subscription.onDone(onDone);
return completer.future;
}
@override
Future<SnCloudFile> uploadFile({
required dynamic fileData,
required String fileName,
required String contentType,
String? poolId,
String? bundleId,
String? encryptPassword,
String? expiredAt,
int? customChunkSize,
String? path,
Function(double? progress, Duration estimate)? onProgress,
}) async {
// Step 1: Create upload task
onProgress?.call(null, Duration.zero);
final createResponse = await createUploadTask(
fileData: fileData,
fileName: fileName,
contentType: contentType,
poolId: poolId,
bundleId: bundleId,
encryptPassword: encryptPassword,
expiredAt: expiredAt,
chunkSize: customChunkSize,
path: path,
);
int totalSize;
if (fileData is XFile) {
totalSize = await fileData.length();
} else if (fileData is Uint8List) {
totalSize = fileData.length;
} else {
throw ArgumentError('Invalid fileData type');
}
if (createResponse['file_exists'] == true) {
// File already exists, create a local task to show it was found
final existingFile = SnCloudFile.fromJson(createResponse['file']);
// Create a task that shows as completed immediately
// Use a generated taskId since the server might not provide one for existing files
final taskId =
createResponse['task_id'] as String? ??
'existing-${DateTime.now().millisecondsSinceEpoch}';
final uploadTask = DriveTask(
id: DateTime.now().millisecondsSinceEpoch.toString(),
taskId: taskId,
fileName: fileName,
contentType: contentType,
fileSize: totalSize,
uploadedBytes: totalSize,
totalChunks: 1, // For existing files, we consider it as 1 chunk
uploadedChunks: 1,
status: DriveTaskStatus.completed,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
type: 'FileUpload',
poolId: poolId,
bundleId: bundleId,
encryptPassword: encryptPassword,
expiredAt: expiredAt,
);
ref.read(uploadTasksProvider.notifier).addUploadTask(uploadTask);
return existingFile;
}
final taskId = createResponse['task_id'] as String;
final chunkSize = createResponse['chunk_size'] as int;
final chunksCount = createResponse['chunks_count'] as int;
// Store upload metadata for when task.created event arrives
talker.info('[UploadTasks] Storing metadata for taskId: $taskId');
ref
.read(uploadTasksProvider.notifier)
.storeUploadMetadata(
taskId,
fileName: fileName,
contentType: contentType,
fileSize: totalSize,
totalChunks: chunksCount,
poolId: poolId,
bundleId: bundleId,
encryptPassword: encryptPassword,
expiredAt: expiredAt,
);
// Step 2: Upload chunks
int bytesUploaded = 0;
if (fileData is XFile) {
// Use stream for XFile
final subscription = fileData.openRead().listen(null);
subscription.pause();
for (int i = 0; i < chunksCount; i++) {
subscription.resume();
final chunkData = await _readNextChunkFromStream(
subscription,
chunkSize,
);
await uploadChunk(
taskId: taskId,
chunkIndex: i,
chunkData: chunkData,
onSendProgress: (sent, total) {
final overallProgress = (bytesUploaded + sent) / totalSize;
onProgress?.call(overallProgress, Duration.zero);
// Update transmission progress in UI
ref
.read(uploadTasksProvider.notifier)
.updateTransmissionProgress(taskId, overallProgress);
},
);
bytesUploaded += chunkData.length;
}
subscription.cancel();
} else if (fileData is Uint8List) {
// Use old way for Uint8List
final chunks = <Uint8List>[];
for (int i = 0; i < fileData.length; i += chunkSize) {
final end =
i + chunkSize > fileData.length ? fileData.length : i + chunkSize;
chunks.add(Uint8List.fromList(fileData.sublist(i, end)));
}
// Upload each chunk
for (int i = 0; i < chunks.length; i++) {
await uploadChunk(
taskId: taskId,
chunkIndex: i,
chunkData: chunks[i],
onSendProgress: (sent, total) {
final overallProgress = (bytesUploaded + sent) / totalSize;
onProgress?.call(overallProgress, Duration.zero);
// Update transmission progress in UI
ref
.read(uploadTasksProvider.notifier)
.updateTransmissionProgress(taskId, overallProgress);
},
);
bytesUploaded += chunks[i].length;
}
} else {
throw ArgumentError('Invalid fileData type');
}
// Step 3: Complete upload
onProgress?.call(null, Duration.zero);
return await completeUpload(taskId);
}
}

View File

@@ -5,7 +5,8 @@ import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_platform_alert/flutter_platform_alert.dart';
import 'package:flutter/material.dart';
import 'package:island/widgets/alert.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart';
@@ -36,41 +37,65 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
} catch (error, stackTrace) {
if (!kIsWeb) {
if (error is DioException) {
FlutterPlatformAlert.showCustomAlert(
windowTitle: 'failedToLoadUserInfo'.tr(),
text: [
(error.response?.statusCode == 401
? 'failedToLoadUserInfoUnauthorized'
: 'failedToLoadUserInfoNetwork')
.tr()
.trim(),
'',
'${error.response?.statusCode ?? 'Network Error'}',
if (error.response?.headers != null) error.response?.headers,
if (error.response?.data != null)
jsonEncode(error.response?.data),
].join('\n'),
iconStyle: IconStyle.error,
neutralButtonTitle: 'retry'.tr(),
negativeButtonTitle: 'okay'.tr(),
showOverlayDialog<bool>(
builder:
(context, close) => AlertDialog(
title: Text('failedToLoadUserInfo'.tr()),
content: Text(
[
(error.response?.statusCode == 401
? 'failedToLoadUserInfoUnauthorized'
: 'failedToLoadUserInfoNetwork')
.tr()
.trim(),
'',
'${error.response?.statusCode ?? 'Network Error'}',
if (error.response?.headers != null)
error.response?.headers,
if (error.response?.data != null)
jsonEncode(error.response?.data),
].join('\n'),
),
actions: [
TextButton(
onPressed: () => close(false),
child: Text('okay'.tr()),
),
TextButton(
onPressed: () => close(true),
child: Text('retry'.tr()),
),
],
),
).then((value) {
if (value == CustomButton.neutralButton) {
if (value == true) {
fetchUser();
}
});
}
FlutterPlatformAlert.showCustomAlert(
windowTitle: 'failedToLoadUserInfo'.tr(),
text:
[
'failedToLoadUserInfoNetwork'.tr(),
error.toString(),
].join('\n\n').trim(),
iconStyle: IconStyle.error,
neutralButtonTitle: 'retry'.tr(),
negativeButtonTitle: 'okay'.tr(),
showOverlayDialog<bool>(
builder:
(context, close) => AlertDialog(
title: Text('failedToLoadUserInfo'.tr()),
content: Text(
[
'failedToLoadUserInfoNetwork'.tr(),
error.toString(),
].join('\n\n').trim(),
),
actions: [
TextButton(
onPressed: () => close(false),
child: Text('okay'.tr()),
),
TextButton(
onPressed: () => close(true),
child: Text('retry'.tr()),
),
],
),
).then((value) {
if (value == CustomButton.neutralButton) {
if (value == true) {
fetchUser();
}
});

View File

@@ -12,7 +12,9 @@ import 'package:island/screens/developers/hub.dart';
import 'package:island/screens/developers/edit_project.dart';
import 'package:island/screens/developers/new_project.dart';
import 'package:island/screens/discovery/articles.dart';
import 'package:island/models/file.dart';
import 'package:island/screens/files/file_list.dart';
import 'package:island/screens/files/file_detail.dart';
import 'package:island/screens/posts/post_categories_list.dart';
import 'package:island/screens/posts/post_category_detail.dart';
import 'package:island/screens/posts/post_search.dart';
@@ -28,10 +30,8 @@ import 'package:island/screens/account/me/profile_update.dart';
import 'package:island/screens/account/leveling.dart';
import 'package:island/screens/account/me/account_settings.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_detail.dart';
import 'package:island/screens/chat/call.dart';
import 'package:island/screens/chat/search_messages.dart';
import 'package:island/screens/thought/think.dart';
import 'package:island/screens/creators/hub.dart';
@@ -42,9 +42,9 @@ import 'package:island/screens/stickers/pack_detail.dart';
import 'package:island/screens/discovery/feeds/feed_marketplace.dart';
import 'package:island/screens/discovery/feeds/feed_detail.dart';
import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/creators/sites/site_detail.dart';
import 'package:island/screens/creators/sites/site_list.dart';
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/compose_article.dart';
import 'package:island/screens/posts/post_detail.dart';
@@ -118,19 +118,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return ArticleEditScreen(id: id);
},
),
GoRoute(
name: 'chatCall',
path: '/chat/:id/call',
builder: (context, state) {
final id = state.pathParameters['id']!;
return CallScreen(roomId: id);
},
),
GoRoute(
name: 'thought',
path: '/thought',
builder: (context, state) => const ThoughtScreen(),
),
GoRoute(
name: 'logs',
path: '/logs',
@@ -176,6 +163,22 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const AboutScreen(),
),
GoRoute(
name: 'fileDetail',
path: '/files/:id',
builder: (context, state) {
// For now, we'll need to pass the file object through extra
// This will be updated when we modify the file list navigation
final file = state.extra as SnCloudFile?;
if (file != null) {
return FileDetailScreen(item: file);
}
// Fallback - this shouldn't happen in normal flow
Navigator.of(context).pop();
return const SizedBox.shrink();
},
),
// Main tabs with TabsScreen shell
ShellRoute(
navigatorKey: _tabsShellKey,
@@ -269,11 +272,6 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/chat',
builder: (context, state) => const ChatListScreen(),
),
GoRoute(
name: 'chatNew',
path: '/chat/new',
builder: (context, state) => const NewChatScreen(),
),
GoRoute(
name: 'chatRoom',
path: '/chat/:id',
@@ -282,14 +280,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return ChatRoomScreen(id: id);
},
),
GoRoute(
name: 'chatEdit',
path: '/chat/:id/edit',
builder: (context, state) {
final id = state.pathParameters['id']!;
return EditChatScreen(id: id);
},
),
GoRoute(
name: 'chatDetail',
path: '/chat/:id/detail',
@@ -396,11 +386,6 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/account/wallet',
builder: (context, state) => const WalletScreen(),
),
GoRoute(
name: 'files',
path: '/account/files',
builder: (context, state) => const FileListScreen(),
),
GoRoute(
name: 'relationships',
path: '/account/relationships',
@@ -445,6 +430,21 @@ final routerProvider = Provider<GoRouter>((ref) {
return AccountProfileScreen(name: name);
},
),
// Files tab
GoRoute(
name: 'files',
path: '/files',
builder: (context, state) => const FileListScreen(),
),
// SN-chan tab
GoRoute(
name: 'thought',
path: '/thought',
builder: (context, state) => const ThoughtScreen(),
),
// Creator hub tab
GoRoute(
name: 'creatorHub',
@@ -477,28 +477,30 @@ final routerProvider = Provider<GoRouter>((ref) {
return CreatorPollListScreen(pubName: name);
},
),
// Poll routes
// Site list route
GoRoute(
name: 'creatorPollNew',
path: ':name/polls/new',
name: 'creatorSites',
path: ':name/sites',
builder: (context, state) {
final name = state.pathParameters['name']!;
// initialPollId left null for create; initialPublisher prefilled
return PollEditorScreen(initialPublisher: name);
},
),
GoRoute(
name: 'creatorPollEdit',
path: ':name/polls/:id/edit',
builder: (context, state) {
final name = state.pathParameters['name']!;
final id = state.pathParameters['id']!;
return PollEditorScreen(
initialPollId: id,
initialPublisher: name,
);
return CreatorSiteListScreen(pubName: name);
},
routes: [
GoRoute(
name: 'creatorSiteDetail',
path: ':siteSlug',
builder: (context, state) {
final name = state.pathParameters['name']!;
final siteSlug = state.pathParameters['siteSlug']!;
return PublicationSiteDetailScreen(
siteSlug: siteSlug,
pubName: name,
);
},
),
],
),
GoRoute(
name: 'creatorStickers',
path: ':name/stickers',
@@ -507,19 +509,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return StickersScreen(pubName: name);
},
),
GoRoute(
name: 'creatorNew',
path: 'new',
builder: (context, state) => const NewPublisherScreen(),
),
GoRoute(
name: 'creatorEdit',
path: ':name/edit',
builder: (context, state) {
final name = state.pathParameters['name']!;
return EditPublisherScreen(name: name);
},
),
],
),

View File

@@ -384,9 +384,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
icon: const Icon(Symbols.content_copy, size: 16),
onPressed: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('copiedToClipboard'.tr())),
);
showSnackBar('copiedToClipboard'.tr());
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),

View File

@@ -129,7 +129,7 @@ class AccountScreen extends HookConsumerWidget {
pathParameters: {'name': user.value!.name},
);
},
),
).padding(bottom: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -375,6 +375,17 @@ class AccountScreen extends HookConsumerWidget {
);
},
),
if (!isWideScreen(context))
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.files),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('files').tr(),
onTap: () {
context.goNamed('files');
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.wallet),
@@ -385,16 +396,6 @@ class AccountScreen extends HookConsumerWidget {
context.pushNamed('wallet');
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.files),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('files').tr(),
onTap: () {
context.pushNamed('files');
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.people),

View File

@@ -84,9 +84,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
'accountPasswordChange'.tr(),
);
if (!confirm || !context.mounted) return;
final captchaTk = await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
try {
if (context.mounted) showLoadingModal(context);

View File

@@ -76,7 +76,7 @@ class UpdateProfileScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,

View File

@@ -2,9 +2,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/screens/auth/captcha.config.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
class CaptchaScreen extends ConsumerWidget {
static Future<String?> show(BuildContext context) {
return Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => const CaptchaScreen(),
fullscreenDialog: true,
),
);
}
const CaptchaScreen({super.key});
@override
@@ -13,9 +23,9 @@ class CaptchaScreen extends ConsumerWidget {
if (!captchaUrl.hasValue) return Center(child: CircularProgressIndicator());
return AppScaffold(
appBar: AppBar(title: Text("Anti-Robot")),
body: InAppWebView(
return SheetScaffold(
titleText: "Anti-Robot",
child: InAppWebView(
initialUrlRequest: URLRequest(
url: WebUri('${captchaUrl.value}?redirect_uri=solian://captcha'),
),

View File

@@ -1,14 +1,22 @@
// ignore_for_file: invalid_runtime_check_with_js_interop_types
import 'dart:ui_web' as ui;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/config.dart';
import 'package:island/screens/auth/captcha.config.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:web/web.dart' as web;
import 'package:flutter/material.dart';
class CaptchaScreen extends ConsumerStatefulWidget {
static Future<String?> show(BuildContext context) {
return Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => const CaptchaScreen(),
fullscreenDialog: true,
),
);
}
const CaptchaScreen({super.key});
@override
@@ -20,7 +28,9 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
void _setupWebListener(String serverUrl) async {
web.window.onMessage.listen((event) {
// ignore: invalid_runtime_check_with_js_interop_types
if (event.data != null && event.data is String) {
// ignore: invalid_runtime_check_with_js_interop_types
final message = event.data as String;
if (message.startsWith("captcha_tk=")) {
String token = message.replaceFirst("captcha_tk=", "");
@@ -61,9 +71,9 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(title: Text("Anti-Robot")),
body:
return SheetScaffold(
titleText: "Anti-Robot",
child:
_isInitialized
? HtmlElementView(viewType: 'captcha-iframe')
: Center(child: CircularProgressIndicator()),

View File

@@ -1,317 +1,22 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:email_validator/email_validator.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/account/me/profile_update.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'captcha.dart';
import 'create_account_content.dart';
class CreateAccountScreen extends HookConsumerWidget {
const CreateAccountScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
final emailController = useTextEditingController();
final usernameController = useTextEditingController();
final nicknameController = useTextEditingController();
final passwordController = useTextEditingController();
void showPostCreateModal() {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => _PostCreateModal(),
);
}
void performAction() async {
if (!formKey.currentState!.validate()) return;
final captchaTk = await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
if (captchaTk == null) return;
if (!context.mounted) return;
try {
showLoadingModal(context);
final client = ref.watch(apiClientProvider);
await client.post(
'/pass/accounts',
data: {
'name': usernameController.text,
'nick': nicknameController.text,
'email': emailController.text,
'password': passwordController.text,
'language':
kServerSupportedLanguages[EasyLocalization.of(
context,
)!.currentLocale.toString()] ??
'en-us',
'captcha_token': captchaTk,
},
);
if (!context.mounted) return;
hideLoadingModal(context);
showPostCreateModal();
} catch (err) {
if (context.mounted) hideLoadingModal(context);
showErrorAlert(err);
}
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('createAccount').tr(),
),
body:
StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 380),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.person_add, size: 28),
).padding(bottom: 8),
),
Text(
'createAccount',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).tr().padding(left: 4, bottom: 16),
Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
TextFormField(
controller: usernameController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'username'.tr(),
helperText: 'usernameCannotChangeHint'.tr(),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
const Gap(12),
TextFormField(
controller: nicknameController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
autocorrect: false,
autofillHints: const [AutofillHints.nickname],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'nickname'.tr(),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
const Gap(12),
TextFormField(
controller: emailController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
if (!EmailValidator.validate(value)) {
return 'fieldEmailAddressMustBeValid'.tr();
}
return null;
},
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'email'.tr(),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
const Gap(12),
TextFormField(
controller: passwordController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'password'.tr(),
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
],
).padding(horizontal: 7),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(
context,
).textTheme.bodySmall!.copyWith(
color: Theme.of(context).colorScheme.onSurface
.withAlpha((255 * 0.75).round()),
),
),
Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink').tr(),
const Gap(4),
const Icon(Symbols.launch, size: 14),
],
),
onTap: () {
launchUrlString(
'https://solsynth.dev/terms',
);
},
),
),
],
),
),
).padding(horizontal: 16),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
performAction();
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("next").tr(),
const Icon(Symbols.chevron_right),
],
),
),
),
],
),
),
),
).padding(all: 24).center(),
);
}
}
class _PostCreateModal extends HookConsumerWidget {
const _PostCreateModal();
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('🎉').fontSize(32),
Text(
'postCreateAccountTitle'.tr(),
textAlign: TextAlign.center,
).fontSize(17),
const Gap(18),
Text('postCreateAccountNext').tr().fontSize(19).bold(),
const Gap(4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
Text('\u2022'),
Expanded(child: Text('postCreateAccountNext1').tr()),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
Text('\u2022'),
Expanded(child: Text('postCreateAccountNext2').tr()),
],
),
const Gap(6),
TextButton(
onPressed: () {
Navigator.pop(context);
context.pushReplacementNamed('login');
},
child: Text('login'.tr()),
),
],
),
),
body: CreateAccountContent(),
);
}
}

View File

@@ -0,0 +1,486 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:email_validator/email_validator.dart';
import 'package:flutter/foundation.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:gap/gap.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/screens/account/me/profile_update.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/udid.dart';
import 'package:island/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'captcha.dart';
Widget getProviderIcon(String provider, {double size = 24, Color? color}) {
final providerLower = provider.toLowerCase();
// Check if we have an SVG for this provider
switch (providerLower) {
case 'apple':
case 'microsoft':
case 'google':
case 'github':
case 'discord':
case 'afdian':
case 'steam':
return SvgPicture.asset(
'assets/images/oidc/$providerLower.svg',
width: size,
height: size,
colorFilter:
color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null,
);
case 'spotify':
return Image.asset(
'assets/images/oidc/spotify.png',
width: size,
height: size,
color: color,
);
default:
return Icon(Symbols.link, size: size);
}
}
class CreateAccountContent extends HookConsumerWidget {
const CreateAccountContent({super.key});
Map<String, dynamic> decodeJwt(String token) {
final parts = token.split('.');
if (parts.length != 3) throw FormatException('Invalid JWT');
final payload = parts[1];
final normalized = base64Url.normalize(payload);
final decoded = utf8.decode(base64Url.decode(normalized));
return json.decode(decoded);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
final emailController = useTextEditingController();
final usernameController = useTextEditingController();
final nicknameController = useTextEditingController();
final passwordController = useTextEditingController();
final waitingForOidc = useState(false);
final onboardingToken = useState<String?>(null);
void showPostCreateModal() {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => _PostCreateModal(),
);
}
void performAction() async {
if (!formKey.currentState!.validate()) return;
String endpoint = '/pass/accounts';
Map<String, dynamic> data = {};
if (onboardingToken.value != null) {
// OIDC onboarding
endpoint = '/pass/account/onboard';
data['onboarding_token'] = onboardingToken.value;
data['name'] = usernameController.text;
data['nick'] = nicknameController.text;
// Password is required in form, but might be optional
} else {
// Manual account creation
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
if (!context.mounted) return;
data['captcha_token'] = captchaTk;
data['name'] = usernameController.text;
data['nick'] = nicknameController.text;
data['email'] = emailController.text;
data['password'] = passwordController.text;
data['language'] =
kServerSupportedLanguages[EasyLocalization.of(
context,
)!.currentLocale.toString()] ??
'en-us';
}
if (!context.mounted) return;
try {
showLoadingModal(context);
final client = ref.watch(apiClientProvider);
final resp = await client.post(endpoint, data: data);
if (endpoint == '/pass/account/onboard') {
// Onboard response has tokens, set them
final token = resp.data['token'];
setToken(ref.watch(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
final userNotifier = ref.read(userInfoProvider.notifier);
await userNotifier.fetchUser();
final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient);
final wsNotifier = ref.read(websocketStateProvider.notifier);
wsNotifier.connect();
if (context.mounted) Navigator.pop(context, true);
} else {
if (!context.mounted) return;
hideLoadingModal(context);
onboardingToken.value = null; // reset
showPostCreateModal();
}
} catch (err) {
if (context.mounted) hideLoadingModal(context);
showErrorAlert(err);
}
}
useEffect(() {
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
event,
) async {
if (!waitingForOidc.value || !context.mounted) return;
waitingForOidc.value = false;
final client = ref.watch(apiClientProvider);
try {
// Exchange code for tokens
final resp = await client.post(
'/pass/auth/token',
data: {
'grant_type': 'authorization_code',
'code': event.challengeId,
},
);
final data = resp.data;
if (data.containsKey('onboarding_token')) {
// New user onboarding
final token = data['onboarding_token'] as String;
final decoded = decodeJwt(token);
final name = decoded['name'] as String?;
final email = decoded['email'] as String?;
final provider = decoded['provider'] as String?;
// Pre-fill form
usernameController.text = '';
nicknameController.text = name ?? '';
emailController.text = email ?? '';
passwordController.clear(); // User needs to set password
onboardingToken.value = token;
// Optionally show a message
showSnackBar('Pre-filled from ${provider ?? 'provider'}');
} else {
// Existing user, switch to login
showSnackBar('Account already exists. Redirecting to login.');
if (context.mounted) context.goNamed('login');
}
} catch (err) {
showErrorAlert(err);
}
});
return subscription.cancel;
}, [waitingForOidc.value, context.mounted]);
Future<void> withOidc(String provider) async {
waitingForOidc.value = true;
final serverUrl = ref.watch(serverUrlProvider);
final deviceId = await getUdid();
final url =
Uri.parse('$serverUrl/pass/auth/login/${provider.toLowerCase()}')
.replace(
queryParameters: {
'returnUrl': 'solian://auth/callback',
'deviceId': deviceId,
'flow': 'login',
},
)
.toString();
final isLaunched = await launchUrlString(
url,
mode:
kIsWeb
? LaunchMode.platformDefault
: LaunchMode.externalApplication,
);
if (!isLaunched) {
waitingForOidc.value = false;
showErrorAlert('failedToLaunchBrowser'.tr());
}
}
return StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 380),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.person_add, size: 28),
).padding(bottom: 8),
),
Text(
'createAccount',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).tr().padding(left: 4, bottom: 16),
if (!kIsWeb)
Row(
spacing: 6,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("orCreateWith").tr().fontSize(11).opacity(0.85),
const Gap(8),
Spacer(),
IconButton.filledTonal(
onPressed: () => withOidc('github'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"github",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'GitHub',
),
IconButton.filledTonal(
onPressed: () => withOidc('google'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"google",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Google',
),
IconButton.filledTonal(
onPressed: () => withOidc('apple'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"apple",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Apple Account',
),
],
).padding(horizontal: 8, vertical: 8)
else
const Gap(12),
Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
TextFormField(
controller: usernameController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'username'.tr(),
helperText: 'usernameCannotChangeHint'.tr(),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
controller: nicknameController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
autocorrect: false,
autofillHints: const [AutofillHints.nickname],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'nickname'.tr(),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
controller: emailController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
if (!EmailValidator.validate(value)) {
return 'fieldEmailAddressMustBeValid'.tr();
}
return null;
},
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'email'.tr(),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextFormField(
controller: passwordController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'fieldCannotBeEmpty'.tr();
}
return null;
},
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'password'.tr(),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
).padding(horizontal: 7),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(
context,
).textTheme.bodySmall!.copyWith(
color: Theme.of(context).colorScheme.onSurface
.withAlpha((255 * 0.75).round()),
),
),
Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink').tr(),
const Gap(4),
const Icon(Symbols.launch, size: 14),
],
),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
),
),
],
),
),
).padding(horizontal: 16),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
performAction();
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("next").tr(),
const Icon(Symbols.chevron_right),
],
),
),
),
],
),
),
),
).padding(all: 24).center();
}
}
class _PostCreateModal extends HookConsumerWidget {
const _PostCreateModal();
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('🎉').fontSize(32),
Text(
'postCreateAccountTitle'.tr(),
textAlign: TextAlign.center,
).fontSize(17),
const Gap(18),
Text('postCreateAccountNext').tr().fontSize(19).bold(),
const Gap(4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
Text('\u2022'),
Expanded(child: Text('postCreateAccountNext1').tr()),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
Text('\u2022'),
Expanded(child: Text('postCreateAccountNext2').tr()),
],
),
const Gap(6),
TextButton(
onPressed: () {
Navigator.pop(context);
context.pushReplacementNamed('login');
},
child: Text('login'.tr()),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/widgets/content/sheet.dart';
import 'create_account_content.dart';
class CreateAccountModal extends HookConsumerWidget {
const CreateAccountModal({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SheetScaffold(
titleText: 'createAccount'.tr(),
heightFactor: 0.9,
child: CreateAccountContent(),
);
}
}

View File

@@ -1,31 +1,10 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:animations/animations.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gap/gap.dart';
import 'package:island/models/auth.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/screens/account/me/settings_connections.dart';
import 'package:island/screens/auth/oidc.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/udid.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'captcha.dart';
import 'login_content.dart';
final Map<int, (String, String, IconData)> kFactorTypes = {
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
@@ -44,745 +23,13 @@ class LoginScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final period = useState(0);
final currentTicket = useState<SnAuthChallenge?>(null);
final factors = useState<List<SnAuthFactor>>([]);
final factorPicked = useState<SnAuthFactor?>(null);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('login').tr(),
),
body: Theme(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child: Column(
children: [
if (isBusy.value)
LinearProgressIndicator(
minHeight: 4,
borderRadius: BorderRadius.zero,
trackGap: 0,
stopIndicatorRadius: 0,
)
else if (currentTicket.value != null)
LinearProgressIndicator(
minHeight: 4,
borderRadius: BorderRadius.zero,
trackGap: 0,
stopIndicatorRadius: 0,
value:
1 -
(currentTicket.value!.stepRemain /
currentTicket.value!.stepTotal),
)
else
const Gap(4),
Expanded(
child:
SingleChildScrollView(
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: Container(
constraints: BoxConstraints(maxWidth: 380),
child: child,
),
);
},
child: switch (period.value % 3) {
1 => _LoginPickerScreen(
key: const ValueKey(1),
challenge: currentTicket.value,
factors: factors.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onPickFactor:
(SnAuthFactor p0) => factorPicked.value = p0,
onNext: () => period.value++,
onBusy: (value) => isBusy.value = value,
),
2 => _LoginCheckScreen(
key: const ValueKey(2),
challenge: currentTicket.value,
factor: factorPicked.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onNext: () => period.value = 1,
onBusy: (value) => isBusy.value = value,
),
_ => _LoginLookupScreen(
key: const ValueKey(0),
ticket: currentTicket.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onFactor:
(List<SnAuthFactor>? p0) =>
factors.value = p0 ?? [],
onNext: () => period.value++,
onBusy: (value) => isBusy.value = value,
),
},
).padding(all: 24),
).center(),
),
const Gap(4),
],
),
),
);
}
}
class _LoginCheckScreen extends HookConsumerWidget {
final SnAuthChallenge? challenge;
final SnAuthFactor? factor;
final Function(SnAuthChallenge?) onChallenge;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginCheckScreen({
super.key,
required this.challenge,
required this.factor,
required this.onChallenge,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final passwordController = useTextEditingController();
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
Future<void> getToken({String? code}) async {
// Get token if challenge is completed
final client = ref.watch(apiClientProvider);
final tokenResp = await client.post(
'/pass/auth/token',
data: {
'grant_type': 'authorization_code',
'code': code ?? challenge!.id,
},
);
final token = tokenResp.data['token'];
setToken(ref.watch(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
if (!context.mounted) return;
// Do post login tasks
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser().then((_) {
final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient);
final wsNotifier = ref.read(websocketStateProvider.notifier);
wsNotifier.connect();
if (context.mounted) Navigator.pop(context, true);
});
}
useEffect(() {
if (challenge != null && challenge?.stepRemain == 0) {
Future(() {
if (isBusy.value) return;
isBusy.value = true;
getToken().catchError((err) {
showErrorAlert(err);
isBusy.value = false;
});
});
}
return null;
}, [challenge]);
if (factor == null) {
// Logging in by third parties
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.asterisk, size: 28),
).padding(bottom: 8),
),
Text(
'loginInProgress'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4, bottom: 16),
const Gap(16),
CircularProgressIndicator().alignment(Alignment.centerLeft),
],
);
}
Future<void> performCheckTicket() async {
final pwd = passwordController.value.text;
if (pwd.isEmpty) return;
isBusy.value = true;
try {
// Pass challenge
final client = ref.watch(apiClientProvider);
final resp = await client.patch(
'/pass/auth/challenge/${challenge!.id}',
data: {'factor_id': factor!.id, 'password': pwd},
);
final result = SnAuthChallenge.fromJson(resp.data);
onChallenge(result);
if (result.stepRemain > 0) {
onNext();
return;
}
await getToken(code: result.id);
} catch (err) {
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
final width = math.min(380, MediaQuery.of(context).size.width);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.asterisk, size: 28),
).padding(bottom: 8),
),
Text(
'loginEnterPassword'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4, bottom: 16),
if ([0].contains(factor!.type))
TextField(
autocorrect: false,
enableSuggestions: false,
controller: passwordController,
obscureText: true,
autofillHints: [
factor!.type == 0
? AutofillHints.password
: AutofillHints.oneTimeCode,
],
decoration: InputDecoration(
isDense: true,
labelText: 'password'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
).padding(horizontal: 7)
else
OtpTextField(
showCursor: false,
numberOfFields: 6,
obscureText: false,
showFieldAsBox: true,
focusedBorderColor: Theme.of(context).colorScheme.primary,
fieldWidth: (width / 6) - 10,
onSubmit: (value) {
passwordController.text = value;
performCheckTicket();
},
textStyle: Theme.of(context).textTheme.titleLarge!,
),
const Gap(12),
ListTile(
leading: Icon(
kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
),
title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isBusy.value ? null : () => performCheckTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Symbols.chevron_right),
],
),
),
],
),
],
);
}
}
class _LoginPickerScreen extends HookConsumerWidget {
final SnAuthChallenge? challenge;
final List<SnAuthFactor>? factors;
final Function(SnAuthChallenge?) onChallenge;
final Function(SnAuthFactor) onPickFactor;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginPickerScreen({
super.key,
required this.challenge,
required this.factors,
required this.onChallenge,
required this.onPickFactor,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final factorPicked = useState<SnAuthFactor?>(null);
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
useEffect(() {
if (challenge != null && challenge?.stepRemain == 0) {
Future(() {
onNext();
});
}
return null;
}, [challenge]);
final unfocusColor = Theme.of(
context,
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
final hintController = useTextEditingController();
void performGetFactorCode() async {
if (factorPicked.value == null) return;
isBusy.value = true;
final client = ref.watch(apiClientProvider);
try {
await client.post(
'/pass/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}',
data:
hintController.text.isNotEmpty
? jsonEncode(hintController.text)
: null,
);
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
onNext();
} catch (err) {
if (err is DioException && err.response?.statusCode == 400) {
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
onNext();
if (context.mounted) {
showSnackBar(err.response!.data.toString());
}
return;
}
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
return Column(
key: const ValueKey<int>(1),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.lock, size: 28),
).padding(bottom: 8),
),
Text(
'loginPickFactor',
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).tr().padding(left: 4),
const Gap(8),
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children:
factors
?.map(
(x) => CheckboxListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
secondary: Icon(
kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
),
title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
enabled: !challenge!.blacklistFactors.contains(x.id),
value: factorPicked.value == x,
onChanged: (value) {
if (value == true) {
factorPicked.value = x;
}
},
),
)
.toList() ??
List.empty(),
),
),
if ([1].contains(factorPicked.value?.type))
TextField(
controller: hintController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'authFactorHint'.tr(),
helperText: 'authFactorHintHelper'.tr(),
),
).padding(top: 12, bottom: 4, horizontal: 4),
const Gap(8),
Text(
'loginMultiFactor'.plural(challenge!.stepRemain),
style: TextStyle(color: unfocusColor, fontSize: 13),
).padding(horizontal: 16),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isBusy.value ? null : () => performGetFactorCode(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr()),
const Icon(Symbols.chevron_right),
],
),
),
],
),
],
);
}
}
class _LoginLookupScreen extends HookConsumerWidget {
final SnAuthChallenge? ticket;
final Function(SnAuthChallenge?) onChallenge;
final Function(List<SnAuthFactor>?) onFactor;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginLookupScreen({
super.key,
required this.ticket,
required this.onChallenge,
required this.onFactor,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final usernameController = useTextEditingController();
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
Future<void> requestResetPassword() async {
final uname = usernameController.value.text;
if (uname.isEmpty) {
showErrorAlert('loginResetPasswordHint'.tr());
return;
}
final captchaTk = await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
if (captchaTk == null) return;
isBusy.value = true;
try {
final client = ref.watch(apiClientProvider);
await client.post(
'/pass/accounts/recovery/password',
data: {'account': uname, 'captcha_token': captchaTk},
);
showInfoAlert('loginResetPasswordSent'.tr(), 'done'.tr());
} catch (err) {
showErrorAlert(err);
} finally {
isBusy.value = false;
}
}
Future<void> performNewTicket() async {
final uname = usernameController.value.text;
if (uname.isEmpty) return;
isBusy.value = true;
try {
final client = ref.watch(apiClientProvider);
final resp = await client.post(
'/pass/auth/challenge',
data: {
'account': uname,
'device_id': await getUdid(),
'device_name': await getDeviceName(),
'platform':
kIsWeb
? 1
: switch (defaultTargetPlatform) {
TargetPlatform.iOS => 2,
TargetPlatform.android => 3,
TargetPlatform.macOS => 4,
TargetPlatform.windows => 5,
TargetPlatform.linux => 6,
_ => 0,
},
},
);
final result = SnAuthChallenge.fromJson(resp.data);
onChallenge(result);
final factorResp = await client.get(
'/pass/auth/challenge/${result.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
Future<void> withApple() async {
final client = ref.watch(apiClientProvider);
try {
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
webAuthenticationOptions: WebAuthenticationOptions(
clientId: 'dev.solsynth.solarpass',
redirectUri: Uri.parse('https://nt.solian.app/auth/callback/apple'),
),
);
if (context.mounted) showLoadingModal(context);
final resp = await client.post(
'/pass/auth/login/apple/mobile',
data: {
'identity_token': credential.identityToken!,
'authorization_code': credential.authorizationCode,
'device_id': await getUdid(),
'device_name': await getDeviceName(),
},
);
final challenge = SnAuthChallenge.fromJson(resp.data);
onChallenge(challenge);
final factorResp = await client.get(
'/pass/auth/challenge/${challenge.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
if (err is SignInWithAppleAuthorizationException) return;
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> withOidc(String provider) async {
final challengeId = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => OidcScreen(provider: provider.toLowerCase()),
),
);
final client = ref.watch(apiClientProvider);
try {
final resp = await client.get('/pass/auth/challenge/$challengeId');
final challenge = SnAuthChallenge.fromJson(resp.data);
onChallenge(challenge);
final factorResp = await client.get(
'/pass/auth/challenge/${challenge.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
showErrorAlert(err);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.login, size: 28),
).padding(bottom: 8),
),
Text(
'loginGreeting',
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).tr().padding(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'username'.tr(),
helperText: 'usernameLookupHint'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: isBusy.value ? null : (_) => performNewTicket(),
).padding(horizontal: 7),
if (!kIsWeb)
Row(
spacing: 6,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("loginOr").tr().fontSize(11).opacity(0.85),
const Gap(8),
Spacer(),
IconButton.filledTonal(
onPressed: () => withOidc('github'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"github",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'GitHub',
),
IconButton.filledTonal(
onPressed: () => withOidc('google'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"google",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Google',
),
IconButton.filledTonal(
onPressed: withApple,
padding: EdgeInsets.zero,
icon: getProviderIcon(
"apple",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Apple Account',
),
],
).padding(horizontal: 8, vertical: 8)
else
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: isBusy.value ? null : () => requestResetPassword(),
style: TextButton.styleFrom(foregroundColor: Colors.grey),
child: Text('forgotPassword'.tr()),
),
TextButton(
onPressed: isBusy.value ? null : () => performNewTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Symbols.chevron_right),
],
),
),
],
),
const Gap(12),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
),
),
Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink'.tr()),
const Gap(4),
const Icon(Symbols.launch, size: 14),
],
),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
),
),
],
),
),
).padding(horizontal: 16),
),
],
body: LoginContent(),
);
}
}

View File

@@ -0,0 +1,814 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:animations/animations.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gap/gap.dart';
import 'package:island/models/auth.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/screens/account/me/settings_connections.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/udid.dart';
import 'package:island/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'captcha.dart';
final Map<int, (String, String, IconData)> kFactorTypes = {
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
2: (
'authFactorInAppNotify',
'authFactorInAppNotifyDescription',
Symbols.notifications_active,
),
3: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm),
};
class _LoginCheckScreen extends HookConsumerWidget {
final SnAuthChallenge? challenge;
final SnAuthFactor? factor;
final Function(SnAuthChallenge?) onChallenge;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginCheckScreen({
super.key,
required this.challenge,
required this.factor,
required this.onChallenge,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final passwordController = useTextEditingController();
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
Future<void> getToken({String? code}) async {
// Get token if challenge is completed
final client = ref.watch(apiClientProvider);
final tokenResp = await client.post(
'/pass/auth/token',
data: {
'grant_type': 'authorization_code',
'code': code ?? challenge!.id,
},
);
final token = tokenResp.data['token'];
setToken(ref.watch(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
if (!context.mounted) return;
// Do post login tasks
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser().then((_) {
final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient);
final wsNotifier = ref.read(websocketStateProvider.notifier);
wsNotifier.connect();
if (context.mounted) Navigator.pop(context, true);
});
}
useEffect(() {
if (challenge != null && challenge?.stepRemain == 0) {
Future(() {
if (isBusy.value) return;
isBusy.value = true;
getToken().catchError((err) {
showErrorAlert(err);
isBusy.value = false;
});
});
}
return null;
}, [challenge]);
if (factor == null) {
// Logging in by third parties
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.asterisk, size: 28),
).padding(bottom: 8),
),
Text(
'loginInProgress'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4, bottom: 16),
const Gap(16),
CircularProgressIndicator().alignment(Alignment.centerLeft),
],
);
}
Future<void> performCheckTicket() async {
final pwd = passwordController.value.text;
if (pwd.isEmpty) return;
isBusy.value = true;
try {
// Pass challenge
final client = ref.watch(apiClientProvider);
final resp = await client.patch(
'/pass/auth/challenge/${challenge!.id}',
data: {'factor_id': factor!.id, 'password': pwd},
);
final result = SnAuthChallenge.fromJson(resp.data);
onChallenge(result);
if (result.stepRemain > 0) {
onNext();
return;
}
await getToken(code: result.id);
} catch (err) {
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
final width = math.min(380, MediaQuery.of(context).size.width);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.asterisk, size: 28),
).padding(bottom: 8),
),
Text(
'loginEnterPassword'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4, bottom: 16),
if ([0].contains(factor!.type))
TextField(
autocorrect: false,
enableSuggestions: false,
controller: passwordController,
obscureText: true,
autofillHints: [
factor!.type == 0
? AutofillHints.password
: AutofillHints.oneTimeCode,
],
decoration: InputDecoration(
isDense: true,
labelText: 'password'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
).padding(horizontal: 7)
else
OtpTextField(
showCursor: false,
numberOfFields: 6,
obscureText: false,
showFieldAsBox: true,
focusedBorderColor: Theme.of(context).colorScheme.primary,
fieldWidth: (width / 6) - 10,
onSubmit: (value) {
passwordController.text = value;
performCheckTicket();
},
textStyle: Theme.of(context).textTheme.titleLarge!,
),
const Gap(12),
ListTile(
leading: Icon(
kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
),
title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isBusy.value ? null : () => performCheckTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Symbols.chevron_right),
],
),
),
],
),
],
);
}
}
class LoginContent extends HookConsumerWidget {
const LoginContent({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final period = useState(0);
final currentTicket = useState<SnAuthChallenge?>(null);
final factors = useState<List<SnAuthFactor>>([]);
final factorPicked = useState<SnAuthFactor?>(null);
return Theme(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child: Column(
children: [
if (isBusy.value)
LinearProgressIndicator(
minHeight: 4,
borderRadius: BorderRadius.zero,
trackGap: 0,
stopIndicatorRadius: 0,
)
else if (currentTicket.value != null)
LinearProgressIndicator(
minHeight: 4,
borderRadius: BorderRadius.zero,
trackGap: 0,
stopIndicatorRadius: 0,
value:
1 -
(currentTicket.value!.stepRemain /
currentTicket.value!.stepTotal),
)
else
const Gap(4),
Expanded(
child:
SingleChildScrollView(
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: Container(
constraints: BoxConstraints(maxWidth: 380),
child: child,
),
);
},
child: switch (period.value % 3) {
1 => _LoginPickerScreen(
key: const ValueKey(1),
challenge: currentTicket.value,
factors: factors.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onPickFactor:
(SnAuthFactor p0) => factorPicked.value = p0,
onNext: () => period.value++,
onBusy: (value) => isBusy.value = value,
),
2 => _LoginCheckScreen(
key: const ValueKey(2),
challenge: currentTicket.value,
factor: factorPicked.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onNext: () => period.value = 1,
onBusy: (value) => isBusy.value = value,
),
_ => _LoginLookupScreen(
key: const ValueKey(0),
ticket: currentTicket.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onFactor:
(List<SnAuthFactor>? p0) =>
factors.value = p0 ?? [],
onNext: () => period.value++,
onBusy: (value) => isBusy.value = value,
),
},
).padding(all: 24),
).center(),
),
const Gap(4),
],
),
);
}
}
class _LoginPickerScreen extends HookConsumerWidget {
final SnAuthChallenge? challenge;
final List<SnAuthFactor>? factors;
final Function(SnAuthChallenge?) onChallenge;
final Function(SnAuthFactor) onPickFactor;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginPickerScreen({
super.key,
required this.challenge,
required this.factors,
required this.onChallenge,
required this.onPickFactor,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final factorPicked = useState<SnAuthFactor?>(null);
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
useEffect(() {
if (challenge != null && challenge?.stepRemain == 0) {
Future(() {
onNext();
});
}
return null;
}, [challenge]);
final unfocusColor = Theme.of(
context,
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
final hintController = useTextEditingController();
void performGetFactorCode() async {
if (factorPicked.value == null) return;
isBusy.value = true;
final client = ref.watch(apiClientProvider);
try {
await client.post(
'/pass/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}',
data:
hintController.text.isNotEmpty
? jsonEncode(hintController.text)
: null,
);
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
onNext();
} catch (err) {
if (err is DioException && err.response?.statusCode == 400) {
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
onNext();
if (context.mounted) {
showSnackBar(err.response!.data.toString());
}
return;
}
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
return Column(
key: const ValueKey<int>(1),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.lock, size: 28),
).padding(bottom: 8),
),
Text(
'loginPickFactor'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4),
const Gap(8),
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children:
factors
?.map(
(x) => CheckboxListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
secondary: Icon(
kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
),
title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
enabled: !challenge!.blacklistFactors.contains(x.id),
value: factorPicked.value == x,
onChanged: (value) {
if (value == true) {
factorPicked.value = x;
}
},
),
)
.toList() ??
List.empty(),
),
),
if ([1].contains(factorPicked.value?.type))
TextField(
controller: hintController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'authFactorHint'.tr(),
helperText: 'authFactorHintHelper'.tr(),
),
).padding(top: 12, bottom: 4, horizontal: 4),
const Gap(8),
Text(
'loginMultiFactor'.plural(challenge!.stepRemain),
style: TextStyle(color: unfocusColor, fontSize: 13),
).padding(horizontal: 16),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isBusy.value ? null : () => performGetFactorCode(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr()),
const Icon(Symbols.chevron_right),
],
),
),
],
),
],
);
}
}
class _LoginLookupScreen extends HookConsumerWidget {
final SnAuthChallenge? ticket;
final Function(SnAuthChallenge?) onChallenge;
final Function(List<SnAuthFactor>?) onFactor;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginLookupScreen({
super.key,
required this.ticket,
required this.onChallenge,
required this.onFactor,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final usernameController = useTextEditingController();
final waitingForOidc = useState(false);
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
useEffect(() {
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
event,
) async {
if (!waitingForOidc.value || !context.mounted) return;
waitingForOidc.value = false;
final client = ref.watch(apiClientProvider);
try {
final resp = await client.get(
'/pass/auth/challenge/${event.challengeId}',
);
final challenge = SnAuthChallenge.fromJson(resp.data);
onChallenge(challenge);
final factorResp = await client.get(
'/pass/auth/challenge/${challenge.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
showErrorAlert(err);
}
});
return subscription.cancel;
}, [waitingForOidc.value, context.mounted]);
Future<void> requestResetPassword() async {
final uname = usernameController.value.text;
if (uname.isEmpty) {
showErrorAlert('loginResetPasswordHint'.tr());
return;
}
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
isBusy.value = true;
try {
final client = ref.watch(apiClientProvider);
await client.post(
'/pass/accounts/recovery/password',
data: {'account': uname, 'captcha_token': captchaTk},
);
showInfoAlert('loginResetPasswordSent'.tr(), 'done'.tr());
} catch (err) {
showErrorAlert(err);
} finally {
isBusy.value = false;
}
}
Future<void> performNewTicket() async {
final uname = usernameController.value.text;
if (uname.isEmpty) return;
isBusy.value = true;
try {
final client = ref.watch(apiClientProvider);
final resp = await client.post(
'/pass/auth/challenge',
data: {
'account': uname,
'device_id': await getUdid(),
'device_name': await getDeviceName(),
'platform':
kIsWeb
? 1
: switch (defaultTargetPlatform) {
TargetPlatform.iOS => 2,
TargetPlatform.android => 3,
TargetPlatform.macOS => 4,
TargetPlatform.windows => 5,
TargetPlatform.linux => 6,
_ => 0,
},
},
);
final result = SnAuthChallenge.fromJson(resp.data);
onChallenge(result);
final factorResp = await client.get(
'/pass/auth/challenge/${result.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
showErrorAlert(err);
return;
} finally {
isBusy.value = false;
}
}
Future<void> withApple() async {
final client = ref.watch(apiClientProvider);
try {
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
webAuthenticationOptions: WebAuthenticationOptions(
clientId: 'dev.solsynth.solarpass',
redirectUri: Uri.parse('https://nt.solian.app/auth/callback/apple'),
),
);
if (context.mounted) showLoadingModal(context);
final resp = await client.post(
'/pass/auth/login/apple/mobile',
data: {
'identity_token': credential.identityToken!,
'authorization_code': credential.authorizationCode,
'device_id': await getUdid(),
'device_name': await getDeviceName(),
},
);
final challenge = SnAuthChallenge.fromJson(resp.data);
onChallenge(challenge);
final factorResp = await client.get(
'/pass/auth/challenge/${challenge.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
if (err is SignInWithAppleAuthorizationException) return;
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> withOidc(String provider) async {
waitingForOidc.value = true;
final serverUrl = ref.watch(serverUrlProvider);
final token = ref.watch(tokenProvider);
final deviceId = await getUdid();
final queryParams = <String, String>{
'returnUrl': 'solian://auth/callback',
'deviceId': deviceId,
'flow': 'login',
};
if (token?.token != null) {
queryParams['token'] = token!.token;
}
final url =
Uri.parse(
'$serverUrl/pass/auth/login/${provider.toLowerCase()}',
).replace(queryParameters: queryParams).toString();
final isLaunched = await launchUrlString(
url,
mode:
kIsWeb
? LaunchMode.platformDefault
: LaunchMode.externalApplication,
webOnlyWindowName:
token?.token != null ? 'auth-${token!.token}' : 'auth',
);
if (!isLaunched) {
waitingForOidc.value = false;
showErrorAlert('failedToLaunchBrowser'.tr());
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: CircleAvatar(
radius: 26,
child: const Icon(Symbols.login, size: 28),
).padding(bottom: 8),
),
Text(
'loginGreeting'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'username'.tr(),
helperText: 'usernameLookupHint'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: isBusy.value ? null : (_) => performNewTicket(),
).padding(horizontal: 7),
if (!kIsWeb)
Row(
spacing: 6,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("loginOr").tr().fontSize(11).opacity(0.85),
const Gap(8),
Spacer(),
IconButton.filledTonal(
onPressed: () => withOidc('github'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"github",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'GitHub',
),
IconButton.filledTonal(
onPressed: () => withOidc('google'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"google",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Google',
),
IconButton.filledTonal(
onPressed: withApple,
padding: EdgeInsets.zero,
icon: getProviderIcon(
"apple",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Apple Account',
),
],
).padding(horizontal: 8, vertical: 8)
else
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: isBusy.value ? null : () => requestResetPassword(),
style: TextButton.styleFrom(foregroundColor: Colors.grey),
child: Text('forgotPassword'.tr()),
),
TextButton(
onPressed: isBusy.value ? null : () => performNewTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Symbols.chevron_right),
],
),
),
],
),
const Gap(12),
Align(
alignment: Alignment.centerRight,
child: StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 290),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'termAcceptNextWithAgree'.tr(),
textAlign: TextAlign.end,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
),
),
Material(
color: Colors.transparent,
child: InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('termAcceptLink'.tr()),
const Gap(4),
const Icon(Symbols.launch, size: 14),
],
),
onTap: () {
launchUrlString('https://solsynth.dev/terms');
},
),
),
],
),
),
).padding(horizontal: 16),
),
],
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/widgets/content/sheet.dart';
import 'login_content.dart';
class LoginModal extends HookConsumerWidget {
const LoginModal({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SheetScaffold(
titleText: 'login'.tr(),
heightFactor: 0.9,
child: LoginContent(),
);
}
}

View File

@@ -3,30 +3,31 @@ import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/chat/call.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/call_button.dart';
import 'package:island/widgets/chat/call_content.dart';
import 'package:island/widgets/chat/call_overlay.dart';
import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:island/widgets/alert.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class CallScreen extends HookConsumerWidget {
final String roomId;
const CallScreen({super.key, required this.roomId});
final SnChatRoom room;
const CallScreen({super.key, required this.room});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ongoingCall = ref.watch(ongoingCallProvider(roomId));
final ongoingCall = ref.watch(ongoingCallProvider(room.id));
final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.watch(callNotifierProvider.notifier);
useEffect(() {
talker.info('[Call] Joining the call...');
callNotifier.joinRoom(roomId).catchError((_) {
callNotifier.joinRoom(room).catchError((_) {
showConfirmAlert(
'Seems there already has a call connected, do you want override it?',
'Call already connected',
@@ -35,7 +36,7 @@ class CallScreen extends HookConsumerWidget {
talker.info('[Call] Joining the call... with overrides');
callNotifier.disconnect();
callNotifier.dispose();
callNotifier.joinRoom(roomId);
callNotifier.joinRoom(room);
});
});
return null;
@@ -110,7 +111,7 @@ class CallScreen extends HookConsumerWidget {
onPressed: () {
callNotifier.disconnect();
callNotifier.dispose();
callNotifier.joinRoom(roomId);
callNotifier.joinRoom(room);
},
child: Text('retry').tr(),
),
@@ -120,72 +121,7 @@ class CallScreen extends HookConsumerWidget {
)
: Column(
children: [
Expanded(
child: Builder(
builder: (context) {
if (!callState.isConnected) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (callNotifier.participants.isEmpty) {
return const Center(
child: Text('No participants in call'),
);
}
final participants = callNotifier.participants;
if (allAudioOnly) {
// Audio-only: show avatars in a compact row
return Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
for (final live in participants)
SpeakingRippleAvatar(
live: live,
size: 72,
).padding(horizontal: 4),
],
),
),
);
}
// Stage view: show main speaker(s) large, others in row
final mainSpeakers =
participants
.where(
(p) => p
.remoteParticipant
.trackPublications
.values
.any(
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO,
),
)
.toList();
if (mainSpeakers.isEmpty && participants.isNotEmpty) {
mainSpeakers.add(participants.first);
}
return Column(
children: [
for (final speaker in mainSpeakers)
Expanded(
child: CallParticipantTile(live: speaker),
),
],
);
},
),
),
Expanded(child: CallContent()),
CallControlsBar(),
Gap(MediaQuery.of(context).padding.bottom + 16),
],

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -6,9 +8,13 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/file.dart';
import 'package:island/models/account.dart';
import 'package:island/pods/database.dart';
import 'package:island/pods/chat/call.dart';
import 'package:island/pods/chat/chat_summary.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/realm/realms.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/responsive.dart';
@@ -47,6 +53,17 @@ class ChatRoomListTile extends HookConsumerWidget {
.watch(chatSummaryProvider)
.whenData((summaries) => summaries[room.id]);
var validMembers = room.members ?? [];
if (validMembers.isNotEmpty) {
final userInfo = ref.watch(userInfoProvider);
if (userInfo.value != null) {
validMembers =
validMembers
.where((e) => e.accountId != userInfo.value!.id)
.toList();
}
}
Widget buildSubtitle() {
if (subtitle != null) return subtitle!;
@@ -55,7 +72,7 @@ class ChatRoomListTile extends HookConsumerWidget {
if (data == null) {
return isDirect && room.description == null
? Text(
room.members!.map((e) => '@${e.account.name}').join(', '),
validMembers.map((e) => '@${e.account.name}').join(', '),
maxLines: 1,
)
: Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1);
@@ -111,7 +128,7 @@ class ChatRoomListTile extends HookConsumerWidget {
(_, _) =>
isDirect && room.description == null
? Text(
room.members!.map((e) => '@${e.account.name}').join(', '),
validMembers.map((e) => '@${e.account.name}').join(', '),
maxLines: 1,
)
: Text(
@@ -121,6 +138,17 @@ class ChatRoomListTile extends HookConsumerWidget {
);
}
String titleText;
if (isDirect && room.name == null) {
if (room.members?.isNotEmpty ?? false) {
titleText = validMembers.map((e) => e.account.nick).join(', ');
} else {
titleText = 'Direct Message';
}
} else {
titleText = room.name ?? '';
}
return ListTile(
leading: Badge(
isLabelVisible: summary.when(
@@ -132,7 +160,7 @@ class ChatRoomListTile extends HookConsumerWidget {
(isDirect && room.picture?.id == null)
? SplitAvatarWidget(
filesId:
room.members!
validMembers
.map((e) => e.account.profile.picture?.id)
.toList(),
)
@@ -140,11 +168,7 @@ class ChatRoomListTile extends HookConsumerWidget {
? CircleAvatar(child: Text(room.name![0].toUpperCase()))
: ProfilePictureWidget(fileId: room.picture?.id),
),
title: Text(
(isDirect && room.name == null)
? room.members!.map((e) => e.account.nick).join(', ')
: room.name ?? '',
),
title: Text(titleText),
subtitle: buildSubtitle(),
trailing: trailing, // Add this line
onTap: () async {
@@ -162,12 +186,92 @@ class ChatRoomListTile extends HookConsumerWidget {
@riverpod
Future<List<SnChatRoom>> chatroomsJoined(Ref ref) async {
final db = ref.watch(databaseProvider);
try {
final localRoomsData = await db.select(db.chatRooms).get();
if (localRoomsData.isNotEmpty) {
final localRooms = await Future.wait(
localRoomsData.map((row) async {
final membersRows =
await (db.select(db.chatMembers)
..where((m) => m.chatRoomId.equals(row.id))).get();
final members =
membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
SnAccountStatus? status;
if (mRow.status != null) {
status = SnAccountStatus.fromJson(jsonDecode(mRow.status!));
}
return SnChatMember(
id: mRow.id,
chatRoomId: mRow.chatRoomId,
accountId: mRow.accountId,
account: account,
nick: mRow.nick,
role: mRow.role,
notify: mRow.notify,
joinedAt: mRow.joinedAt,
breakUntil: mRow.breakUntil,
timeoutUntil: mRow.timeoutUntil,
isBot: mRow.isBot,
status: status,
lastTyped: mRow.lastTyped,
createdAt: mRow.createdAt,
updatedAt: mRow.updatedAt,
deletedAt: mRow.deletedAt,
chatRoom: null,
);
}).toList();
return SnChatRoom(
id: row.id,
name: row.name,
description: row.description,
type: row.type,
isPublic: row.isPublic!,
isCommunity: row.isCommunity!,
picture:
row.picture != null ? SnCloudFile.fromJson(row.picture!) : null,
background:
row.background != null
? SnCloudFile.fromJson(row.background!)
: null,
realmId: row.realmId,
realm: null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
members: members,
);
}),
);
// Background sync
Future(() async {
try {
final client = ref.read(apiClientProvider);
final resp = await client.get('/sphere/chat');
final remoteRooms =
resp.data
.map((e) => SnChatRoom.fromJson(e))
.cast<SnChatRoom>()
.toList();
await db.saveChatRooms(remoteRooms);
ref.invalidateSelf();
} catch (_) {}
}).ignore();
return localRooms;
}
} catch (_) {}
// Fallback to API
final client = ref.watch(apiClientProvider);
final resp = await client.get('/sphere/chat');
return resp.data
.map((e) => SnChatRoom.fromJson(e))
.cast<SnChatRoom>()
.toList();
final rooms =
resp.data.map((e) => SnChatRoom.fromJson(e)).cast<SnChatRoom>().toList();
await db.saveChatRooms(rooms);
return rooms;
}
class ChatListBodyWidget extends HookConsumerWidget {

View File

@@ -6,7 +6,7 @@ part of 'chat.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatroomsJoinedHash() => r'3bb6389af07e81007680484d04bf5fe6f6c10571';
String _$chatroomsJoinedHash() => r'9523efecd1869e7dd26adfc8ec87be48db19ee1c';
/// See also [chatroomsJoined].
@ProviderFor(chatroomsJoined)

View File

@@ -16,10 +16,10 @@ 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';
import 'package:island/widgets/content/sheet.dart';
class NewChatScreen extends StatelessWidget {
const NewChatScreen({super.key});
@@ -99,7 +99,7 @@ class EditChatScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,
@@ -151,12 +151,10 @@ class EditChatScreen extends HookConsumerWidget {
}
}
return AppScaffold(
appBar: AppBar(
title: Text(id == null ? 'createChatRoom' : 'editChatRoom').tr(),
leading: const PageBackButton(),
),
body: SingleChildScrollView(
return SheetScaffold(
titleText: (id == null ? 'createChatRoom' : 'editChatRoom').tr(),
onClose: () => context.pop(),
child: SingleChildScrollView(
child: Column(
children: [
AspectRatio(
@@ -204,16 +202,24 @@ class EditChatScreen extends HookConsumerWidget {
children: [
TextFormField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Name'),
decoration: InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: const InputDecoration(
decoration: InputDecoration(
labelText: 'Description',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
minLines: 3,
maxLines: null,
@@ -223,7 +229,12 @@ class EditChatScreen extends HookConsumerWidget {
const SizedBox(height: 16),
DropdownButtonFormField<SnRealm>(
value: currentRealm.value,
decoration: InputDecoration(labelText: 'realm'.tr()),
decoration: InputDecoration(
labelText: 'realm'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: [
DropdownMenuItem<SnRealm>(
value: null,

View File

@@ -2,6 +2,7 @@ import "dart:async";
import "dart:math" as math;
import "package:easy_localization/easy_localization.dart";
import "package:file_picker/file_picker.dart";
import "package:image_picker/image_picker.dart";
import "package:flutter/material.dart";
import "package:go_router/go_router.dart";
import "package:flutter_hooks/flutter_hooks.dart";
@@ -10,6 +11,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/database/message.dart";
import "package:island/models/chat.dart";
import "package:island/models/file.dart";
import "package:island/models/poll.dart";
import "package:island/models/wallet.dart";
import "package:island/pods/chat/chat_rooms.dart";
import "package:island/pods/chat/chat_subscribe.dart";
import "package:island/pods/chat/messages_notifier.dart";
@@ -36,6 +39,7 @@ import "package:island/widgets/chat/chat_input.dart";
import "package:island/widgets/chat/chat_link_attachments.dart";
import "package:island/widgets/chat/public_room_preview.dart";
import "package:island/screens/thought/think_sheet.dart";
import "package:island/screens/chat/widgets/message_item_wrapper.dart";
class ChatRoomScreen extends HookConsumerWidget {
final String id;
@@ -141,19 +145,72 @@ class ChatRoomScreen extends HookConsumerWidget {
final messageController = useTextEditingController();
final scrollController = useScrollController();
// Input height measurement for dynamic padding
final inputKey = useMemoized(() => GlobalKey());
final inputHeight = useState<double>(80.0);
// Periodic height measurement for dynamic sizing
useEffect(() {
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
final renderBox =
inputKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null) {
final newHeight = renderBox.size.height;
if (newHeight != inputHeight.value) {
inputHeight.value = newHeight;
}
}
});
return timer.cancel;
}, []);
// Scroll animation notifiers
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
final messageReplyingTo = useState<SnChatMessage?>(null);
final messageForwardingTo = useState<SnChatMessage?>(null);
final messageEditingTo = useState<SnChatMessage?>(null);
final selectedPoll = useState<SnPoll?>(null);
final selectedFund = useState<SnWalletFund?>(null);
final attachments = useState<List<UniversalFile>>([]);
final attachmentProgress = useState<Map<String, Map<int, double>>>({});
final attachmentProgress = useState<Map<String, Map<int, double?>>>({});
// Selection mode state
final isSelectionMode = useState<bool>(false);
final selectedMessages = useState<Set<String>>({});
final roomOpenTime = useMemoized(() => DateTime.now());
final onMessageAction = useCallback(
(String action, LocalChatMessage message) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(message.id);
case MessageItemAction.edit:
messageEditingTo.value = message.toRemoteMessage();
messageController.text = messageEditingTo.value?.content ?? '';
attachments.value =
messageEditingTo.value!.attachments
.map((e) => UniversalFile.fromAttachment(e))
.toList();
case MessageItemAction.forward:
messageForwardingTo.value = message.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value = message.toRemoteMessage();
case MessageItemAction.resend:
messagesNotifier.retryMessage(message.id);
}
},
[
messagesNotifier,
messageEditingTo,
messageController,
attachments,
messageForwardingTo,
messageReplyingTo,
],
);
var isLoading = false;
var isScrollingToMessage = false; // Flag to prevent scroll conflicts
@@ -181,16 +238,13 @@ class ChatRoomScreen extends HookConsumerWidget {
}, [scrollController]);
Future<void> pickPhotoMedia() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
final ImagePicker picker = ImagePicker();
final List<XFile> results = await picker.pickMultiImage();
if (results.isEmpty) return;
attachments.value = [
...attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.image),
...results.map(
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
),
];
}
@@ -265,10 +319,15 @@ class ChatRoomScreen extends HookConsumerWidget {
void sendMessage() {
if (messageController.text.trim().isNotEmpty ||
attachments.value.isNotEmpty) {
attachments.value.isNotEmpty ||
selectedPoll.value != null ||
selectedFund.value != null) {
messagesNotifier.sendMessage(
ref,
messageController.text.trim(),
attachments.value,
poll: selectedPoll.value,
fund: selectedFund.value,
editingTo: messageEditingTo.value,
forwardingTo: messageForwardingTo.value,
replyingTo: messageReplyingTo.value,
@@ -283,6 +342,8 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
selectedPoll.value = null;
selectedFund.value = null;
attachments.value = [];
}
}
@@ -563,7 +624,7 @@ class ChatRoomScreen extends HookConsumerWidget {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: attachment,
poolId: config.poolId,
mode:
@@ -573,7 +634,7 @@ class ChatRoomScreen extends HookConsumerWidget {
onProgress: (progress, _) {
attachmentProgress.value = {
...attachmentProgress.value,
'chat-upload': {index: progress},
'chat-upload': {index: progress ?? 0.0},
};
},
).future;
@@ -595,180 +656,67 @@ class ChatRoomScreen extends HookConsumerWidget {
Widget chatMessageListWidget(
List<LocalChatMessage> messageList,
) => SuperListView.builder(
listController: listController,
) => AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for chat input
bottom: MediaQuery.of(context).padding.bottom + 8 + inputHeight.value,
),
controller: scrollController,
reverse: true, // Show newest messages at the bottom
itemCount: messageList.length,
findChildIndexCallback: (key) {
if (key is! ValueKey<String>) return null;
final messageId = key.value.substring(messageKeyPrefix.length);
final index = messageList.indexWhere(
(m) => (m.nonce ?? m.id) == messageId,
);
// Return null for invalid indices to let SuperListView handle it properly
return index >= 0 ? index : null;
},
extentEstimation: (_, _) => 40,
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage =
index < messageList.length - 1 ? messageList[index + 1] : null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
child: SuperListView.builder(
listController: listController,
controller: scrollController,
reverse: true, // Show newest messages at the bottom
itemCount: messageList.length,
findChildIndexCallback: (key) {
if (key is! ValueKey<String>) return null;
final messageId = key.value.substring(messageKeyPrefix.length);
final index = messageList.indexWhere(
(m) => (m.nonce ?? m.id) == messageId,
);
return index >= 0 ? index : null;
},
extentEstimation: (_, _) => 40,
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage =
index < messageList.length - 1 ? messageList[index + 1] : null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
// Use a stable animation key that doesn't change during message lifecycle
final key = Key('$messageKeyPrefix${message.nonce ?? message.id}');
final key = Key('$messageKeyPrefix${message.nonce ?? message.id}');
final messageWidget = chatIdentity.when(
skipError: true,
data:
(identity) => GestureDetector(
onLongPress: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
onTap: () {
if (isSelectionMode.value) {
toggleMessageSelection(message.id);
}
},
child: Container(
color:
selectedMessages.value.contains(message.id)
? Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.3)
: null,
child: Stack(
children: [
MessageItem(
key: settings.disableAnimation ? key : null,
message: message,
isCurrentUser: identity?.id == message.senderId,
onAction:
isSelectionMode.value
? null
: (action) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(
message.id,
);
case MessageItemAction.edit:
messageEditingTo.value =
message.toRemoteMessage();
messageController.text =
messageEditingTo.value?.content ?? '';
attachments.value =
messageEditingTo.value!.attachments
.map(
(e) =>
UniversalFile.fromAttachment(
e,
),
)
.toList();
case MessageItemAction.forward:
messageForwardingTo.value =
message.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value =
message.toRemoteMessage();
case MessageItemAction.resend:
messagesNotifier.retryMessage(message.id);
}
},
onJump: (messageId) {
scrollToMessage(
messageId: messageId,
messageList: messageList,
messagesNotifier: messagesNotifier,
listController: listController,
scrollController: scrollController,
ref: ref,
);
},
progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode.value,
isSelected: selectedMessages.value.contains(message.id),
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
}
},
),
if (selectedMessages.value.contains(message.id))
Positioned(
top: 8,
right: 8,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 12,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
return MessageItemWrapper(
key: key,
message: message,
index: index,
isLastInGroup: isLastInGroup,
isSelectionMode: isSelectionMode.value,
selectedMessages: selectedMessages.value,
chatIdentity: chatIdentity,
toggleSelectionMode: toggleSelectionMode,
toggleMessageSelection: toggleMessageSelection,
onMessageAction: onMessageAction,
onJump:
(messageId) => scrollToMessage(
messageId: messageId,
messageList: messageList,
messagesNotifier: messagesNotifier,
listController: listController,
scrollController: scrollController,
ref: ref,
),
),
loading:
() => MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
),
error: (_, _) => const SizedBox.shrink(),
);
return settings.disableAnimation
? messageWidget
: TweenAnimationBuilder<double>(
key: key,
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(
milliseconds: 400 + (index % 5) * 50,
), // Staggered delay
curve: Curves.easeOutCubic,
builder: (context, animationValue, child) {
return Transform.translate(
offset: Offset(
0,
20 * (1 - animationValue),
), // Slide up from bottom
child: Opacity(opacity: animationValue, child: child),
);
},
child: messageWidget,
);
},
attachmentProgress: attachmentProgress.value,
disableAnimation: settings.disableAnimation,
roomOpenTime: roomOpenTime,
);
},
),
);
return AppScaffold(
@@ -790,7 +738,11 @@ class ChatRoomScreen extends HookConsumerWidget {
),
),
actions: [
AudioCallButton(roomId: id),
chatRoom.when(
data: (data) => AudioCallButton(room: data!),
error: (err, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () async {
@@ -891,7 +843,14 @@ class ChatRoomScreen extends HookConsumerWidget {
left: 0,
right: 0,
top: 0,
child: CallOverlayBar().padding(horizontal: 8, top: 12),
child: chatRoom.when(
data:
(data) => CallOverlayBar(
room: data!,
).padding(horizontal: 8, top: 12),
error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
),
if (isSyncing)
Positioned(
@@ -965,6 +924,7 @@ class ChatRoomScreen extends HookConsumerWidget {
child: chatRoom.when(
data:
(room) => Column(
key: inputKey,
mainAxisSize: MainAxisSize.min,
children: [
ChatInput(
@@ -979,10 +939,16 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
selectedPoll.value = null;
selectedFund.value = null;
},
messageEditingTo: messageEditingTo.value,
messageReplyingTo: messageReplyingTo.value,
messageForwardingTo: messageForwardingTo.value,
selectedPoll: selectedPoll.value,
onPollSelected: (poll) => selectedPoll.value = poll,
selectedFund: selectedFund.value,
onFundSelected: (fund) => selectedFund.value = fund,
onPickFile: (bool isPhoto) {
if (isPhoto) {
pickPhotoMedia();

View File

@@ -16,6 +16,7 @@ import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/screens/chat/chat_form.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -447,10 +448,17 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
if ((chatIdentity.value?.role ?? 0) >= 50)
PopupMenuItem(
onTap: () {
context.pushReplacementNamed(
'chatEdit',
pathParameters: {'id': id},
);
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => EditChatScreen(id: id),
).then((value) {
if (value != null) {
// Invalidate to refresh room data after edit
ref.invalidate(chatroomProvider(id));
}
});
},
child: Row(
children: [

View File

@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/database/message.dart';
import 'package:island/models/chat.dart';
import 'package:island/widgets/chat/message_item.dart';
// Provider to track animated messages to prevent replay
final animatedMessagesProvider = StateProvider<Set<String>>((ref) => {});
class MessageItemWrapper extends HookConsumerWidget {
final LocalChatMessage message;
final int index;
final bool isLastInGroup;
final bool isSelectionMode;
final Set<String> selectedMessages;
final AsyncValue<SnChatMember?> chatIdentity;
final VoidCallback toggleSelectionMode;
final Function(String) toggleMessageSelection;
final Function(String, LocalChatMessage) onMessageAction;
final Function(String) onJump;
final Map<String, Map<int, double?>> attachmentProgress;
final bool disableAnimation;
final DateTime roomOpenTime;
const MessageItemWrapper({
super.key,
required this.message,
required this.index,
required this.isLastInGroup,
required this.isSelectionMode,
required this.selectedMessages,
required this.chatIdentity,
required this.toggleSelectionMode,
required this.toggleMessageSelection,
required this.onMessageAction,
required this.onJump,
required this.attachmentProgress,
required this.disableAnimation,
required this.roomOpenTime,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Animation logic
final animatedMessages = ref.watch(animatedMessagesProvider);
final isNewMessage = message.createdAt.isAfter(roomOpenTime);
final hasAnimated = animatedMessages.contains(message.id);
// Only animate if:
// 1. Animation is enabled
// 2. Message is new (created after room open)
// 3. Has not animated yet
final shouldAnimate = !disableAnimation && isNewMessage && !hasAnimated;
final child = chatIdentity.when(
skipError: true,
data: (identity) => _buildContent(context, identity),
loading: () => _buildLoading(),
error: (_, _) => const SizedBox.shrink(),
);
if (!shouldAnimate) {
return child;
}
return TweenAnimationBuilder<double>(
key: ValueKey('anim-${message.id}'), // Ensure unique key for animation
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 400 + (index % 5) * 50),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: Opacity(opacity: value, child: child),
);
},
onEnd: () {
// Mark as animated
WidgetsBinding.instance.addPostFrameCallback((_) {
ref
.read(animatedMessagesProvider.notifier)
.update((state) => {...state, message.id});
});
},
child: child,
);
}
Widget _buildContent(BuildContext context, SnChatMember? identity) {
final isSelected = selectedMessages.contains(message.id);
final isCurrentUser = identity?.id == message.senderId;
return GestureDetector(
onLongPress: () {
if (!isSelectionMode) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
onTap: () {
if (isSelectionMode) {
toggleMessageSelection(message.id);
}
},
child: Container(
color:
isSelected
? Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.3)
: null,
child: Stack(
children: [
MessageItem(
// If animation is disabled, we might want to pass a key to maintain state?
// But here we are inside the wrapper.
key: ValueKey('item-${message.id}'),
message: message,
isCurrentUser: isCurrentUser,
onAction:
isSelectionMode
? null
: (action) => onMessageAction(action, message),
onJump: onJump,
progress: attachmentProgress[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode,
isSelected: isSelected,
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
if (!isSelectionMode) toggleSelectionMode();
},
),
if (isSelected)
Positioned(
top: 8,
right: 8,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 12,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
),
);
}
Widget _buildLoading() {
return MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
);
}
}

View File

@@ -261,7 +261,11 @@ class _PublisherUnselectedWidget extends HookConsumerWidget {
subtitle: Text('createPublisherHint').tr(),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
context.pushNamed('creatorNew').then((value) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const NewPublisherScreen(),
).then((value) {
if (value != null) {
ref.invalidate(publishersManagedProvider);
}
@@ -285,19 +289,18 @@ class CreatorHubScreen extends HookConsumerWidget {
);
void updatePublisher() {
context
.pushNamed(
'creatorEdit',
pathParameters: {'name': currentPublisher.value!.name},
)
.then((value) async {
if (value == null) return;
final data = await ref.refresh(publishersManagedProvider.future);
currentPublisher.value =
data
.where((e) => e.id == currentPublisher.value!.id)
.firstOrNull;
});
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) =>
EditPublisherScreen(name: currentPublisher.value!.name),
).then((value) async {
if (value == null) return;
final data = await ref.refresh(publishersManagedProvider.future);
currentPublisher.value =
data.where((e) => e.id == currentPublisher.value!.id).firstOrNull;
});
}
void deletePublisher() {
@@ -400,6 +403,21 @@ class CreatorHubScreen extends HookConsumerWidget {
);
},
),
ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
minTileHeight: 48,
title: Text('publicationSites').tr(),
trailing: const Icon(Symbols.chevron_right),
leading: const Icon(Symbols.web),
onTap: () {
context.pushNamed(
'creatorSites',
pathParameters: {'name': currentPublisher.value!.name},
);
},
),
ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
@@ -582,7 +600,7 @@ class CreatorHubScreen extends HookConsumerWidget {
).padding(horizontal: 12),
buildNavigationWidget(true),
],
)
).padding(vertical: 24)
: Column(
spacing: 12,
children: [
@@ -828,7 +846,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.post(
'/publishers/$publisherUname/invites',
'/sphere/publishers/invites/$publisherUname',
data: {'related_user_id': result.id, 'role': 0},
);
// Refresh both providers
@@ -959,7 +977,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
apiClientProvider,
);
await apiClient.delete(
'/publishers/$publisherUname/members/${member.accountId}',
'/sphere/publishers/$publisherUname/members/${member.accountId}',
);
// Refresh both providers
memberNotifier.reset();
@@ -1084,7 +1102,7 @@ class _PublisherMemberRoleSheet extends HookConsumerWidget {
final apiClient = ref.read(apiClientProvider);
await apiClient.patch(
'/publishers/$publisherUname/members/${member.accountId}/role',
'/sphere/publishers/$publisherUname/members/${member.accountId}/role',
data: newRole,
);
@@ -1116,7 +1134,7 @@ class _PublisherInviteSheet extends HookConsumerWidget {
try {
final client = ref.read(apiClientProvider);
await client.post(
'/publishers/invites/${invite.publisher!.name}/accept',
'/sphere/publishers/invites/${invite.publisher!.name}/accept',
);
ref.invalidate(publisherInvitesProvider);
ref.invalidate(publishersManagedProvider);
@@ -1129,7 +1147,7 @@ class _PublisherInviteSheet extends HookConsumerWidget {
try {
final client = ref.read(apiClientProvider);
await client.post(
'/publishers/invites/${invite.publisher!.name}/decline',
'/sphere/publishers/invites/${invite.publisher!.name}/decline',
);
ref.invalidate(publisherInvitesProvider);
} catch (err) {

View File

@@ -1,10 +1,11 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/poll/poll_feedback.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -73,10 +74,14 @@ class CreatorPollListScreen extends HookConsumerWidget {
final String pubName;
Future<void> _createPoll(BuildContext context) async {
final result = await GoRouter.of(
context,
).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
if (result is SnPollWithStats && context.mounted) {
final result = await showModalBottomSheet<SnPollWithStats>(
context: context,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
builder: (context) => PollEditorScreen(initialPublisher: pubName),
);
if (result != null && context.mounted) {
Navigator.of(context).maybePop(result);
}
}
@@ -176,11 +181,20 @@ class _CreatorPollItem extends HookConsumerWidget {
Text('edit').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'creatorPollEdit',
pathParameters: {'name': pubName, 'id': pollWithStats.id},
onTap: () async {
final result = await showModalBottomSheet<SnPoll>(
context: context,
isScrollControlled: true,
isDismissible: false,
builder:
(context) => PollEditorScreen(
initialPublisher: pubName,
initialPollId: pollWithStats.id,
),
);
if (result != null && context.mounted) {
ref.invalidate(pollListNotifierProvider(pubName));
}
},
),
PopupMenuItem(
@@ -221,19 +235,9 @@ class _CreatorPollItem extends HookConsumerWidget {
'/sphere/polls/${pollWithStats.id}',
);
ref.invalidate(pollListNotifierProvider(pubName));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Poll deleted successfully'),
),
);
}
showSnackBar('Poll deleted successfully');
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to delete poll')),
);
}
showErrorAlert(e);
}
}
},

View File

@@ -16,8 +16,8 @@ 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:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -95,11 +95,11 @@ class EditPublisherScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,
),
client: ref.read(apiClientProvider),
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
@@ -177,13 +177,11 @@ class EditPublisherScreen extends HookConsumerWidget {
}
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(name == null ? 'createPublisher' : 'editPublisher').tr(),
leading: const PageBackButton(),
),
body: SingleChildScrollView(
final titleText = (name == null ? 'createPublisher' : 'editPublisher').tr();
return SheetScaffold(
titleText: titleText,
child: SingleChildScrollView(
padding: EdgeInsets.only(bottom: 16),
child: Column(
children: [

View File

@@ -0,0 +1,233 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/site_pages.dart';
import 'package:island/widgets/sites/page_form.dart';
import 'package:island/widgets/sites/site_action_menu.dart';
import 'package:island/widgets/sites/site_detail_content.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/widgets/sites/info_row.dart';
import 'package:island/widgets/sites/pages_section.dart';
import 'package:island/widgets/sites/file_management_section.dart';
import 'package:island/widgets/sites/file_management_action_section.dart';
import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
part 'site_detail.g.dart';
@riverpod
Future<SnPublicationSite> publicationSiteDetail(
Ref ref,
String pubName,
String siteSlug,
) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/zone/sites/$pubName/$siteSlug');
return SnPublicationSite.fromJson(resp.data);
}
class PublicationSiteDetailScreen extends HookConsumerWidget {
final String siteSlug;
final String pubName;
const PublicationSiteDetailScreen({
super.key,
required this.siteSlug,
required this.pubName,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final siteAsync = ref.watch(
publicationSiteDetailProvider(pubName, siteSlug),
);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: siteAsync.maybeWhen(
data: (site) => Text(site.name),
orElse: () => Text('siteDetails'.tr()),
),
actions: [
siteAsync.maybeWhen(
data: (site) => SiteActionMenu(site: site, pubName: pubName),
orElse: () => const SizedBox.shrink(),
),
const Gap(8),
],
),
body: siteAsync.when(
data: (site) {
final theme = Theme.of(context);
if (isWideScreen(context)) {
return ExtendedRefreshIndicator(
onRefresh:
() async => ref.invalidate(
publicationSiteDetailProvider(pubName, site.slug),
),
child: Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PagesSection(site: site, pubName: pubName),
if (site.mode == 1) // Self-Managed only
FileManagementSection(site: site, pubName: pubName),
],
),
),
),
Expanded(
flex: 2,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'siteInformation'.tr(),
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const Gap(16),
InfoRow(
label: 'name'.tr(),
value: site.name,
icon: Symbols.title,
),
const Gap(8),
InfoRow(
label: 'slug'.tr(),
value: site.slug,
icon: Symbols.tag,
monospace: true,
),
const Gap(8),
InfoRow(
label: 'siteDomain'.tr(),
value: '${site.slug}.solian.page',
icon: Symbols.globe,
monospace: true,
onTap: () {
final url =
'https://${site.slug}.solian.page';
launchUrlString(url);
},
),
const Gap(8),
InfoRow(
label: 'siteMode'.tr(),
value:
site.mode == 0
? 'siteModeFullyManaged'.tr()
: 'siteModeSelfManaged'.tr(),
icon: Symbols.settings,
),
if (site.description != null &&
site.description!.isNotEmpty) ...[
const Gap(8),
InfoRow(
label: 'description'.tr(),
value: site.description!,
icon: Symbols.description,
),
],
const Gap(8),
InfoRow(
label: 'siteCreated'.tr(),
value: site.createdAt.formatSystem(),
icon: Symbols.calendar_add_on,
),
const Gap(8),
InfoRow(
label: 'siteUpdated'.tr(),
value: site.updatedAt.formatSystem(),
icon: Symbols.update,
),
],
),
),
),
const Gap(8),
if (site.mode == 1) // Self-Managed only
FileManagementActionSection(
site: site,
pubName: pubName,
),
],
),
),
),
],
).padding(horizontal: 12),
);
} else {
return SiteDetailContent(site: site, pubName: pubName);
}
},
error:
(error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'failedToLoadSite'.tr(),
style: Theme.of(context).textTheme.headlineSmall,
),
const Gap(16),
Text(error.toString()),
const Gap(24),
ElevatedButton(
onPressed:
() => ref.invalidate(
publicationSiteDetailProvider(pubName, siteSlug),
),
child: Text('retry'.tr()),
),
],
),
),
loading: () => const Center(child: CircularProgressIndicator()),
),
floatingActionButton: siteAsync.maybeWhen(
data:
(site) => FloatingActionButton(
onPressed: () {
// Create new page
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => PageForm(site: site, pubName: pubName),
).then((_) {
// Refresh pages after creation
ref.invalidate(sitePagesProvider(pubName, site.slug));
});
},
child: const Icon(Symbols.add),
),
orElse: () => null,
),
);
}
}

View File

@@ -0,0 +1,173 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'site_detail.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$publicationSiteDetailHash() =>
r'e5d259ea39c4ba47e92d37e644fc3d84984927a9';
/// 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 [publicationSiteDetail].
@ProviderFor(publicationSiteDetail)
const publicationSiteDetailProvider = PublicationSiteDetailFamily();
/// See also [publicationSiteDetail].
class PublicationSiteDetailFamily
extends Family<AsyncValue<SnPublicationSite>> {
/// See also [publicationSiteDetail].
const PublicationSiteDetailFamily();
/// See also [publicationSiteDetail].
PublicationSiteDetailProvider call(String pubName, String siteSlug) {
return PublicationSiteDetailProvider(pubName, siteSlug);
}
@override
PublicationSiteDetailProvider getProviderOverride(
covariant PublicationSiteDetailProvider provider,
) {
return call(provider.pubName, provider.siteSlug);
}
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'publicationSiteDetailProvider';
}
/// See also [publicationSiteDetail].
class PublicationSiteDetailProvider
extends AutoDisposeFutureProvider<SnPublicationSite> {
/// See also [publicationSiteDetail].
PublicationSiteDetailProvider(String pubName, String siteSlug)
: this._internal(
(ref) => publicationSiteDetail(
ref as PublicationSiteDetailRef,
pubName,
siteSlug,
),
from: publicationSiteDetailProvider,
name: r'publicationSiteDetailProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$publicationSiteDetailHash,
dependencies: PublicationSiteDetailFamily._dependencies,
allTransitiveDependencies:
PublicationSiteDetailFamily._allTransitiveDependencies,
pubName: pubName,
siteSlug: siteSlug,
);
PublicationSiteDetailProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.pubName,
required this.siteSlug,
}) : super.internal();
final String pubName;
final String siteSlug;
@override
Override overrideWith(
FutureOr<SnPublicationSite> Function(PublicationSiteDetailRef provider)
create,
) {
return ProviderOverride(
origin: this,
override: PublicationSiteDetailProvider._internal(
(ref) => create(ref as PublicationSiteDetailRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
pubName: pubName,
siteSlug: siteSlug,
),
);
}
@override
AutoDisposeFutureProviderElement<SnPublicationSite> createElement() {
return _PublicationSiteDetailProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is PublicationSiteDetailProvider &&
other.pubName == pubName &&
other.siteSlug == siteSlug;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, pubName.hashCode);
hash = _SystemHash.combine(hash, siteSlug.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin PublicationSiteDetailRef
on AutoDisposeFutureProviderRef<SnPublicationSite> {
/// The parameter `pubName` of this provider.
String get pubName;
/// The parameter `siteSlug` of this provider.
String get siteSlug;
}
class _PublicationSiteDetailProviderElement
extends AutoDisposeFutureProviderElement<SnPublicationSite>
with PublicationSiteDetailRef {
_PublicationSiteDetailProviderElement(super.provider);
@override
String get pubName => (origin as PublicationSiteDetailProvider).pubName;
@override
String get siteSlug => (origin as PublicationSiteDetailProvider).siteSlug;
}
// 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,384 @@
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/pods/network.dart';
import 'package:island/screens/creators/sites/site_detail.dart';
import 'package:island/screens/creators/sites/site_list.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class SiteForm extends HookConsumerWidget {
final String pubName;
final String? siteSlug;
const SiteForm({super.key, required this.pubName, this.siteSlug});
Widget _buildForm(
GlobalKey<FormState> formKey,
TextEditingController slugController,
TextEditingController nameController,
TextEditingController descriptionController,
ValueNotifier<int> modeController,
Function() saveSite,
Function() deleteSite,
String siteSlug,
) {
final formFields = Column(
children: [
TextFormField(
controller: slugController,
decoration: InputDecoration(
labelText: 'siteSlug'.tr(),
hintText: 'siteSlugHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'siteSlugRequired'.tr();
}
final slugRegex = RegExp(r'^[a-z0-9]+(?:-[a-z0-9]+)*$');
if (!slugRegex.hasMatch(value)) {
return 'siteSlugInvalid'.tr();
}
return null;
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: nameController,
decoration: InputDecoration(
labelText: 'siteName'.tr(),
hintText: 'siteNameHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'siteNameRequired'.tr();
}
return null;
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: InputDecoration(
labelText: 'description'.tr(),
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
maxLines: 3,
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
value: modeController.value,
decoration: InputDecoration(
labelText: 'siteMode'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
items: [
DropdownMenuItem(
value: 0,
child: Text('siteModeFullyManaged'.tr()),
),
DropdownMenuItem(value: 1, child: Text('siteModeSelfManaged'.tr())),
],
onChanged: (value) {
if (value != null) {
modeController.value = value;
}
},
),
],
).padding(all: 20);
return SheetScaffold(
titleText: 'editPublicationSite'.tr(),
child: Builder(
builder:
(context) => SingleChildScrollView(
child: Column(
children: [
Form(key: formKey, child: formFields),
Row(
children: [
TextButton.icon(
onPressed: deleteSite,
icon: const Icon(Symbols.delete_forever),
label: Text('deletePublicationSite'.tr()),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
).alignment(Alignment.centerRight),
const Spacer(),
TextButton.icon(
onPressed: saveSite,
icon: const Icon(Symbols.save),
label: Text('saveChanges').tr(),
),
],
).padding(horizontal: 20, vertical: 12),
],
),
),
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final slugController = useTextEditingController();
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final modeController = useState<int>(0); // Default to fully managed (0)
final isLoading = useState(false);
final saveSite = useCallback(() async {
if (!formKey.currentState!.validate()) return;
isLoading.value = true;
try {
final client = ref.read(apiClientProvider);
final url = '/zone/sites/$pubName';
final payload = <String, dynamic>{
'slug': slugController.text,
'name': nameController.text,
'mode': modeController.value,
if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
};
if (siteSlug != null) {
await client.patch('$url/$siteSlug', data: payload);
} else {
await client.post(url, data: payload);
}
// Refresh the site list
ref.invalidate(siteListNotifierProvider(pubName));
if (context.mounted) {
showSnackBar('publicationSiteSavedSuccess'.tr());
Navigator.pop(context);
}
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}, [pubName, siteSlug, context]);
final deleteSite = useCallback(() async {
if (siteSlug == null) return; // Shouldn't happen for editing
final confirmed = await showConfirmAlert(
'publicationSiteDeleteConfirm'.tr(),
'deletePublicationSite'.tr(),
);
if (confirmed != true) return;
isLoading.value = true;
try {
final client = ref.read(apiClientProvider);
await client.delete('/zone/sites/$pubName/$siteSlug');
ref.invalidate(siteListNotifierProvider(pubName));
if (context.mounted) {
showSnackBar('publicationSiteDeletedSuccess'.tr());
Navigator.pop(context);
}
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}, [pubName, siteSlug, context]);
// Use Riverpod provider for loading and error states for editing
if (siteSlug != null) {
final editingSiteSlug =
siteSlug!; // Assert non-null since we checked above
final siteAsync = ref.watch(
publicationSiteDetailProvider(pubName, editingSiteSlug),
);
// Initialize form fields when site data is loaded
useEffect(() {
if (siteAsync.value != null && nameController.text.isEmpty) {
final site = siteAsync.value!;
slugController.text = site.slug;
nameController.text = site.name;
descriptionController.text = site.description ?? '';
modeController.value = site.mode ?? 0;
}
return null;
}, [siteAsync]);
// Handle loading and error states for editing using AsyncValue
return siteAsync.when(
data:
(_) => _buildForm(
formKey,
slugController,
nameController,
descriptionController,
modeController,
saveSite,
deleteSite,
editingSiteSlug,
),
loading:
() => SheetScaffold(
titleText: 'editPublicationSite'.tr(),
child: Center(child: CircularProgressIndicator()),
),
error:
(error, _) => SheetScaffold(
titleText: 'editPublicationSite'.tr(),
child: ResponseErrorWidget(
error: error.toString(),
onRetry: () {
ref.invalidate(
publicationSiteDetailProvider(pubName, editingSiteSlug),
);
},
),
),
);
}
// For new sites, directly show the form
final formFields = Column(
children: [
TextFormField(
controller: slugController,
decoration: const InputDecoration(
labelText: 'Slug',
hintText: 'my-site',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a slug';
}
final slugRegex = RegExp(r'^[a-z0-9]+(?:-[a-z0-9]+)*$');
if (!slugRegex.hasMatch(value)) {
return 'Slug can only contain lowercase letters, numbers, and dashes';
}
return null;
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Site Name',
hintText: 'My Publication Site',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a site name';
}
return null;
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
maxLines: 3,
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
value: modeController.value,
decoration: const InputDecoration(
labelText: 'Mode',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
items: [
DropdownMenuItem(
value: 0,
child: Text('siteModeFullyManaged'.tr()),
),
DropdownMenuItem(value: 1, child: Text('siteModeSelfManaged'.tr())),
],
onChanged: (value) {
if (value != null) {
modeController.value = value;
}
},
),
],
).padding(all: 20);
final saveButton = TextButton.icon(
onPressed: isLoading.value ? null : saveSite,
icon: const Icon(Symbols.save),
label: Text('saveChanges').tr(),
).padding(horizontal: 20, vertical: 12);
return SheetScaffold(
titleText:
siteSlug == null
? 'newPublicationSite'.tr()
: 'editPublicationSite'.tr(),
child: SingleChildScrollView(
child: Column(
children: [
Form(key: formKey, child: formFields),
Row(
children: [
if (siteSlug != null) ...[
TextButton.icon(
onPressed: isLoading.value ? null : deleteSite,
icon: const Icon(Symbols.delete_forever),
label: Text('deletePublicationSite'.tr()),
style: TextButton.styleFrom(foregroundColor: Colors.red),
).alignment(Alignment.centerRight),
const SizedBox(height: 16),
],
const Spacer(),
saveButton,
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,241 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/creators/sites/site_edit.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:styled_widget/styled_widget.dart';
part 'site_list.g.dart';
@riverpod
class SiteListNotifier extends _$SiteListNotifier
with CursorPagingNotifierMixin<SnPublicationSite> {
static const int _pageSize = 20;
@override
Future<CursorPagingData<SnPublicationSite>> build(String? pubName) {
// immediately load first page
return fetch(cursor: null);
}
@override
Future<CursorPagingData<SnPublicationSite>> fetch({
required String? cursor,
}) async {
final client = ref.read(apiClientProvider);
final offset = cursor == null ? 0 : int.parse(cursor);
// read the current family argument passed to provider
final queryParams = {'offset': offset, 'take': _pageSize};
final response = await client.get(
'/zone/sites/$pubName',
queryParameters: queryParams,
);
final total = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data;
final items = data.map((json) => SnPublicationSite.fromJson(json)).toList();
final hasMore = offset + items.length < total;
final nextCursor = hasMore ? (offset + items.length).toString() : null;
return CursorPagingData(
items: items,
hasMore: hasMore,
nextCursor: nextCursor,
);
}
}
class CreatorSiteListScreen extends HookConsumerWidget {
const CreatorSiteListScreen({super.key, required this.pubName});
final String pubName;
Future<void> _createSite(BuildContext context) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SiteForm(pubName: pubName),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: Text('publicationSites'.tr())),
floatingActionButton: FloatingActionButton(
onPressed: () => _createSite(context),
child: Icon(Icons.add),
),
body: ExtendedRefreshIndicator(
onRefresh: () => ref.refresh(siteListNotifierProvider(pubName).future),
child: CustomScrollView(
slivers: [
const SliverGap(8),
PagingHelperSliverView(
provider: siteListNotifierProvider(pubName),
futureRefreshable: siteListNotifierProvider(pubName).future,
notifierRefreshable: siteListNotifierProvider(pubName).notifier,
contentBuilder:
(data, widgetCount, endItemView) => SliverList.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final site = data.items[index];
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: _CreatorSiteItem(site: site, pubName: pubName),
).center();
},
),
),
],
),
),
);
}
}
class _CreatorSiteItem extends HookConsumerWidget {
final String pubName;
const _CreatorSiteItem({required this.site, required this.pubName});
final SnPublicationSite site;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
// Navigate to site detail screen
context.pushNamed(
'creatorSiteDetail',
pathParameters: {'name': pubName, 'siteSlug': site.slug},
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
spacing: 2,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.globe,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const Gap(6),
Text(site.name).bold(),
],
),
if (site.description != null &&
site.description!.isNotEmpty)
Text(
site.description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Divider(height: 8),
Text(
'${site.slug}.solian.page',
style: GoogleFonts.robotoMono(fontSize: 11),
).opacity(0.8),
],
),
),
PopupMenuButton<String>(
itemBuilder:
(context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SiteForm(
pubName: pubName,
siteSlug: site.slug,
),
);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('delete').tr().textColor(Colors.red),
],
),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: Text('deleteSite'.tr()),
content: Text('deleteSiteConfirm'.tr()),
actions: [
TextButton(
onPressed:
() =>
Navigator.of(context).pop(false),
child: Text('cancel'.tr()),
),
TextButton(
onPressed:
() => Navigator.of(context).pop(true),
child: Text('delete'.tr()),
),
],
),
);
if (confirmed == true) {
try {
final client = ref.read(apiClientProvider);
await client.delete('/zone/sites/${site.id}');
ref.invalidate(siteListNotifierProvider(pubName));
showSnackBar('siteDeletedSuccess'.tr());
} catch (e) {
showErrorAlert(e);
}
}
},
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,183 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'site_list.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$siteListNotifierHash() => r'1670cadcc0c7ccbd98bc33bbf5b4af21e9cb166c';
/// 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 _$SiteListNotifier
extends
BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnPublicationSite>> {
late final String? pubName;
FutureOr<CursorPagingData<SnPublicationSite>> build(String? pubName);
}
/// See also [SiteListNotifier].
@ProviderFor(SiteListNotifier)
const siteListNotifierProvider = SiteListNotifierFamily();
/// See also [SiteListNotifier].
class SiteListNotifierFamily
extends Family<AsyncValue<CursorPagingData<SnPublicationSite>>> {
/// See also [SiteListNotifier].
const SiteListNotifierFamily();
/// See also [SiteListNotifier].
SiteListNotifierProvider call(String? pubName) {
return SiteListNotifierProvider(pubName);
}
@override
SiteListNotifierProvider getProviderOverride(
covariant SiteListNotifierProvider 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'siteListNotifierProvider';
}
/// See also [SiteListNotifier].
class SiteListNotifierProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
SiteListNotifier,
CursorPagingData<SnPublicationSite>
> {
/// See also [SiteListNotifier].
SiteListNotifierProvider(String? pubName)
: this._internal(
() => SiteListNotifier()..pubName = pubName,
from: siteListNotifierProvider,
name: r'siteListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$siteListNotifierHash,
dependencies: SiteListNotifierFamily._dependencies,
allTransitiveDependencies:
SiteListNotifierFamily._allTransitiveDependencies,
pubName: pubName,
);
SiteListNotifierProvider._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
FutureOr<CursorPagingData<SnPublicationSite>> runNotifierBuild(
covariant SiteListNotifier notifier,
) {
return notifier.build(pubName);
}
@override
Override overrideWith(SiteListNotifier Function() create) {
return ProviderOverride(
origin: this,
override: SiteListNotifierProvider._internal(
() => create()..pubName = pubName,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
pubName: pubName,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<
SiteListNotifier,
CursorPagingData<SnPublicationSite>
>
createElement() {
return _SiteListNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SiteListNotifierProvider && 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 SiteListNotifierRef
on
AutoDisposeAsyncNotifierProviderRef<
CursorPagingData<SnPublicationSite>
> {
/// The parameter `pubName` of this provider.
String? get pubName;
}
class _SiteListNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
SiteListNotifier,
CursorPagingData<SnPublicationSite>
>
with SiteListNotifierRef {
_SiteListNotifierProviderElement(super.provider);
@override
String? get pubName => (origin as SiteListNotifierProvider).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

@@ -141,7 +141,7 @@ class EditAppScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,

View File

@@ -127,11 +127,11 @@ class EditBotScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,
),
client: ref.read(apiClientProvider),
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');

View File

@@ -11,15 +11,16 @@ import 'package:island/models/realm.dart';
import 'package:island/models/webfeed.dart';
import 'package:island/pods/event_calendar.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/auth/login_modal.dart';
import 'package:island/screens/notification.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/friends_overview.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/models/post.dart';
import 'package:island/widgets/check_in.dart';
import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:island/widgets/post/post_featured.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/compose_card.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -341,7 +342,7 @@ class ExploreScreen extends HookConsumerWidget {
margin: EdgeInsets.zero,
),
PostFeaturedList(),
const PostComposeCard(),
FriendsOverviewWidget(),
],
),
),
@@ -350,21 +351,39 @@ class ExploreScreen extends HookConsumerWidget {
else
Flexible(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome to\nthe Solar Network',
style: Theme.of(context).textTheme.titleLarge,
).bold(),
const Gap(2),
Text(
'Login to explore more!',
style: Theme.of(context).textTheme.bodyLarge,
),
],
).padding(horizontal: 36, vertical: 16),
child:
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.emoji_people_rounded, size: 40),
const Gap(8),
Text(
'Welcome to\nthe Solar Network',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
).bold(),
const Gap(2),
Text(
'Login to explore more!',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const Gap(4),
TextButton.icon(
onPressed: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => LoginModal(),
);
},
icon: const Icon(Symbols.login),
label: Text('login').tr(),
),
],
).padding(horizontal: 36, vertical: 16).center(),
),
],
).padding(horizontal: 12);
@@ -523,6 +542,12 @@ class ExploreScreen extends HookConsumerWidget {
child: PostFeaturedList(),
),
),
SliverToBoxAdapter(
child: FriendsOverviewWidget(
padding: const EdgeInsets.only(bottom: 8),
hideWhenEmpty: true,
),
),
if (notificationCount.value != null &&
notificationCount.value! > 0)
SliverToBoxAdapter(

View File

@@ -0,0 +1,342 @@
import 'dart:io';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gal/gal.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/file_references.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/upload_tasks.dart';
import 'package:island/models/drive_task.dart';
import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:island/widgets/content/file_viewer_contents.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:path/path.dart' show extension;
import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart';
class FileDetailScreen extends HookConsumerWidget {
final SnCloudFile item;
const FileDetailScreen({super.key, required this.item});
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider);
final isWide = isWideScreen(context);
// Animation controller for the drawer
final animationController = useAnimationController(
duration: const Duration(milliseconds: 300),
);
final animation = useMemoized(
() => Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
),
[animationController],
);
final showDrawer = useState(false);
void showInfoSheet() {
if (isWide) {
// Show as animated right panel on wide screens
showDrawer.value = !showDrawer.value;
if (showDrawer.value) {
animationController.forward();
} else {
animationController.reverse();
}
} else {
// Show as bottom sheet on narrow screens
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
}
}
// Listen to drawer state changes
useEffect(() {
void listener() {
if (!animationController.isAnimating) {
if (animationController.value == 0) {
showDrawer.value = false;
}
}
}
animationController.addListener(listener);
return () => animationController.removeListener(listener);
}, [animationController]);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
elevation: 0,
leading: IconButton(
icon: Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(item.name.isEmpty ? 'File Details' : item.name),
actions: _buildAppBarActions(context, ref, showInfoSheet),
),
body: LayoutBuilder(
builder: (context, constraints) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Stack(
children: [
// Main content area - resizes with animation
Positioned(
left: 0,
top: 0,
bottom: 0,
width: constraints.maxWidth - animation.value * 400,
child: _buildContent(context, ref, serverUrl),
),
// Animated drawer panel - overlays
if (isWide)
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 400,
child: Transform.translate(
offset: Offset((1 - animation.value) * 400, 0),
child: SizedBox(
width: 400,
child: Material(
color:
Theme.of(context).colorScheme.surfaceContainer,
elevation: 8,
child: FileInfoSheet(
item: item,
onClose: showInfoSheet,
),
),
),
),
),
],
);
},
);
},
),
);
}
List<Widget> _buildAppBarActions(
BuildContext context,
WidgetRef ref,
VoidCallback showInfoSheet,
) {
final actions = <Widget>[];
// Add content-specific actions
switch (item.mimeType?.split('/').firstOrNull) {
case 'image':
if (!kIsWeb) {
actions.add(
IconButton(
icon: Icon(Icons.save_alt),
onPressed: () async => _saveToGallery(ref),
),
);
}
// HD/SD toggle will be handled in the image content overlay
break;
default:
if (!kIsWeb) {
actions.add(
IconButton(
icon: Icon(Icons.save_alt),
onPressed: () async => _downloadFile(ref),
),
);
}
break;
}
// Add references button
actions.add(
IconButton(
icon: Icon(Icons.link),
onPressed:
() => showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'File References',
child: ReferencesList(fileId: item.id),
),
),
),
);
// Always add info button
actions.add(
IconButton(icon: Icon(Icons.info_outline), onPressed: showInfoSheet),
);
actions.add(const Gap(8));
return actions;
}
Future<void> _saveToGallery(WidgetRef ref) async {
try {
showSnackBar('Saving image...');
final client = ref.read(apiClientProvider);
final tempDir = await getTemporaryDirectory();
var extName = extension(item.name).trim();
if (extName.isEmpty) {
extName = item.mimeType?.split('/').lastOrNull ?? 'jpeg';
}
final filePath = '${tempDir.path}/${item.id}.$extName';
await client.download(
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
);
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
await Gal.putImage(filePath, album: 'Solar Network');
showSnackBar('Image saved to gallery');
} else {
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
showSnackBar('Image saved to $filePath');
}
} catch (e) {
showErrorAlert(e);
}
}
Future<void> _downloadFile(WidgetRef ref) async {
final taskNotifier = ref.read(uploadTasksProvider.notifier);
final taskId = taskNotifier.addLocalDownloadTask(item);
try {
showSnackBar('Downloading file...');
final client = ref.read(apiClientProvider);
final tempDir = await getTemporaryDirectory();
var extName = extension(item.name).trim();
if (extName.isEmpty) {
extName = item.mimeType?.split('/').lastOrNull ?? 'bin';
}
final filePath = '${tempDir.path}/${item.id}.$extName';
await client.download(
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
onReceiveProgress: (count, total) {
if (total > 0) {
taskNotifier.updateDownloadProgress(taskId, count, total);
taskNotifier.updateTransmissionProgress(taskId, count / total);
}
},
);
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
taskNotifier.updateTaskStatus(taskId, DriveTaskStatus.completed);
showSnackBar('File saved to downloads');
} catch (e) {
taskNotifier.updateTaskStatus(
taskId,
DriveTaskStatus.failed,
errorMessage: e.toString(),
);
showErrorAlert(e);
}
}
Widget _buildContent(BuildContext context, WidgetRef ref, String serverUrl) {
final uri = '$serverUrl/drive/files/${item.id}';
return switch (item.mimeType?.split('/').firstOrNull) {
'image' => ImageFileContent(item: item, uri: uri),
'video' => VideoFileContent(item: item, uri: uri),
'audio' => AudioFileContent(item: item, uri: uri),
_ when item.mimeType == 'application/pdf' => PdfFileContent(uri: uri),
_ when item.mimeType?.startsWith('text/') == true => TextFileContent(
uri: uri,
),
_ => GenericFileContent(item: item),
};
}
}
class ReferencesList extends ConsumerWidget {
const ReferencesList({super.key, required this.fileId});
final String fileId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncReferences = ref.watch(fileReferencesProvider(fileId));
return asyncReferences.when(
data:
(references) => ListView.builder(
itemCount: references.length,
itemBuilder: (context, index) {
final reference = references[index];
return ListTile(
leading: const Icon(Icons.link),
title: Row(
spacing: 6,
children: [
Text(
reference.usage,
style: GoogleFonts.robotoMono(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
Text(
reference.id,
style: GoogleFonts.robotoMono(fontSize: 13),
),
],
),
subtitle: Row(
spacing: 8,
children: [
Text(reference.createdAt.formatRelative(context)),
const VerticalDivider(width: 1, thickness: 1).height(12),
Text(reference.createdAt.formatSystem()),
],
),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => Center(child: Text('Error loading references: $error')),
);
}
}

View File

@@ -1,122 +1,73 @@
import 'package:cross_file/cross_file.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/file_pool.dart';
import 'package:island/utils/format.dart';
import 'package:island/models/file_pool.dart';
import 'package:island/pods/file_list.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:island/widgets/content/file_info_sheet.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/file_list_view.dart';
import 'package:island/widgets/usage_overview.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
part 'file_list.g.dart';
@riverpod
class CloudFileListNotifier extends _$CloudFileListNotifier
with CursorPagingNotifierMixin<SnCloudFile> {
String? _poolId;
bool _includeRecycled = false;
void setFilters(String? poolId, bool includeRecycled) {
_poolId = poolId;
_includeRecycled = includeRecycled;
ref.invalidateSelf();
}
@override
Future<CursorPagingData<SnCloudFile>> build() => fetch(cursor: null);
@override
Future<CursorPagingData<SnCloudFile>> fetch({required String? cursor}) async {
final client = ref.read(apiClientProvider);
final offset = cursor == null ? 0 : int.parse(cursor);
final take = 20;
final queryParameters = <String, dynamic>{'offset': offset, 'take': take};
// Add filter parameters
if (_poolId != null) {
queryParameters['pool'] = _poolId!;
}
if (_includeRecycled) {
queryParameters['recycled'] = 'true';
}
final response = await client.get(
'/drive/files/me',
queryParameters: queryParameters,
);
final List<SnCloudFile> items =
(response.data as List)
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList();
final total = int.parse(response.headers.value('X-Total') ?? '0');
final hasMore = offset + items.length < total;
final nextCursor = hasMore ? (offset + items.length).toString() : null;
return CursorPagingData(
items: items,
hasMore: hasMore,
nextCursor: nextCursor,
);
}
}
@riverpod
Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/drive/billing/usage');
return response.data;
}
@riverpod
Future<Map<String, dynamic>?> billingQuota(Ref ref) async {
final client = ref.read(apiClientProvider);
final response = await client.get('/drive/billing/quota');
return response.data;
}
class FileListScreen extends HookConsumerWidget {
const FileListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Filter state
final selectedPool = useState<String?>(null);
final includeRecycled = useState(false);
// Path navigation state
final currentPath = useState<String>('/');
final mode = useState<FileListMode>(FileListMode.normal);
final selectedPool = useState<SnFilePool?>(null);
final usageAsync = ref.watch(billingUsageProvider);
final quotaAsync = ref.watch(billingQuotaProvider);
// Update notifier filters when state changes
useEffect(() {
final notifier = ref.read(cloudFileListNotifierProvider.notifier);
notifier.setFilters(selectedPool.value, includeRecycled.value);
return null;
}, [selectedPool.value, includeRecycled.value]);
final viewMode = useState(FileListViewMode.list);
return AppScaffold(
appBar: AppBar(title: Text('Files'), leading: const PageBackButton()),
isNoBackground: false,
appBar: AppBar(
title: Text('files').tr(),
leading: const PageBackButton(backTo: '/account'),
actions: [
IconButton(
icon: const Icon(Symbols.bar_chart),
onPressed:
() => _showUsageSheet(
context,
usageAsync.value,
quotaAsync.value,
),
),
const Gap(8),
],
),
body: usageAsync.when(
data:
(usage) => quotaAsync.when(
data:
(quota) => _buildQuotaUI(
usage,
quota,
ref,
selectedPool,
includeRecycled,
(quota) => FileListView(
usage: usage,
quota: quota,
currentPath: currentPath,
selectedPool: selectedPool,
onPickAndUpload:
() => _pickAndUploadFile(
ref,
currentPath.value,
selectedPool.value?.id,
),
onShowCreateDirectory: _showCreateDirectoryDialog,
mode: mode,
viewMode: viewMode,
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading quota')),
@@ -127,430 +78,143 @@ class FileListScreen extends HookConsumerWidget {
);
}
Widget _buildQuotaUI(
Map<String, dynamic>? usage,
Map<String, dynamic>? quota,
Future<void> _pickAndUploadFile(
WidgetRef ref,
ValueNotifier<String?> selectedPool,
ValueNotifier<bool> includeRecycled,
) {
if (usage == null) return const SizedBox.shrink();
return CustomScrollView(
slivers: [
const SliverGap(8),
SliverToBoxAdapter(
child: Column(
children: [
Row(
children: [
Expanded(
child: _buildStatCard(
'All Uploads',
'${((usage['total_usage_bytes'] as num) / (1024 * 1024 * 1024)).toStringAsFixed(3)} GiB',
),
),
Expanded(
child: _buildStatCard(
'All Files',
'${usage['total_file_count']}',
),
),
],
),
Row(
children: [
Expanded(
child: _buildStatCard(
'Quota',
'${usage['total_quota']} MiB',
),
),
Expanded(
child: _buildStatCard(
'Used Quota',
'${((usage['used_quota'] as num) / (usage['total_quota'] as num) * 100).toStringAsFixed(2)}%',
progress:
(usage['used_quota'] as num) /
(usage['total_quota'] as num),
),
),
],
),
],
).padding(horizontal: 8),
),
SliverToBoxAdapter(
child: Row(
children: [
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text('Pool Usage'),
SizedBox(
height: 200,
child: PieChart(_buildPoolChartData(usage)),
),
],
),
),
),
),
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text('Verbose Quota'),
SizedBox(
height: 200,
child: PieChart(_buildQuotaChartData(quota)),
),
],
),
),
),
),
],
).padding(horizontal: 8),
),
const SliverGap(8),
SliverToBoxAdapter(
child: _buildFilters(ref, selectedPool, includeRecycled),
),
const SliverGap(8),
PagingHelperSliverView(
provider: cloudFileListNotifierProvider,
futureRefreshable: cloudFileListNotifierProvider.future,
notifierRefreshable: cloudFileListNotifierProvider.notifier,
contentBuilder:
(data, widgetCount, endItemView) => SliverList.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final item = data.items[index];
final itemType = item.mimeType?.split('/').firstOrNull;
return ListTile(
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: SizedBox(
height: 48,
width: 48,
child: switch (itemType) {
'image' => CloudImageWidget(file: item),
'audio' =>
const Icon(Symbols.audio_file, fill: 1).center(),
'video' =>
const Icon(Symbols.video_file, fill: 1).center(),
_ =>
const Icon(Symbols.body_system, fill: 1).center(),
},
),
),
title:
item.name.isEmpty
? Text('untitled').tr().italic()
: Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(formatFileSize(item.size)),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
},
trailing: IconButton(
icon: const Icon(Symbols.delete),
onPressed: () async {
final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(),
'deleteFile'.tr(),
);
if (!confirmed) return;
if (context.mounted) showLoadingModal(context);
try {
final client = ref.read(apiClientProvider);
await client.delete('/drive/files/${item.id}');
ref.invalidate(cloudFileListNotifierProvider);
} catch (e) {
showSnackBar('failedToDeleteFile'.tr());
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
),
);
},
),
),
],
);
}
PieChartData _buildPoolChartData(Map<String, dynamic> usage) {
final pools = usage['pool_usages'] as List<dynamic>;
final colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.red,
Colors.purple,
];
return PieChartData(
sections:
pools.asMap().entries.map((entry) {
final pool = entry.value as Map<String, dynamic>;
final title = pool['pool_name'] as String;
final truncatedTitle =
title.length > 8 ? '${title.substring(0, 8)}...' : title;
return PieChartSectionData(
value: (pool['usage_bytes'] as num).toDouble(),
title: truncatedTitle,
color: colors[entry.key % colors.length],
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}).toList(),
);
}
PieChartData _buildQuotaChartData(Map<String, dynamic>? quota) {
if (quota == null) return PieChartData(sections: []);
return PieChartData(
sections: [
PieChartSectionData(
value: (quota['based_quota'] as num).toDouble(),
title: 'Base',
color: Colors.green,
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
PieChartSectionData(
value: (quota['extra_quota'] as num).toDouble(),
title: 'Extra',
color: Colors.orange,
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
);
}
Widget _buildFilters(
WidgetRef ref,
ValueNotifier<String?> selectedPool,
ValueNotifier<bool> includeRecycled,
) {
final poolsAsync = ref.watch(poolsProvider);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'filters'.tr(),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const Gap(16),
LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 600;
return isWide
? Row(
children: [
Expanded(
flex: 2,
child: poolsAsync.when(
data:
(pools) => DropdownButtonFormField<String?>(
value: selectedPool.value,
decoration: InputDecoration(
labelText: 'Pool',
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text('allPools'.tr()),
),
...pools.map(
(pool) => DropdownMenuItem<String?>(
value: pool.id,
child: Text(pool.name),
),
),
],
onChanged:
(value) => selectedPool.value = value,
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => const Text('Error loading pools'),
),
),
const Gap(8),
Expanded(
child: Row(
children: [
Text('includeRecycled'.tr()),
const Gap(8),
Switch(
value: includeRecycled.value,
onChanged:
(value) => includeRecycled.value = value,
padding: EdgeInsets.zero,
),
],
),
),
const Gap(16),
IconButton(
icon: const Icon(Symbols.delete_sweep),
tooltip: 'deleteRecycledFiles'.tr(),
onPressed:
includeRecycled.value
? () => _deleteRecycledFiles(ref)
: null,
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
poolsAsync.when(
data:
(pools) => DropdownButtonFormField<String?>(
value: selectedPool.value,
decoration: const InputDecoration(
labelText: 'Pool',
border: OutlineInputBorder(),
),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text('allPools'.tr()),
),
...pools.map(
(pool) => DropdownMenuItem<String?>(
value: pool.id,
child: Text(pool.name),
),
),
],
onChanged:
(value) => selectedPool.value = value,
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => const Text('Error loading pools'),
),
const Gap(16),
Row(
children: [
Text('includeRecycled'.tr()),
const Gap(8),
Switch(
value: includeRecycled.value,
onChanged:
(value) => includeRecycled.value = value,
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.delete_sweep),
tooltip: 'deleteRecycledFiles'.tr(),
onPressed:
includeRecycled.value
? () => _deleteRecycledFiles(ref)
: null,
),
],
),
],
);
},
),
],
),
),
).padding(horizontal: 8);
}
Future<void> _deleteRecycledFiles(WidgetRef ref) async {
final confirmed = await showConfirmAlert(
'confirmDeleteRecycledFiles'.tr(),
'deleteRecycledFiles'.tr(),
);
if (!confirmed) return;
if (ref.context.mounted) showLoadingModal(ref.context);
String currentPath,
String? poolId,
) async {
try {
final client = ref.read(apiClientProvider);
await client.delete('/drive/files/recycled');
ref.invalidate(cloudFileListNotifierProvider);
showSnackBar('recycledFilesDeleted'.tr());
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
withData: false,
);
if (result != null && result.files.isNotEmpty) {
for (final file in result.files) {
if (file.path != null) {
// Create UniversalFile from the picked file
final universalFile = UniversalFile(
data: XFile(file.path!),
type: UniversalFileType.file,
displayName: file.name,
);
// Upload the file with the current path
final completer = FileUploader.createCloudFile(
fileData: universalFile,
ref: ref,
path: currentPath,
poolId: poolId,
onProgress: (progress, _) {
// Progress is handled by the upload tasks system
if (progress != null) {
debugPrint('Upload progress: ${(progress * 100).toInt()}%');
}
},
);
completer.future
.then((uploadedFile) {
if (uploadedFile != null) {
ref.invalidate(cloudFileListNotifierProvider);
}
})
.catchError((error) {
showSnackBar('Failed to upload file: $error');
});
}
}
}
} catch (e) {
showSnackBar('failedToDeleteRecycledFiles'.tr());
} finally {
if (ref.context.mounted) hideLoadingModal(ref.context);
showSnackBar('Error picking file: $e');
}
}
Widget _buildStatCard(String label, String value, {double? progress}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: const TextStyle(fontSize: 14)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Future<void> _showCreateDirectoryDialog(
BuildContext context,
ValueNotifier<String> currentPath,
) async {
final controller = TextEditingController(text: currentPath.value);
String? newPath;
void handleChangeDirectory(BuildContext context) {
newPath = controller.text.trim();
if (newPath!.isNotEmpty) {
// Normalize the path
String fullPath = newPath!;
// Ensure it starts with /
if (!fullPath.startsWith('/')) {
fullPath = '/$fullPath';
}
// Remove double slashes and normalize
fullPath = fullPath.replaceAll(RegExp(r'/+'), '/');
currentPath.value = fullPath;
Navigator.of(context).pop();
}
}
await showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Navigate to Directory'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
const Gap(8),
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Directory path',
hintText: 'e.g., documents, projects/my-app',
helperText:
'Enter a directory path. The directory will be created when you upload files to it.',
helperMaxLines: 3,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onSubmitted: (_) {
handleChangeDirectory(context);
},
),
if (progress != null) ...[
const SizedBox(height: 8),
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(value: progress),
),
],
],
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton.icon(
onPressed: () => handleChangeDirectory(context),
label: const Text('Go to Directory'),
icon: const Icon(Symbols.arrow_right_alt),
),
],
),
);
}
void _showUsageSheet(
BuildContext context,
Map<String, dynamic>? usage,
Map<String, dynamic>? quota,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'Usage Overview',
child: UsageOverviewWidget(
usage: usage,
quota: quota,
).padding(horizontal: 8, vertical: 16),
),
);
}
}

View File

@@ -534,7 +534,7 @@ class _LotteryPurchaseSheetState extends State<LotteryPurchaseSheet> {
),
const Gap(4),
Text(
'The last selected number will be your special number.',
'lotteryLastNumberSpecial'.tr(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.w500,
@@ -738,11 +738,11 @@ class _LotteryPurchaseSheetState extends State<LotteryPurchaseSheet> {
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a multiplier';
return 'lotteryMultiplierRequired'.tr();
}
final parsed = int.tryParse(value);
if (parsed == null || parsed < 1 || parsed > 10) {
return 'Multiplier must be between 1 and 10';
return 'lotteryMultiplierRange'.tr();
}
return null;
},

View File

@@ -10,6 +10,7 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/route.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
@@ -68,6 +69,16 @@ class NotificationUnreadCountNotifier
void clear() async {
state = AsyncData(0);
}
Future<void> refresh() async {
try {
final client = ref.read(apiClientProvider);
final response = await client.get('/ring/notifications/count');
state = AsyncData((response.data as num).toInt());
} catch (_) {
// Keep the current state if refresh fails
}
}
}
@riverpod
@@ -115,8 +126,36 @@ class NotificationListNotifier extends _$NotificationListNotifier
class NotificationSheet extends HookConsumerWidget {
const NotificationSheet({super.key});
IconData _getNotificationIcon(String topic) {
switch (topic) {
case 'post.replies':
return Symbols.reply;
case 'wallet.transactions':
return Symbols.account_balance_wallet;
case 'relationships.friends.request':
return Symbols.person_add;
case 'invites.chat':
return Symbols.chat;
case 'invites.realm':
return Symbols.domain;
case 'auth.login':
return Symbols.login;
case 'posts.new':
return Symbols.post_add;
case 'wallet.orders.paid':
return Symbols.shopping_bag;
case 'posts.reactions.new':
return Symbols.add_reaction;
default:
return Symbols.notifications;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
// Refresh unread count when sheet opens to sync across devices
ref.read(notificationUnreadCountNotifierProvider.notifier).refresh();
Future<void> markAllRead() async {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
@@ -149,12 +188,30 @@ class NotificationSheet extends HookConsumerWidget {
}
final notification = data.items[index];
final pfp = notification.meta['pfp'] as String?;
final images = notification.meta['images'] as List?;
final imageIds = images?.cast<String>() ?? [];
return ListTile(
isThreeLine: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading:
pfp != null
? ProfilePictureWidget(fileId: pfp, radius: 20)
: CircleAvatar(
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
child: Icon(
_getNotificationIcon(notification.topic),
color:
Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
title: Text(notification.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -187,6 +244,29 @@ class NotificationSheet extends HookConsumerWidget {
).colorScheme.onSurface.withOpacity(0.8),
),
),
if (imageIds.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children:
imageIds.map((imageId) {
return SizedBox(
width: 80,
height: 80,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CloudImageWidget(
fileId: imageId,
aspectRatio: 1,
fit: BoxFit.cover,
),
),
);
}).toList(),
),
),
],
),
trailing:

View File

@@ -8,7 +8,7 @@ import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/models/poll.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:uuid/uuid.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -393,7 +393,7 @@ class PollEditorScreen extends ConsumerWidget {
showSnackBar(isUpdate ? 'pollUpdated'.tr() : 'pollCreated'.tr());
if (!context.mounted) return;
Navigator.of(context).maybePop(res.data);
Navigator.of(context).maybePop(SnPoll.fromJson(res.data));
} catch (e) {
showErrorAlert(e);
}
@@ -415,23 +415,46 @@ class PollEditorScreen extends ConsumerWidget {
});
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr()),
actions: [
if (kDebugMode)
IconButton(
tooltip: 'pollPreviewJsonDebug'.tr(),
onPressed: () {
_showDebugPreview(context, model);
},
icon: const Icon(Icons.visibility_outlined),
),
const Gap(8),
],
),
body: Column(
return SheetScaffold(
titleText: model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr(),
actions: [
if (kDebugMode)
IconButton(
tooltip: 'pollPreviewJsonDebug'.tr(),
onPressed: () {
_showDebugPreview(context, model);
},
icon: const Icon(Icons.visibility_outlined),
),
],
heightFactor: 0.9,
onClose: () async {
final confirmed = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text('confirm'.tr()),
content: Text('pollConfirmDiscard'.tr()),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text('cancel'.tr()),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(ctx).colorScheme.error,
),
child: Text('discard'.tr()),
),
],
),
);
if (confirmed == true) {
if (context.mounted) Navigator.of(context).pop();
}
},
child: Column(
children: [
Expanded(
child: ConstrainedBox(

View File

@@ -306,7 +306,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
],
),
children: [
ValueListenableBuilder<Map<int, double>>(
ValueListenableBuilder<Map<int, double?>>(
valueListenable: state.attachmentProgress,
builder: (context, progressMap, _) {
return Wrap(

View File

@@ -237,7 +237,9 @@ class PostSearchScreen extends HookConsumerWidget {
controller: pubNameController,
decoration: InputDecoration(
labelText: 'pubName'.tr(),
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
),
onChanged:
(value) => onSearchWithFilters(searchController.text),
@@ -247,7 +249,9 @@ class PostSearchScreen extends HookConsumerWidget {
controller: realmController,
decoration: InputDecoration(
labelText: 'realm'.tr(),
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
),
onChanged:
(value) => onSearchWithFilters(searchController.text),

View File

@@ -73,10 +73,33 @@ class _PublisherBasisWidget extends StatelessWidget {
Positioned(
bottom: -24,
left: 16,
child: ProfilePictureWidget(
file: data.picture,
radius: 32,
borderRadius: data.type == 0 ? null : 12,
child: GestureDetector(
child: Badge(
isLabelVisible: data.type == 0,
padding: EdgeInsets.all(3),
label: Icon(
Symbols.launch,
size: 12,
color: Theme.of(context).colorScheme.onPrimary,
),
backgroundColor:
Theme.of(context).colorScheme.primary,
offset: Offset(0, 48),
child: ProfilePictureWidget(
file: data.picture,
radius: 32,
borderRadius: data.type == 0 ? null : 12,
),
),
onTap: () {
if (data.account?.name != null) {
Navigator.pop(context, true);
context.pushNamed(
'accountProfile',
pathParameters: {'name': data.account!.name},
);
}
},
),
),
],

View File

@@ -92,7 +92,7 @@ class EditRealmScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,

View File

@@ -5,13 +5,16 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/notification.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/navigation/conditional_bottom_nav.dart';
import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/chat/chat_summary.dart';
final currentRouteProvider = StateProvider<String?>((ref) => null);
@@ -21,6 +24,8 @@ const kTabRoutes = [
'/chat',
'/realms',
'/account',
'/files',
'/thought',
'/creators',
'/developers',
];
@@ -46,6 +51,8 @@ class TabsScreen extends HookConsumerWidget {
notificationUnreadCountNotifierProvider,
);
final chatUnreadCount = ref.watch(chatUnreadCountNotifierProvider);
final wideScreen = isWideScreen(context);
final destinations = [
@@ -55,7 +62,11 @@ class TabsScreen extends HookConsumerWidget {
),
NavigationDestination(
label: 'chat'.tr(),
icon: const Icon(Symbols.forum_rounded),
icon: Badge.count(
count: chatUnreadCount.value ?? 0,
isLabelVisible: (chatUnreadCount.value ?? 0) > 0,
child: const Icon(Symbols.forum_rounded),
),
),
NavigationDestination(
label: 'realms'.tr(),
@@ -66,19 +77,40 @@ class TabsScreen extends HookConsumerWidget {
icon: Badge.count(
count: notificationUnreadCount.value ?? 0,
isLabelVisible: (notificationUnreadCount.value ?? 0) > 0,
child: const Icon(Symbols.person_rounded),
child: Consumer(
child: const Icon(Symbols.account_circle_rounded),
builder: (context, ref, fallbackChild) {
final userInfo = ref.watch(userInfoProvider);
if (userInfo.value?.profile.picture != null) {
return ProfilePictureWidget(
file: userInfo.value!.profile.picture,
radius: 12,
);
}
return fallbackChild!;
},
),
),
),
if (wideScreen)
NavigationDestination(
label: 'creatorHub'.tr(),
icon: const Icon(Symbols.design_services_rounded),
),
if (wideScreen)
NavigationDestination(
label: 'developerHub'.tr(),
icon: const Icon(Symbols.data_object_rounded),
),
...([
NavigationDestination(
label: 'files'.tr(),
icon: const Icon(Symbols.folder_rounded),
),
NavigationDestination(
label: 'aiThought'.tr(),
icon: const Icon(Symbols.bubble_chart),
),
NavigationDestination(
label: 'creatorHub'.tr(),
icon: const Icon(Symbols.design_services_rounded),
),
NavigationDestination(
label: 'developerHub'.tr(),
icon: const Icon(Symbols.data_object_rounded),
),
]),
];
int getCurrentIndex() {

View File

@@ -1,6 +1,3 @@
import "dart:convert";
import "dart:math" as math;
import "package:dio/dio.dart";
import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
@@ -9,18 +6,22 @@ import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/models/thought.dart";
import "package:island/pods/network.dart";
import "package:island/pods/userinfo.dart";
import "package:island/widgets/alert.dart";
import "package:island/widgets/app_scaffold.dart";
import "package:island/widgets/response.dart";
import "package:island/widgets/thought/thought_sequence_list.dart";
import "package:island/widgets/thought/thought_shared.dart";
import "package:material_symbols_icons/material_symbols_icons.dart";
import "package:super_sliver_list/super_sliver_list.dart";
import "package:collection/collection.dart";
part 'think.g.dart';
@riverpod
Future<bool> thoughtAvailableStaus(Ref ref) async {
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get('/insight/billing/status');
return response.data['status'] == 'ok';
}
@riverpod
Future<List<SnThinkingThought>> thoughtSequence(
Ref ref,
@@ -35,6 +36,13 @@ Future<List<SnThinkingThought>> thoughtSequence(
.toList();
}
@riverpod
Future<ThoughtServicesResponse> thoughtServices(Ref ref) async {
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get('/insight/thought/services');
return ThoughtServicesResponse.fromJson(response.data);
}
class ThoughtScreen extends HookConsumerWidget {
const ThoughtScreen({super.key});
@@ -46,203 +54,32 @@ class ThoughtScreen extends HookConsumerWidget {
? ref.watch(thoughtSequenceProvider(selectedSequenceId.value!))
: const AsyncValue<List<SnThinkingThought>>.data([]);
final localThoughts = useState<List<SnThinkingThought>>([]);
final currentTopic = useState<String?>('aiThought'.tr());
final messageController = useTextEditingController();
final scrollController = useScrollController();
final isStreaming = useState(false);
final streamingText = useState<String>('');
final functionCalls = useState<List<String>>([]);
final reasoningChunks = useState<List<String>>([]);
final listController = useMemoized(() => ListController(), []);
// Scroll animation notifiers
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
// Update local thoughts when provider data changes
useEffect(() {
thoughts.whenData((data) {
// Server returns messages in DESC order (newest first), keep as-is for UI
localThoughts.value = data;
// Update topic from the first thought's sequence
if (data.isNotEmpty && data.first.sequence?.topic != null) {
currentTopic.value = data.first.sequence!.topic;
} else {
currentTopic.value = 'aiThought'.tr();
// Extract sequence ID from loaded thoughts for the chat interface
final sequenceIdFromThoughts = thoughts.maybeWhen(
data: (thoughts) {
if (thoughts.isNotEmpty && thoughts.first.sequenceId.isNotEmpty) {
return thoughts.first.sequenceId;
}
});
return null;
}, [thoughts]);
return null;
},
orElse: () => null,
);
// Scroll to bottom when thoughts change or streaming state changes
useEffect(() {
if (localThoughts.value.isNotEmpty || isStreaming.value) {
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}
return null;
}, [localThoughts.value.length, isStreaming.value]);
// Get initial thoughts and topic from provider
final initialThoughts = thoughts.valueOrNull;
final initialTopic =
(initialThoughts?.isNotEmpty ?? false) &&
initialThoughts!.first.sequence?.topic != null
? initialThoughts.first.sequence!.topic
: 'aiThought'.tr();
// Add scroll listener for gradient animations
useEffect(() {
void onScroll() {
// Update gradient animations
final pixels = scrollController.position.pixels;
// Bottom gradient: appears when not at bottom (pixels > 0)
bottomGradientNotifier.value.value = (pixels / 500.0).clamp(0.0, 1.0);
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController]);
void sendMessage() async {
if (messageController.text.trim().isEmpty) return;
final userMessage = messageController.text.trim();
// Add user message to local thoughts
final userInfo = ref.read(userInfoProvider);
final now = DateTime.now();
final userThought = SnThinkingThought(
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
content: userMessage,
files: [],
role: ThinkingThoughtRole.user,
sequenceId: selectedSequenceId.value ?? '',
createdAt: now,
updatedAt: now,
sequence:
selectedSequenceId.value != null
? thoughts.value?.firstOrNull?.sequence ??
SnThinkingSequence(
id: selectedSequenceId.value!,
accountId: '',
createdAt: now,
updatedAt: now,
)
: SnThinkingSequence(
id: '',
accountId: userInfo.value!.id,
createdAt: now,
updatedAt: now,
),
);
localThoughts.value = [userThought, ...localThoughts.value];
final request = StreamThinkingRequest(
userMessage: userMessage,
sequenceId: selectedSequenceId.value,
accpetProposals: ['post_create'],
attachedMessages: [], // Message datas
attachedPosts: [], // ID list for posts
);
try {
isStreaming.value = true;
streamingText.value = '';
functionCalls.value = [];
reasoningChunks.value = [];
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.post(
'/insight/thought',
data: request.toJson(),
options: Options(
responseType: ResponseType.stream,
sendTimeout: Duration(minutes: 1),
receiveTimeout: Duration(minutes: 1),
),
);
final stream = response.data.stream;
final lineBuffer = StringBuffer();
stream.listen(
(data) {
final chunk = utf8.decode(data);
lineBuffer.write(chunk);
final lines = lineBuffer.toString().split('\n');
lineBuffer.clear();
lineBuffer.write(lines.last); // keep incomplete line
for (final line in lines.sublist(0, lines.length - 1)) {
if (line.trim().isEmpty) continue;
try {
if (line.startsWith('data: ')) {
final jsonStr = line.substring(6);
final event = jsonDecode(jsonStr);
final type = event['type'];
final eventData = event['data'];
if (type == 'text') {
streamingText.value += eventData;
} else if (type == 'function_call') {
functionCalls.value = [
...functionCalls.value,
JsonEncoder.withIndent(' ').convert(eventData),
];
} else if (type == 'reasoning') {
reasoningChunks.value = [
...reasoningChunks.value,
eventData,
];
}
} else if (line.startsWith('topic: ')) {
final jsonStr = line.substring(7);
final event = jsonDecode(jsonStr);
currentTopic.value = event['data'];
} else if (line.startsWith('thought: ')) {
final jsonStr = line.substring(9);
final event = jsonDecode(jsonStr);
final aiThought = SnThinkingThought.fromJson(event['data']);
localThoughts.value = [aiThought, ...localThoughts.value];
if (selectedSequenceId.value == null &&
aiThought.sequenceId.isNotEmpty) {
selectedSequenceId.value = aiThought.sequenceId;
}
isStreaming.value = false;
}
} catch (e) {
// Ignore parsing errors for individual events
}
}
},
onDone: () {
if (isStreaming.value) {
isStreaming.value = false;
showErrorAlert('thoughtParseError'.tr());
}
},
onError: (error) {
isStreaming.value = false;
if (error is DioException && error.response?.data is ResponseBody) {
showErrorAlert('toughtParseError'.tr());
} else {
showErrorAlert(error);
}
},
);
messageController.clear();
FocusManager.instance.primaryFocus?.unfocus();
} catch (error) {
isStreaming.value = false;
showErrorAlert(error);
}
}
final statusAsync = ref.watch(thoughtAvailableStausProvider);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(currentTopic.value ?? 'aiThought'.tr()),
title: Text(initialTopic ?? 'aiThought'.tr()),
leading: const PageBackButton(),
actions: [
IconButton(
icon: const Icon(Symbols.history),
@@ -259,137 +96,96 @@ class ThoughtScreen extends HookConsumerWidget {
);
},
),
if (localThoughts.value.isNotEmpty &&
!isStreaming.value &&
localThoughts.value.last.role == ThinkingThoughtRole.assistant)
IconButton(
icon: const Icon(Symbols.add),
tooltip: 'thoughtNewConversation'.tr(),
onPressed: () {
// Clear current conversation and start new one
selectedSequenceId.value = null;
localThoughts.value = [];
currentTopic.value = 'aiThought'.tr();
messageController.clear();
},
),
const Gap(8),
],
),
body: Stack(
children: [
// Thoughts list
Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: Column(
body: statusAsync.maybeWhen(
data: (status) {
final retry = useMemoized(
() => () async {
showLoadingModal(context);
try {
await ref
.read(apiClientProvider)
.post('/insight/billing/retry');
showSnackBar('Retried billing process');
ref.invalidate(thoughtAvailableStausProvider);
} catch (e) {
showSnackBar('Failed to retry billing');
}
if (context.mounted) hideLoadingModal(context);
},
[context, ref],
);
final thoughtsBody = thoughts.when(
data:
(thoughtList) => ThoughtChatInterface(
initialThoughts: thoughtList,
initialSequenceId: sequenceIdFromThoughts,
initialTopic: initialTopic,
isDisabled: !status,
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() =>
selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(
selectedSequenceId.value!,
),
)
: null,
),
);
return status
? thoughtsBody
: Column(
children: [
Expanded(
child: thoughts.when(
data:
(thoughtList) => SuperListView.builder(
listController: listController,
controller: scrollController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for thought input
),
reverse: true,
itemCount:
localThoughts.value.length +
(isStreaming.value ? 1 : 0),
itemBuilder: (context, index) {
if (isStreaming.value && index == 0) {
return ThoughtItem(
isStreaming: true,
streamingText: streamingText.value,
reasoningChunks: reasoningChunks.value,
streamingFunctionCalls: functionCalls.value,
);
}
final thoughtIndex =
isStreaming.value ? index - 1 : index;
final thought = localThoughts.value[thoughtIndex];
return ThoughtItem(
thought: thought,
thoughtIndex: thoughtIndex,
);
},
),
loading:
() =>
const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() =>
selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(
selectedSequenceId.value!,
),
)
: null,
),
MaterialBanner(
leading: const Icon(Symbols.error),
content: const Text(
'You have unpaid orders. Please settle your payment to continue using the service.',
style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
TextButton(
onPressed: () {
retry();
},
child: Text('retry'.tr()),
),
],
),
Expanded(child: thoughtsBody),
],
),
),
),
// Bottom gradient - appears when scrolling towards newer thoughts (behind thought input)
AnimatedBuilder(
animation: bottomGradientNotifier.value,
builder:
(context, child) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: Opacity(
opacity: bottomGradientNotifier.value.value,
child: Container(
height: math.min(
MediaQuery.of(context).size.height * 0.1,
128,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.8),
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.0),
],
),
),
),
);
},
orElse:
() => thoughts.when(
data:
(thoughtList) => ThoughtChatInterface(
initialThoughts: thoughtList,
initialTopic: initialTopic,
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() =>
selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(
selectedSequenceId.value!,
),
)
: null,
),
),
),
// Thought Input positioned above gradient (higher z-index)
Positioned(
left: 0,
right: 0,
bottom: 0, // At the very bottom, above gradient
child: Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: ThoughtInput(
messageController: messageController,
isStreaming: isStreaming.value,
onSend: sendMessage,
),
),
),
),
],
),
);
}

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