Compare commits

..

115 Commits

Author SHA1 Message Date
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
156 changed files with 26069 additions and 5753 deletions

View File

@@ -136,6 +136,7 @@
"reactionNegative": "Negative", "reactionNegative": "Negative",
"reactionNeutral": "Neutral", "reactionNeutral": "Neutral",
"customReaction": "Custom Reaction", "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", "customReactions": "Custom Reactions",
"stickerPlaceholder": "Sticker Placeholder", "stickerPlaceholder": "Sticker Placeholder",
"reactionAttitude": "Reaction Attitude", "reactionAttitude": "Reaction Attitude",
@@ -179,6 +180,7 @@
"noFortuneData": "No fortune data available for this month.", "noFortuneData": "No fortune data available for this month.",
"creatorHub": "Creator Hub", "creatorHub": "Creator Hub",
"creatorHubDescription": "Manage posts, analytics, and more.", "creatorHubDescription": "Manage posts, analytics, and more.",
"publicationSites": "Publication Sites",
"developerPortal": "Developer Portal", "developerPortal": "Developer Portal",
"developerPortalDescription": "Build with Solar Network™.", "developerPortalDescription": "Build with Solar Network™.",
"statusCreateHint": "What's on your mind? Add a status.", "statusCreateHint": "What's on your mind? Add a status.",
@@ -1302,7 +1304,9 @@
"thoughtInputHint": "Ask sn-chan anything...", "thoughtInputHint": "Ask sn-chan anything...",
"thoughtNewConversation": "Start New Conversation", "thoughtNewConversation": "Start New Conversation",
"thoughtParseError": "Failed to parse AI response", "thoughtParseError": "Failed to parse AI response",
"thoughtFunctionCall": "Function Call", "thoughtFunctionCall": "Use {}",
"thoughtFunctionCallBegin": "Calling tool {}",
"thoughtFunctionCallFinish": "{} responded",
"aiThought": "AI Thought", "aiThought": "AI Thought",
"aiThoughtTitle": "Let sn-chan think", "aiThoughtTitle": "Let sn-chan think",
"postReferenceUnavailable": "Referenced post is unavailable", "postReferenceUnavailable": "Referenced post is unavailable",
@@ -1321,5 +1325,151 @@
"popularity": "Popularity", "popularity": "Popularity",
"descendingOrder": "Descending Order", "descendingOrder": "Descending Order",
"selectDate": "Select Date", "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": "翻译者", "translatorBadgeName": "翻译者",
"translatorBadgeDescription": "协助将 Solar Network 翻译成不同语言", "translatorBadgeDescription": "协助将 Solar Network 翻译成不同语言",
"wallet": "钱包", "wallet": "钱包",
"walletCurrencyPoints": "新太阳点", "walletCurrencyPoints": "源能点",
"walletCurrencyShortPoints": "NSP", "walletCurrencyShortPoints": "NSP",
"walletCurrencyGolds": "太阳币", "walletCurrencyGolds": "星辰碎片",
"walletCurrencyShortGolds": "TSD", "walletCurrencyShortGolds": "SHD",
"retry": "重试", "retry": "重试",
"creatorHubUnselectedHint": "选择/创建一个发布者以开始使用。", "creatorHubUnselectedHint": "选择/创建一个发布者以开始使用。",
"relationships": "关系", "relationships": "关系",
@@ -1090,5 +1090,6 @@
"thoughtNewConversation": "开始新对话", "thoughtNewConversation": "开始新对话",
"thoughtParseError": "解析 AI 响应失败", "thoughtParseError": "解析 AI 响应失败",
"aiThought": "寻思", "aiThought": "寻思",
"aiThoughtTitle": "让 SN 酱寻思寻思" "aiThoughtTitle": "让 SN 酱寻思寻思",
"thoughtUnpaidHint": "寻思因为有未支付的订单而被禁用"
} }

File diff suppressed because one or more lines are too long

View File

@@ -57,7 +57,7 @@ PODS:
- firebase_core (4.2.1): - firebase_core (4.2.1):
- Firebase/CoreOnly (= 12.4.0) - Firebase/CoreOnly (= 12.4.0)
- Flutter - Flutter
- firebase_crashlytics (5.0.4): - firebase_crashlytics (5.0.5):
- Firebase/Crashlytics (= 12.4.0) - Firebase/Crashlytics (= 12.4.0)
- firebase_core - firebase_core
- Flutter - Flutter
@@ -140,15 +140,13 @@ PODS:
- Flutter - Flutter
- flutter_native_splash (2.4.3): - flutter_native_splash (2.4.3):
- Flutter - Flutter
- flutter_platform_alert (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0): - flutter_secure_storage (6.0.0):
- Flutter - Flutter
- flutter_timezone (0.0.1): - flutter_timezone (0.0.1):
- Flutter - Flutter
- flutter_udid (0.0.1): - flutter_udid (0.0.1):
- Flutter - Flutter
- SAMKeychain - KeychainAccess
- flutter_webrtc (1.2.0): - flutter_webrtc (1.2.0):
- Flutter - Flutter
- WebRTC-SDK (= 137.7151.04) - WebRTC-SDK (= 137.7151.04)
@@ -216,7 +214,8 @@ PODS:
- Flutter - Flutter
- irondash_engine_context (0.0.1): - irondash_engine_context (0.0.1):
- Flutter - Flutter
- Kingfisher (8.6.1) - KeychainAccess (4.2.2)
- Kingfisher (8.6.2)
- KingfisherWebP (1.7.2): - KingfisherWebP (1.7.2):
- Kingfisher (~> 8.0) - Kingfisher (~> 8.0)
- libwebp (>= 1.1.0) - libwebp (>= 1.1.0)
@@ -250,14 +249,13 @@ PODS:
- nanopb/encode (3.30910.0) - nanopb/encode (3.30910.0)
- native_exif (0.0.1): - native_exif (0.0.1):
- Flutter - Flutter
- objective_c (0.0.1):
- Flutter
- OrderedSet (6.0.3) - OrderedSet (6.0.3)
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
- Flutter - Flutter
- pasteboard (0.0.1): - pasteboard (0.0.1):
- Flutter - Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- pointer_interceptor_ios (0.0.1): - pointer_interceptor_ios (0.0.1):
- Flutter - Flutter
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
@@ -269,7 +267,6 @@ PODS:
- Flutter - Flutter
- record_ios (1.1.0): - record_ios (1.1.0):
- Flutter - Flutter
- SAMKeychain (1.5.3)
- SDWebImage (5.21.3): - SDWebImage (5.21.3):
- SDWebImage/Core (= 5.21.3) - SDWebImage/Core (= 5.21.3)
- SDWebImage/Core (5.21.3) - SDWebImage/Core (5.21.3)
@@ -315,8 +312,6 @@ PODS:
- Flutter - Flutter
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- volume_controller (0.0.1):
- Flutter
- wakelock_plus (0.0.1): - wakelock_plus (0.0.1):
- Flutter - Flutter
- WebRTC-SDK (137.7151.04) - WebRTC-SDK (137.7151.04)
@@ -338,7 +333,6 @@ DEPENDENCIES:
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/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_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
@@ -353,9 +347,9 @@ DEPENDENCIES:
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- native_exif (from `.symlinks/plugins/native_exif/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`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- pasteboard (from `.symlinks/plugins/pasteboard/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`) - pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`) - protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
@@ -368,7 +362,6 @@ DEPENDENCIES:
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`) - syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS: SPEC REPOS:
@@ -390,6 +383,7 @@ SPEC REPOS:
- GoogleAppMeasurement - GoogleAppMeasurement
- GoogleDataTransport - GoogleDataTransport
- GoogleUtilities - GoogleUtilities
- KeychainAccess
- Kingfisher - Kingfisher
- KingfisherWebP - KingfisherWebP
- libwebp - libwebp
@@ -397,7 +391,6 @@ SPEC REPOS:
- OrderedSet - OrderedSet
- PromisesObjC - PromisesObjC
- PromisesSwift - PromisesSwift
- SAMKeychain
- SDWebImage - SDWebImage
- sqlite3 - sqlite3
- SwiftyGif - SwiftyGif
@@ -434,8 +427,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_local_notifications/ios" :path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash: flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios" :path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_platform_alert:
:path: ".symlinks/plugins/flutter_platform_alert/ios"
flutter_secure_storage: flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios" :path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_timezone: flutter_timezone:
@@ -460,12 +451,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/media_kit_video/ios" :path: ".symlinks/plugins/media_kit_video/ios"
native_exif: native_exif:
:path: ".symlinks/plugins/native_exif/ios" :path: ".symlinks/plugins/native_exif/ios"
objective_c:
:path: ".symlinks/plugins/objective_c/ios"
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
pasteboard: pasteboard:
:path: ".symlinks/plugins/pasteboard/ios" :path: ".symlinks/plugins/pasteboard/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
pointer_interceptor_ios: pointer_interceptor_ios:
:path: ".symlinks/plugins/pointer_interceptor_ios/ios" :path: ".symlinks/plugins/pointer_interceptor_ios/ios"
protocol_handler_ios: protocol_handler_ios:
@@ -490,8 +481,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios" :path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
volume_controller:
:path: ".symlinks/plugins/volume_controller/ios"
wakelock_plus: wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios" :path: ".symlinks/plugins/wakelock_plus/ios"
@@ -507,7 +496,7 @@ SPEC CHECKSUMS:
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
firebase_analytics: 67fbdd9f3c04e55048024f3da21cfc36f05e56cf firebase_analytics: 67fbdd9f3c04e55048024f3da21cfc36f05e56cf
firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594 firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594
firebase_crashlytics: 83c7467d7534975a4d779af43bd226d0a4616464 firebase_crashlytics: c039028126cb45e32f4c217aa392408b0963d081
firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde
FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f
FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3 FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
@@ -524,10 +513,9 @@ SPEC CHECKSUMS:
flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619 flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_platform_alert: bf3b5fcd4ac14bd637e20527e9c471633071afd3
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 flutter_udid: 92a5d31fe0526b7b6002a2318df702e12e7eb300
flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22 flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22
gal: baecd024ebfd13c441269ca7404792a7152fde89 gal: baecd024ebfd13c441269ca7404792a7152fde89
GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1 GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1
@@ -536,7 +524,8 @@ SPEC CHECKSUMS:
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
Kingfisher: 7ac7a7288653787a54206b11a3c74f49ab650f1f KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
Kingfisher: 23d18f54677d973b713e54ce6a8f5eef6e7056ba
KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402 KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
livekit_client: 86c8af579274e4b7a215185a8080db2d4e176f40 livekit_client: 86c8af579274e4b7a215185a8080db2d4e176f40
@@ -545,17 +534,16 @@ SPEC CHECKSUMS:
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
native_exif: 0eb73d3d5b3ca892719228df8d2d1b13d1ae396c native_exif: 0eb73d3d5b3ca892719228df8d2d1b13d1ae396c
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0 pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
protocol_handler_ios: 59f23ee71f3ec602d67902ca7f669a80957888d5 protocol_handler_ios: 59f23ee71f3ec602d67902ca7f669a80957888d5
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
@@ -567,7 +555,6 @@ SPEC CHECKSUMS:
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14 syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e

View File

@@ -2,17 +2,19 @@ import 'dart:convert';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:island/database/message.dart'; import 'package:island/database/message.dart';
import 'package:island/database/draft.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'; import 'package:island/models/post.dart';
part 'drift_db.g.dart'; part 'drift_db.g.dart';
// Define the database // Define the database
@DriftDatabase(tables: [ChatMessages, PostDrafts]) @DriftDatabase(tables: [ChatRooms, ChatMembers, ChatMessages, PostDrafts])
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase(super.e); AppDatabase(super.e);
@override @override
int get schemaVersion => 7; int get schemaVersion => 8;
@override @override
MigrationStrategy get migration => MigrationStrategy( 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 roomId,
String query, { String query, {
bool? withAttachments, bool? withAttachments,
Future<SnAccount?> Function(String accountId)? fetchAccount,
}) async { }) async {
var selectStatement = select(chatMessages) var selectStatement = select(chatMessages)
..where((m) => m.roomId.equals(roomId)); ..where((m) => m.roomId.equals(roomId));
@@ -178,7 +186,11 @@ class AppDatabase extends _$AppDatabase {
await (selectStatement await (selectStatement
..orderBy([(m) => OrderingTerm.desc(m.createdAt)])) ..orderBy([(m) => OrderingTerm.desc(m.createdAt)]))
.get(); .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 // 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); 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( return LocalChatMessage(
id: dbMessage.id, id: dbMessage.id,
roomId: dbMessage.roomId, roomId: dbMessage.roomId,
senderId: dbMessage.senderId, senderId: dbMessage.senderId,
sender: sender,
data: data, data: data,
createdAt: dbMessage.createdAt, createdAt: dbMessage.createdAt,
status: dbMessage.status, 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 // Methods for post drafts
Future<List<SnPost>> getAllPostDrafts() async { Future<List<SnPost>> getAllPostDrafts() async {
final drafts = await select(postDrafts).get(); final drafts = await select(postDrafts).get();
@@ -276,4 +443,10 @@ class AppDatabase extends _$AppDatabase {
return await (select(postDrafts) return await (select(postDrafts)
..where((tbl) => tbl.id.equals(id))).getSingleOrNull(); ..where((tbl) => tbl.id.equals(id))).getSingleOrNull();
} }
Future<void> saveMember(SnChatMember member) async {
await into(
chatMembers,
).insert(companionFromMember(member), mode: InsertMode.insertOrReplace);
}
} }

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); 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 { class ChatMessages extends Table {
TextColumn get id => text()(); TextColumn get id => text()();
TextColumn get roomId => text()(); TextColumn get roomId => text().references(ChatRooms, #id)();
TextColumn get senderId => text()(); TextColumn get senderId => text().references(ChatMembers, #id)();
TextColumn get content => text().nullable()(); TextColumn get content => text().nullable()();
TextColumn get nonce => text().nullable()(); TextColumn get nonce => text().nullable()();
TextColumn get data => text()(); TextColumn get data => text()();
@@ -72,6 +114,7 @@ class LocalChatMessage {
final String id; final String id;
final String roomId; final String roomId;
final String senderId; final String senderId;
final SnChatMember? sender;
final Map<String, dynamic> data; final Map<String, dynamic> data;
final DateTime createdAt; final DateTime createdAt;
MessageStatus status; MessageStatus status;
@@ -94,6 +137,7 @@ class LocalChatMessage {
required this.id, required this.id,
required this.roomId, required this.roomId,
required this.senderId, required this.senderId,
required this.sender,
required this.data, required this.data,
required this.createdAt, required this.createdAt,
required this.nonce, required this.nonce,
@@ -114,7 +158,12 @@ class LocalChatMessage {
}); });
SnChatMessage toRemoteMessage() { 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( static LocalChatMessage fromRemoteMessage(
@@ -122,11 +171,26 @@ class LocalChatMessage {
MessageStatus status, { MessageStatus status, {
String? nonce, 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( return LocalChatMessage(
id: message.id, id: message.id,
roomId: message.chatRoomId, roomId: message.chatRoomId,
senderId: message.senderId, senderId: message.senderId,
data: message.toJson(), sender: message.sender,
data: msgData,
createdAt: message.createdAt, createdAt: message.createdAt,
status: status, status: status,
nonce: nonce ?? message.nonce, nonce: nonce ?? message.nonce,

View File

@@ -1,4 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/activity.dart';
import 'package:island/models/auth.dart'; import 'package:island/models/auth.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/wallet.dart'; import 'package:island/models/wallet.dart';
@@ -263,3 +264,15 @@ sealed class SnSocialCreditRecord with _$SnSocialCreditRecord {
factory SnSocialCreditRecord.fromJson(Map<String, dynamic> json) => factory SnSocialCreditRecord.fromJson(Map<String, dynamic> json) =>
_$SnSocialCreditRecordFromJson(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 // dart format on

View File

@@ -449,3 +449,22 @@ Map<String, dynamic> _$SnSocialCreditRecordToJson(
'updated_at': instance.updatedAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.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

@@ -46,6 +46,18 @@ sealed class SnPoll with _$SnPoll {
}) = _SnPoll; }) = _SnPoll;
factory SnPoll.fromJson(Map<String, dynamic> json) => _$SnPollFromJson(json); 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 @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; 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 @freezed
sealed class StreamThinkingRequest with _$StreamThinkingRequest { sealed class StreamThinkingRequest with _$StreamThinkingRequest {
const factory StreamThinkingRequest({ const factory StreamThinkingRequest({
@@ -46,6 +71,7 @@ sealed class StreamThinkingRequest with _$StreamThinkingRequest {
@Default([]) List<String> accpetProposals, @Default([]) List<String> accpetProposals,
List<String>? attachedPosts, List<String>? attachedPosts,
List<Map<String, dynamic>>? attachedMessages, List<Map<String, dynamic>>? attachedMessages,
@JsonKey(name: 'service_id') String? serviceId,
}) = _StreamThinkingRequest; }) = _StreamThinkingRequest;
factory StreamThinkingRequest.fromJson(Map<String, dynamic> json) => factory StreamThinkingRequest.fromJson(Map<String, dynamic> json) =>
@@ -77,6 +103,43 @@ sealed class SnThinkingChunk with _$SnThinkingChunk {
_$SnThinkingChunkFromJson(json); _$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 @freezed
sealed class SnThinkingSequence with _$SnThinkingSequence { sealed class SnThinkingSequence with _$SnThinkingSequence {
const factory SnThinkingSequence({ const factory SnThinkingSequence({
@@ -98,9 +161,8 @@ sealed class SnThinkingSequence with _$SnThinkingSequence {
sealed class SnThinkingThought with _$SnThinkingThought { sealed class SnThinkingThought with _$SnThinkingThought {
const factory SnThinkingThought({ const factory SnThinkingThought({
required String id, required String id,
String? content, @Default([]) List<SnThinkingMessagePart> parts,
@Default([]) List<SnCloudFile> files, @Default([]) List<SnCloudFile> files,
@Default([]) List<SnThinkingChunk> chunks,
@ThinkingThoughtRoleConverter() required ThinkingThoughtRole role, @ThinkingThoughtRoleConverter() required ThinkingThoughtRole role,
int? tokenCount, int? tokenCount,
String? modelName, String? modelName,
@@ -114,3 +176,26 @@ sealed class SnThinkingThought with _$SnThinkingThought {
factory SnThinkingThought.fromJson(Map<String, dynamic> json) => factory SnThinkingThought.fromJson(Map<String, dynamic> json) =>
_$SnThinkingThoughtFromJson(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>?) (json['attached_messages'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>) ?.map((e) => e as Map<String, dynamic>)
.toList(), .toList(),
serviceId: json['service_id'] as String?,
); );
Map<String, dynamic> _$StreamThinkingRequestToJson( Map<String, dynamic> _$StreamThinkingRequestToJson(
@@ -34,6 +35,7 @@ Map<String, dynamic> _$StreamThinkingRequestToJson(
'accpet_proposals': instance.accpetProposals, 'accpet_proposals': instance.accpetProposals,
'attached_posts': instance.attachedPosts, 'attached_posts': instance.attachedPosts,
'attached_messages': instance.attachedMessages, 'attached_messages': instance.attachedMessages,
'service_id': instance.serviceId,
}; };
_SnThinkingChunk _$SnThinkingChunkFromJson(Map<String, dynamic> json) => _SnThinkingChunk _$SnThinkingChunkFromJson(Map<String, dynamic> json) =>
@@ -50,6 +52,64 @@ Map<String, dynamic> _$SnThinkingChunkToJson(_SnThinkingChunk instance) =>
'data': instance.data, '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 _$SnThinkingSequenceFromJson(Map<String, dynamic> json) =>
_SnThinkingSequence( _SnThinkingSequence(
id: json['id'] as String, id: json['id'] as String,
@@ -80,17 +140,19 @@ Map<String, dynamic> _$SnThinkingSequenceToJson(_SnThinkingSequence instance) =>
_SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) => _SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
_SnThinkingThought( _SnThinkingThought(
id: json['id'] as String, 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: files:
(json['files'] as List<dynamic>?) (json['files'] as List<dynamic>?)
?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
const [], const [],
chunks:
(json['chunks'] as List<dynamic>?)
?.map((e) => SnThinkingChunk.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
role: const ThinkingThoughtRoleConverter().fromJson( role: const ThinkingThoughtRoleConverter().fromJson(
(json['role'] as num).toInt(), (json['role'] as num).toInt(),
), ),
@@ -114,9 +176,8 @@ _SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) => Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
<String, dynamic>{ <String, dynamic>{
'id': instance.id, 'id': instance.id,
'content': instance.content, 'parts': instance.parts.map((e) => e.toJson()).toList(),
'files': instance.files.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), 'role': const ThinkingThoughtRoleConverter().toJson(instance.role),
'token_count': instance.tokenCount, 'token_count': instance.tokenCount,
'model_name': instance.modelName, 'model_name': instance.modelName,
@@ -126,3 +187,34 @@ Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
'updated_at': instance.updatedAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.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 id,
required String currency, required String currency,
required double totalAmount, required double totalAmount,
required double remainingAmount,
required int amountOfSplits,
required int splitType, // 0: even, 1: random required int splitType, // 0: even, 1: random
required int required int
status, // 0: created, 1: partially claimed, 2: fully claimed, 3: expired status, // 0: created, 1: partially claimed, 2: fully claimed, 3: expired
@@ -184,6 +186,7 @@ sealed class SnWalletFund with _$SnWalletFund {
required SnAccount? creatorAccount, required SnAccount? creatorAccount,
required DateTime expiredAt, required DateTime expiredAt,
required List<SnWalletFundRecipient> recipients, required List<SnWalletFundRecipient> recipients,
required bool isOpen,
required DateTime createdAt, required DateTime createdAt,
required DateTime updatedAt, required DateTime updatedAt,
required DateTime? deletedAt, required DateTime? deletedAt,

View File

@@ -2553,9 +2553,9 @@ $SnWalletSubscriptionCopyWith<$Res>? get subscription {
/// @nodoc /// @nodoc
mixin _$SnWalletFund { 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 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 /// Create a copy of SnWalletFund
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -2568,16 +2568,16 @@ $SnWalletFundCopyWith<SnWalletFund> get copyWith => _$SnWalletFundCopyWithImpl<S
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { 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; factory $SnWalletFundCopyWith(SnWalletFund value, $Res Function(SnWalletFund) _then) = _$SnWalletFundCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of SnWalletFund
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? 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( return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,currency: null == currency ? _self.currency : currency // 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 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,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 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?,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 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 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 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,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,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?, 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) { switch (_that) {
case _SnWalletFund() when $default != null: 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(); 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) { switch (_that) {
case _SnWalletFund(): 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` /// 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) { switch (_that) {
case _SnWalletFund() when $default != null: 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; return null;
} }
@@ -2767,12 +2770,14 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
@JsonSerializable() @JsonSerializable()
class _SnWalletFund implements SnWalletFund { 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); factory _SnWalletFund.fromJson(Map<String, dynamic> json) => _$SnWalletFundFromJson(json);
@override final String id; @override final String id;
@override final String currency; @override final String currency;
@override final double totalAmount; @override final double totalAmount;
@override final double remainingAmount;
@override final int amountOfSplits;
@override final int splitType; @override final int splitType;
// 0: even, 1: random // 0: even, 1: random
@override final int status; @override final int status;
@@ -2788,6 +2793,7 @@ class _SnWalletFund implements SnWalletFund {
return EqualUnmodifiableListView(_recipients); return EqualUnmodifiableListView(_recipients);
} }
@override final bool isOpen;
@override final DateTime createdAt; @override final DateTime createdAt;
@override final DateTime updatedAt; @override final DateTime updatedAt;
@override final DateTime? deletedAt; @override final DateTime? deletedAt;
@@ -2805,16 +2811,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { 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; factory _$SnWalletFundCopyWith(_SnWalletFund value, $Res Function(_SnWalletFund) _then) = __$SnWalletFundCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of SnWalletFund
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? 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( return _then(_SnWalletFund(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,currency: null == currency ? _self.currency : currency // 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 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,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 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?,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 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 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 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,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,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?, as DateTime?,

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,11 @@ import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/database/drift_db.dart"; import "package:island/database/drift_db.dart";
import "package:island/database/message.dart"; import "package:island/database/message.dart";
import "package:island/models/account.dart";
import "package:island/models/chat.dart"; import "package:island/models/chat.dart";
import "package:island/models/file.dart"; import "package:island/models/file.dart";
import "package:island/models/poll.dart";
import "package:island/models/wallet.dart";
import "package:island/pods/database.dart"; import "package:island/pods/database.dart";
import "package:island/pods/lifecycle.dart"; import "package:island/pods/lifecycle.dart";
import "package:island/pods/network.dart"; import "package:island/pods/network.dart";
@@ -18,6 +21,7 @@ import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:uuid/uuid.dart"; import "package:uuid/uuid.dart";
import "package:island/screens/chat/chat.dart"; import "package:island/screens/chat/chat.dart";
import "package:island/pods/chat/chat_rooms.dart"; import "package:island/pods/chat/chat_rooms.dart";
import "package:island/screens/account/profile.dart";
part 'messages_notifier.g.dart'; part 'messages_notifier.g.dart';
@@ -43,6 +47,8 @@ class MessagesNotifier extends _$MessagesNotifier {
bool _isUpdatingState = false; bool _isUpdatingState = false;
DateTime? _lastPauseTime; DateTime? _lastPauseTime;
late final Future<SnAccount?> Function(String) _fetchAccount;
@override @override
FutureOr<List<LocalChatMessage>> build(String roomId) async { FutureOr<List<LocalChatMessage>> build(String roomId) async {
_roomId = roomId; _roomId = roomId;
@@ -51,6 +57,15 @@ class MessagesNotifier extends _$MessagesNotifier {
final room = await ref.watch(chatroomProvider(roomId).future); final room = await ref.watch(chatroomProvider(roomId).future);
final identity = await ref.watch(chatroomIdentityProvider(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) { if (room == null) {
throw Exception('Room not found'); throw Exception('Room not found');
} }
@@ -131,6 +146,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_roomId, _roomId,
searchQuery, searchQuery,
withAttachments: withAttachments, withAttachments: withAttachments,
fetchAccount: _fetchAccount,
); );
} else { } else {
final chatMessagesFromDb = await _database.getMessagesForRoom( final chatMessagesFromDb = await _database.getMessagesForRoom(
@@ -138,8 +154,16 @@ class MessagesNotifier extends _$MessagesNotifier {
offset: offset, offset: offset,
limit: take, limit: take,
); );
dbMessages = dbMessages = await Future.wait(
chatMessagesFromDb.map(_database.companionToMessage).toList(); chatMessagesFromDb
.map(
(msg) => _database.companionToMessage(
msg,
fetchAccount: _fetchAccount,
),
)
.toList(),
);
} }
List<LocalChatMessage> filteredMessages = dbMessages; List<LocalChatMessage> filteredMessages = dbMessages;
@@ -200,8 +224,14 @@ class MessagesNotifier extends _$MessagesNotifier {
offset: offset, offset: offset,
limit: take, limit: take,
); );
final dbMessages = final dbMessages = await Future.wait(
chatMessagesFromDb.map(_database.companionToMessage).toList(); chatMessagesFromDb
.map(
(msg) =>
_database.companionToMessage(msg, fetchAccount: _fetchAccount),
)
.toList(),
);
// Always ensure unique messages to prevent duplicate keys // Always ensure unique messages to prevent duplicate keys
final uniqueMessages = <LocalChatMessage>[]; final uniqueMessages = <LocalChatMessage>[];
@@ -270,6 +300,9 @@ class MessagesNotifier extends _$MessagesNotifier {
for (final message in messages) { for (final message in messages) {
await _database.saveMessage(_database.messageToCompanion(message)); await _database.saveMessage(_database.messageToCompanion(message));
if (message.sender != null) {
await _database.saveMember(message.sender!); // Save/update member data
}
if (message.nonce != null) { if (message.nonce != null) {
_pendingMessages.removeWhere( _pendingMessages.removeWhere(
(_, pendingMsg) => pendingMsg.nonce == message.nonce, (_, pendingMsg) => pendingMsg.nonce == message.nonce,
@@ -298,7 +331,10 @@ class MessagesNotifier extends _$MessagesNotifier {
final lastMessage = final lastMessage =
dbMessages.isEmpty dbMessages.isEmpty
? null ? null
: _database.companionToMessage(dbMessages.first); : await _database.companionToMessage(
dbMessages.first,
fetchAccount: _fetchAccount,
);
if (lastMessage == null) { if (lastMessage == null) {
talker.log('No local messages, fetching from network'); talker.log('No local messages, fetching from network');
@@ -437,6 +473,8 @@ class MessagesNotifier extends _$MessagesNotifier {
WidgetRef ref, WidgetRef ref,
String content, String content,
List<UniversalFile> attachments, { List<UniversalFile> attachments, {
SnPoll? poll,
SnWalletFund? fund,
SnChatMessage? editingTo, SnChatMessage? editingTo,
SnChatMessage? forwardingTo, SnChatMessage? forwardingTo,
SnChatMessage? replyingTo, SnChatMessage? replyingTo,
@@ -464,6 +502,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages[localMessage.id] = localMessage; _pendingMessages[localMessage.id] = localMessage;
_fileUploadProgress[localMessage.id] = {}; _fileUploadProgress[localMessage.id] = {};
await _database.saveMessage(_database.messageToCompanion(localMessage)); await _database.saveMessage(_database.messageToCompanion(localMessage));
await _database.saveMember(mockMessage.sender);
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
state = AsyncValue.data([localMessage, ...currentMessages]); state = AsyncValue.data([localMessage, ...currentMessages]);
@@ -498,6 +537,8 @@ class MessagesNotifier extends _$MessagesNotifier {
'attachments_id': cloudAttachments.map((e) => e.id).toList(), 'attachments_id': cloudAttachments.map((e) => e.id).toList(),
'replied_message_id': replyingTo?.id, 'replied_message_id': replyingTo?.id,
'forwarded_message_id': forwardingTo?.id, 'forwarded_message_id': forwardingTo?.id,
'poll_id': poll?.id,
'fund_id': fund?.id,
'meta': {}, 'meta': {},
'nonce': nonce, 'nonce': nonce,
}, },
@@ -882,7 +923,10 @@ class MessagesNotifier extends _$MessagesNotifier {
await (_database.select(_database.chatMessages) await (_database.select(_database.chatMessages)
..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull(); ..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull();
if (localMessage != null) { if (localMessage != null) {
return _database.companionToMessage(localMessage); return _database.companionToMessage(
localMessage,
fetchAccount: _fetchAccount,
);
} }
final response = await _apiClient.get( final response = await _apiClient.get(

View File

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

View File

@@ -11,12 +11,36 @@ part 'file_list.g.dart';
class CloudFileListNotifier extends _$CloudFileListNotifier class CloudFileListNotifier extends _$CloudFileListNotifier
with CursorPagingNotifierMixin<FileListItem> { with CursorPagingNotifierMixin<FileListItem> {
String _currentPath = '/'; String _currentPath = '/';
String? _poolId;
String? _query;
String? _order;
bool _orderDesc = false;
void setPath(String path) { void setPath(String path) {
_currentPath = path; _currentPath = path;
ref.invalidateSelf(); 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 @override
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null); Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
@@ -26,9 +50,25 @@ class CloudFileListNotifier extends _$CloudFileListNotifier
}) async { }) async {
final client = ref.read(apiClientProvider); 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( final response = await client.get(
'/drive/index/browse', '/drive/index/browse',
queryParameters: {'path': _currentPath}, queryParameters: queryParameters,
); );
final List<String> folders = final List<String> folders =
@@ -58,6 +98,37 @@ Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
@riverpod @riverpod
class UnindexedFileListNotifier extends _$UnindexedFileListNotifier class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
with CursorPagingNotifierMixin<FileListItem> { 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 @override
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null); Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
@@ -70,9 +141,32 @@ class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
final offset = cursor != null ? int.tryParse(cursor) ?? 0 : 0; final offset = cursor != null ? int.tryParse(cursor) ?? 0 : 0;
const take = 50; // Default page size 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( final response = await client.get(
'/drive/index/unindexed', '/drive/index/unindexed',
queryParameters: {'take': take.toString(), 'offset': offset.toString()}, queryParameters: queryParameters,
); );
final total = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0; final total = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0;

View File

@@ -45,7 +45,7 @@ final billingQuotaProvider =
// ignore: unused_element // ignore: unused_element
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>; typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
String _$cloudFileListNotifierHash() => String _$cloudFileListNotifierHash() =>
r'5f2f80357cb31ac6473df5ac2101f9a462004f81'; r'533dfa86f920b60cf7491fb4aeb95ece19e428af';
/// See also [CloudFileListNotifier]. /// See also [CloudFileListNotifier].
@ProviderFor(CloudFileListNotifier) @ProviderFor(CloudFileListNotifier)
@@ -66,7 +66,7 @@ final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
typedef _$CloudFileListNotifier = typedef _$CloudFileListNotifier =
AutoDisposeAsyncNotifier<CursorPagingData<FileListItem>>; AutoDisposeAsyncNotifier<CursorPagingData<FileListItem>>;
String _$unindexedFileListNotifierHash() => String _$unindexedFileListNotifierHash() =>
r'48fc92432a50a562190da5fe8ed0920d171b07b6'; r'afa487d7b956b71b21ca1b073a01364a34ede1d5';
/// See also [UnindexedFileListNotifier]. /// See also [UnindexedFileListNotifier].
@ProviderFor(UnindexedFileListNotifier) @ProviderFor(UnindexedFileListNotifier)

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

View File

@@ -258,6 +258,24 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
}).toList(); }).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) { void removeTask(String taskId) {
state = state.where((task) => task.taskId != taskId).toList(); state = state.where((task) => task.taskId != taskId).toList();
} }
@@ -275,6 +293,10 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
.toList(); .toList();
} }
void clearAllTasks() {
state = [];
}
DriveTask? getTask(String taskId) { DriveTask? getTask(String taskId) {
return state.where((task) => task.taskId == taskId).firstOrNull; return state.where((task) => task.taskId == taskId).firstOrNull;
} }
@@ -291,6 +313,27 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
.toList(); .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 @override
void dispose() { void dispose() {
_websocketSubscription?.cancel(); _websocketSubscription?.cancel();

View File

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

View File

@@ -30,10 +30,8 @@ import 'package:island/screens/account/me/profile_update.dart';
import 'package:island/screens/account/leveling.dart'; import 'package:island/screens/account/leveling.dart';
import 'package:island/screens/account/me/account_settings.dart'; import 'package:island/screens/account/me/account_settings.dart';
import 'package:island/screens/chat/chat.dart'; import 'package:island/screens/chat/chat.dart';
import 'package:island/screens/chat/chat_form.dart';
import 'package:island/screens/chat/room.dart'; import 'package:island/screens/chat/room.dart';
import 'package:island/screens/chat/room_detail.dart'; import 'package:island/screens/chat/room_detail.dart';
import 'package:island/screens/chat/call.dart';
import 'package:island/screens/chat/search_messages.dart'; import 'package:island/screens/chat/search_messages.dart';
import 'package:island/screens/thought/think.dart'; import 'package:island/screens/thought/think.dart';
import 'package:island/screens/creators/hub.dart'; import 'package:island/screens/creators/hub.dart';
@@ -44,8 +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_marketplace.dart';
import 'package:island/screens/discovery/feeds/feed_detail.dart'; import 'package:island/screens/discovery/feeds/feed_detail.dart';
import 'package:island/screens/creators/poll/poll_list.dart'; import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/screens/creators/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/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.dart';
import 'package:island/screens/posts/compose_article.dart'; import 'package:island/screens/posts/compose_article.dart';
import 'package:island/screens/posts/post_detail.dart'; import 'package:island/screens/posts/post_detail.dart';
@@ -119,19 +118,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return ArticleEditScreen(id: id); 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( GoRoute(
name: 'logs', name: 'logs',
path: '/logs', path: '/logs',
@@ -177,6 +163,22 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const AboutScreen(), 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 // Main tabs with TabsScreen shell
ShellRoute( ShellRoute(
navigatorKey: _tabsShellKey, navigatorKey: _tabsShellKey,
@@ -270,11 +272,6 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/chat', path: '/chat',
builder: (context, state) => const ChatListScreen(), builder: (context, state) => const ChatListScreen(),
), ),
GoRoute(
name: 'chatNew',
path: '/chat/new',
builder: (context, state) => const NewChatScreen(),
),
GoRoute( GoRoute(
name: 'chatRoom', name: 'chatRoom',
path: '/chat/:id', path: '/chat/:id',
@@ -283,14 +280,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return ChatRoomScreen(id: id); return ChatRoomScreen(id: id);
}, },
), ),
GoRoute(
name: 'chatEdit',
path: '/chat/:id/edit',
builder: (context, state) {
final id = state.pathParameters['id']!;
return EditChatScreen(id: id);
},
),
GoRoute( GoRoute(
name: 'chatDetail', name: 'chatDetail',
path: '/chat/:id/detail', path: '/chat/:id/detail',
@@ -447,23 +436,13 @@ final routerProvider = Provider<GoRouter>((ref) {
name: 'files', name: 'files',
path: '/files', path: '/files',
builder: (context, state) => const FileListScreen(), builder: (context, state) => const FileListScreen(),
routes: [ ),
GoRoute(
name: 'fileDetail', // SN-chan tab
path: ':id', GoRoute(
builder: (context, state) { name: 'thought',
// For now, we'll need to pass the file object through extra path: '/thought',
// This will be updated when we modify the file list navigation builder: (context, state) => const ThoughtScreen(),
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();
},
),
],
), ),
// Creator hub tab // Creator hub tab
@@ -498,28 +477,30 @@ final routerProvider = Provider<GoRouter>((ref) {
return CreatorPollListScreen(pubName: name); return CreatorPollListScreen(pubName: name);
}, },
), ),
// Poll routes // Site list route
GoRoute( GoRoute(
name: 'creatorPollNew', name: 'creatorSites',
path: ':name/polls/new', path: ':name/sites',
builder: (context, state) { builder: (context, state) {
final name = state.pathParameters['name']!; final name = state.pathParameters['name']!;
// initialPollId left null for create; initialPublisher prefilled return CreatorSiteListScreen(pubName: name);
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,
);
}, },
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( GoRoute(
name: 'creatorStickers', name: 'creatorStickers',
path: ':name/stickers', path: ':name/stickers',

View File

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

View File

@@ -375,16 +375,17 @@ class AccountScreen extends HookConsumerWidget {
); );
}, },
), ),
ListTile( if (!isWideScreen(context))
minTileHeight: 48, ListTile(
leading: const Icon(Symbols.files), minTileHeight: 48,
trailing: const Icon(Symbols.chevron_right), leading: const Icon(Symbols.files),
contentPadding: EdgeInsets.symmetric(horizontal: 24), trailing: const Icon(Symbols.chevron_right),
title: Text('files').tr(), contentPadding: EdgeInsets.symmetric(horizontal: 24),
onTap: () { title: Text('files').tr(),
context.goNamed('files'); onTap: () {
}, context.goNamed('files');
), },
),
ListTile( ListTile(
minTileHeight: 48, minTileHeight: 48,
leading: const Icon(Symbols.wallet), leading: const Icon(Symbols.wallet),

View File

@@ -9,6 +9,7 @@ class CaptchaScreen extends ConsumerWidget {
return showModalBottomSheet<String>( return showModalBottomSheet<String>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
isDismissible: false,
builder: (context) => const CaptchaScreen(), builder: (context) => const CaptchaScreen(),
); );
} }

View File

@@ -12,6 +12,7 @@ class CaptchaScreen extends ConsumerStatefulWidget {
static Future<String?> show(BuildContext context) { static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>( return showModalBottomSheet<String>(
context: context, context: context,
isDismissible: false,
isScrollControlled: true, isScrollControlled: true,
builder: (context) => const CaptchaScreen(), builder: (context) => const CaptchaScreen(),
); );

View File

@@ -1,315 +1,22 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:email_validator/email_validator.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.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: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 { class CreateAccountScreen extends HookConsumerWidget {
const CreateAccountScreen({super.key}); const CreateAccountScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 CaptchaScreen.show(context);
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( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: AppBar( appBar: AppBar(
leading: const PageBackButton(), leading: const PageBackButton(),
title: Text('createAccount').tr(), title: Text('createAccount').tr(),
), ),
body: body: CreateAccountContent(),
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()),
),
],
),
),
); );
} }
} }

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:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
import 'package:hooks_riverpod/hooks_riverpod.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:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.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 = { final Map<int, (String, String, IconData)> kFactorTypes = {
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password), 0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
@@ -44,743 +23,13 @@ class LoginScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: AppBar( appBar: AppBar(
leading: const PageBackButton(), leading: const PageBackButton(),
title: Text('login').tr(), title: Text('login').tr(),
), ),
body: Theme( body: LoginContent(),
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 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 {
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),
),
],
); );
} }
} }

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:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/chat/call.dart'; import 'package:island/pods/chat/call.dart';
import 'package:island/talker.dart'; import 'package:island/talker.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/call_button.dart'; import 'package:island/widgets/chat/call_button.dart';
import 'package:island/widgets/chat/call_content.dart';
import 'package:island/widgets/chat/call_overlay.dart'; import 'package:island/widgets/chat/call_overlay.dart';
import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class CallScreen extends HookConsumerWidget { class CallScreen extends HookConsumerWidget {
final String roomId; final SnChatRoom room;
const CallScreen({super.key, required this.roomId}); const CallScreen({super.key, required this.room});
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 callState = ref.watch(callNotifierProvider);
final callNotifier = ref.watch(callNotifierProvider.notifier); final callNotifier = ref.watch(callNotifierProvider.notifier);
useEffect(() { useEffect(() {
talker.info('[Call] Joining the call...'); talker.info('[Call] Joining the call...');
callNotifier.joinRoom(roomId).catchError((_) { callNotifier.joinRoom(room).catchError((_) {
showConfirmAlert( showConfirmAlert(
'Seems there already has a call connected, do you want override it?', 'Seems there already has a call connected, do you want override it?',
'Call already connected', 'Call already connected',
@@ -35,7 +36,7 @@ class CallScreen extends HookConsumerWidget {
talker.info('[Call] Joining the call... with overrides'); talker.info('[Call] Joining the call... with overrides');
callNotifier.disconnect(); callNotifier.disconnect();
callNotifier.dispose(); callNotifier.dispose();
callNotifier.joinRoom(roomId); callNotifier.joinRoom(room);
}); });
}); });
return null; return null;
@@ -110,7 +111,7 @@ class CallScreen extends HookConsumerWidget {
onPressed: () { onPressed: () {
callNotifier.disconnect(); callNotifier.disconnect();
callNotifier.dispose(); callNotifier.dispose();
callNotifier.joinRoom(roomId); callNotifier.joinRoom(room);
}, },
child: Text('retry').tr(), child: Text('retry').tr(),
), ),
@@ -120,72 +121,7 @@ class CallScreen extends HookConsumerWidget {
) )
: Column( : Column(
children: [ children: [
Expanded( Expanded(child: CallContent()),
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),
),
],
);
},
),
),
CallControlsBar(), CallControlsBar(),
Gap(MediaQuery.of(context).padding.bottom + 16), 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:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -6,9 +8,13 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.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/call.dart';
import 'package:island/pods/chat/chat_summary.dart'; import 'package:island/pods/chat/chat_summary.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/realm/realms.dart'; import 'package:island/screens/realm/realms.dart';
import 'package:island/services/event_bus.dart'; import 'package:island/services/event_bus.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
@@ -47,6 +53,17 @@ class ChatRoomListTile extends HookConsumerWidget {
.watch(chatSummaryProvider) .watch(chatSummaryProvider)
.whenData((summaries) => summaries[room.id]); .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() { Widget buildSubtitle() {
if (subtitle != null) return subtitle!; if (subtitle != null) return subtitle!;
@@ -55,7 +72,7 @@ class ChatRoomListTile extends HookConsumerWidget {
if (data == null) { if (data == null) {
return isDirect && room.description == null return isDirect && room.description == null
? Text( ? Text(
room.members!.map((e) => '@${e.account.name}').join(', '), validMembers.map((e) => '@${e.account.name}').join(', '),
maxLines: 1, maxLines: 1,
) )
: Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1); : Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1);
@@ -111,7 +128,7 @@ class ChatRoomListTile extends HookConsumerWidget {
(_, _) => (_, _) =>
isDirect && room.description == null isDirect && room.description == null
? Text( ? Text(
room.members!.map((e) => '@${e.account.name}').join(', '), validMembers.map((e) => '@${e.account.name}').join(', '),
maxLines: 1, maxLines: 1,
) )
: Text( : 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( return ListTile(
leading: Badge( leading: Badge(
isLabelVisible: summary.when( isLabelVisible: summary.when(
@@ -132,7 +160,7 @@ class ChatRoomListTile extends HookConsumerWidget {
(isDirect && room.picture?.id == null) (isDirect && room.picture?.id == null)
? SplitAvatarWidget( ? SplitAvatarWidget(
filesId: filesId:
room.members! validMembers
.map((e) => e.account.profile.picture?.id) .map((e) => e.account.profile.picture?.id)
.toList(), .toList(),
) )
@@ -140,11 +168,7 @@ class ChatRoomListTile extends HookConsumerWidget {
? CircleAvatar(child: Text(room.name![0].toUpperCase())) ? CircleAvatar(child: Text(room.name![0].toUpperCase()))
: ProfilePictureWidget(fileId: room.picture?.id), : ProfilePictureWidget(fileId: room.picture?.id),
), ),
title: Text( title: Text(titleText),
(isDirect && room.name == null)
? room.members!.map((e) => e.account.nick).join(', ')
: room.name ?? '',
),
subtitle: buildSubtitle(), subtitle: buildSubtitle(),
trailing: trailing, // Add this line trailing: trailing, // Add this line
onTap: () async { onTap: () async {
@@ -162,12 +186,92 @@ class ChatRoomListTile extends HookConsumerWidget {
@riverpod @riverpod
Future<List<SnChatRoom>> chatroomsJoined(Ref ref) async { 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 client = ref.watch(apiClientProvider);
final resp = await client.get('/sphere/chat'); final resp = await client.get('/sphere/chat');
return resp.data final rooms =
.map((e) => SnChatRoom.fromJson(e)) resp.data.map((e) => SnChatRoom.fromJson(e)).cast<SnChatRoom>().toList();
.cast<SnChatRoom>() await db.saveChatRooms(rooms);
.toList(); return rooms;
} }
class ChatListBodyWidget extends HookConsumerWidget { class ChatListBodyWidget extends HookConsumerWidget {

View File

@@ -6,7 +6,7 @@ part of 'chat.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$chatroomsJoinedHash() => r'3bb6389af07e81007680484d04bf5fe6f6c10571'; String _$chatroomsJoinedHash() => r'9523efecd1869e7dd26adfc8ec87be48db19ee1c';
/// See also [chatroomsJoined]. /// See also [chatroomsJoined].
@ProviderFor(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.dart';
import 'package:island/services/file_uploader.dart'; import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:island/widgets/content/sheet.dart';
class NewChatScreen extends StatelessWidget { class NewChatScreen extends StatelessWidget {
const NewChatScreen({super.key}); const NewChatScreen({super.key});
@@ -151,12 +151,10 @@ class EditChatScreen extends HookConsumerWidget {
} }
} }
return AppScaffold( return SheetScaffold(
appBar: AppBar( titleText: (id == null ? 'createChatRoom' : 'editChatRoom').tr(),
title: Text(id == null ? 'createChatRoom' : 'editChatRoom').tr(), onClose: () => context.pop(),
leading: const PageBackButton(), child: SingleChildScrollView(
),
body: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [
AspectRatio( AspectRatio(
@@ -204,16 +202,24 @@ class EditChatScreen extends HookConsumerWidget {
children: [ children: [
TextFormField( TextFormField(
controller: nameController, controller: nameController,
decoration: const InputDecoration(labelText: 'Name'), decoration: InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
controller: descriptionController, controller: descriptionController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Description', labelText: 'Description',
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
minLines: 3, minLines: 3,
maxLines: null, maxLines: null,
@@ -223,7 +229,12 @@ class EditChatScreen extends HookConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<SnRealm>( DropdownButtonFormField<SnRealm>(
value: currentRealm.value, value: currentRealm.value,
decoration: InputDecoration(labelText: 'realm'.tr()), decoration: InputDecoration(
labelText: 'realm'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: [ items: [
DropdownMenuItem<SnRealm>( DropdownMenuItem<SnRealm>(
value: null, value: null,

View File

@@ -11,6 +11,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/database/message.dart"; import "package:island/database/message.dart";
import "package:island/models/chat.dart"; import "package:island/models/chat.dart";
import "package:island/models/file.dart"; import "package:island/models/file.dart";
import "package:island/models/poll.dart";
import "package:island/models/wallet.dart";
import "package:island/pods/chat/chat_rooms.dart"; import "package:island/pods/chat/chat_rooms.dart";
import "package:island/pods/chat/chat_subscribe.dart"; import "package:island/pods/chat/chat_subscribe.dart";
import "package:island/pods/chat/messages_notifier.dart"; import "package:island/pods/chat/messages_notifier.dart";
@@ -37,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/chat_link_attachments.dart";
import "package:island/widgets/chat/public_room_preview.dart"; import "package:island/widgets/chat/public_room_preview.dart";
import "package:island/screens/thought/think_sheet.dart"; import "package:island/screens/thought/think_sheet.dart";
import "package:island/screens/chat/widgets/message_item_wrapper.dart";
class ChatRoomScreen extends HookConsumerWidget { class ChatRoomScreen extends HookConsumerWidget {
final String id; final String id;
@@ -142,12 +145,33 @@ class ChatRoomScreen extends HookConsumerWidget {
final messageController = useTextEditingController(); final messageController = useTextEditingController();
final scrollController = useScrollController(); 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 // Scroll animation notifiers
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0)); final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
final messageReplyingTo = useState<SnChatMessage?>(null); final messageReplyingTo = useState<SnChatMessage?>(null);
final messageForwardingTo = useState<SnChatMessage?>(null); final messageForwardingTo = useState<SnChatMessage?>(null);
final messageEditingTo = useState<SnChatMessage?>(null); final messageEditingTo = useState<SnChatMessage?>(null);
final selectedPoll = useState<SnPoll?>(null);
final selectedFund = useState<SnWalletFund?>(null);
final attachments = useState<List<UniversalFile>>([]); final attachments = useState<List<UniversalFile>>([]);
final attachmentProgress = useState<Map<String, Map<int, double?>>>({}); final attachmentProgress = useState<Map<String, Map<int, double?>>>({});
@@ -155,6 +179,38 @@ class ChatRoomScreen extends HookConsumerWidget {
final isSelectionMode = useState<bool>(false); final isSelectionMode = useState<bool>(false);
final selectedMessages = useState<Set<String>>({}); 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 isLoading = false;
var isScrollingToMessage = false; // Flag to prevent scroll conflicts var isScrollingToMessage = false; // Flag to prevent scroll conflicts
@@ -263,11 +319,15 @@ class ChatRoomScreen extends HookConsumerWidget {
void sendMessage() { void sendMessage() {
if (messageController.text.trim().isNotEmpty || if (messageController.text.trim().isNotEmpty ||
attachments.value.isNotEmpty) { attachments.value.isNotEmpty ||
selectedPoll.value != null ||
selectedFund.value != null) {
messagesNotifier.sendMessage( messagesNotifier.sendMessage(
ref, ref,
messageController.text.trim(), messageController.text.trim(),
attachments.value, attachments.value,
poll: selectedPoll.value,
fund: selectedFund.value,
editingTo: messageEditingTo.value, editingTo: messageEditingTo.value,
forwardingTo: messageForwardingTo.value, forwardingTo: messageForwardingTo.value,
replyingTo: messageReplyingTo.value, replyingTo: messageReplyingTo.value,
@@ -282,6 +342,8 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value = null; messageEditingTo.value = null;
messageReplyingTo.value = null; messageReplyingTo.value = null;
messageForwardingTo.value = null; messageForwardingTo.value = null;
selectedPoll.value = null;
selectedFund.value = null;
attachments.value = []; attachments.value = [];
} }
} }
@@ -594,180 +656,67 @@ class ChatRoomScreen extends HookConsumerWidget {
Widget chatMessageListWidget( Widget chatMessageListWidget(
List<LocalChatMessage> messageList, List<LocalChatMessage> messageList,
) => SuperListView.builder( ) => AnimatedPadding(
listController: listController, duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: 16, bottom: MediaQuery.of(context).padding.bottom + 8 + inputHeight.value,
bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for chat input
), ),
controller: scrollController, child: SuperListView.builder(
reverse: true, // Show newest messages at the bottom listController: listController,
itemCount: messageList.length, controller: scrollController,
findChildIndexCallback: (key) { reverse: true, // Show newest messages at the bottom
if (key is! ValueKey<String>) return null; itemCount: messageList.length,
final messageId = key.value.substring(messageKeyPrefix.length); findChildIndexCallback: (key) {
final index = messageList.indexWhere( if (key is! ValueKey<String>) return null;
(m) => (m.nonce ?? m.id) == messageId, final messageId = key.value.substring(messageKeyPrefix.length);
); final index = messageList.indexWhere(
// Return null for invalid indices to let SuperListView handle it properly (m) => (m.nonce ?? m.id) == messageId,
return index >= 0 ? index : null; );
}, return index >= 0 ? index : null;
extentEstimation: (_, _) => 40, },
itemBuilder: (context, index) { extentEstimation: (_, _) => 40,
final message = messageList[index]; itemBuilder: (context, index) {
final nextMessage = final message = messageList[index];
index < messageList.length - 1 ? messageList[index + 1] : null; final nextMessage =
final isLastInGroup = index < messageList.length - 1 ? messageList[index + 1] : null;
nextMessage == null || final isLastInGroup =
nextMessage.senderId != message.senderId || nextMessage == null ||
nextMessage.createdAt nextMessage.senderId != message.senderId ||
.difference(message.createdAt) nextMessage.createdAt
.inMinutes .difference(message.createdAt)
.abs() > .inMinutes
3; .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( return MessageItemWrapper(
skipError: true, key: key,
data: message: message,
(identity) => GestureDetector( index: index,
onLongPress: () { isLastInGroup: isLastInGroup,
if (!isSelectionMode.value) { isSelectionMode: isSelectionMode.value,
toggleSelectionMode(); selectedMessages: selectedMessages.value,
toggleMessageSelection(message.id); chatIdentity: chatIdentity,
} toggleSelectionMode: toggleSelectionMode,
}, toggleMessageSelection: toggleMessageSelection,
onTap: () { onMessageAction: onMessageAction,
if (isSelectionMode.value) { onJump:
toggleMessageSelection(message.id); (messageId) => scrollToMessage(
} messageId: messageId,
}, messageList: messageList,
child: Container( messagesNotifier: messagesNotifier,
color: listController: listController,
selectedMessages.value.contains(message.id) scrollController: scrollController,
? Theme.of( ref: ref,
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,
),
),
),
],
),
), ),
), attachmentProgress: attachmentProgress.value,
loading: disableAnimation: settings.disableAnimation,
() => MessageItem( roomOpenTime: roomOpenTime,
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,
);
},
); );
return AppScaffold( return AppScaffold(
@@ -789,7 +738,11 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
), ),
actions: [ actions: [
AudioCallButton(roomId: id), chatRoom.when(
data: (data) => AudioCallButton(room: data!),
error: (err, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
IconButton( IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
onPressed: () async { onPressed: () async {
@@ -890,7 +843,14 @@ class ChatRoomScreen extends HookConsumerWidget {
left: 0, left: 0,
right: 0, right: 0,
top: 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) if (isSyncing)
Positioned( Positioned(
@@ -964,6 +924,7 @@ class ChatRoomScreen extends HookConsumerWidget {
child: chatRoom.when( child: chatRoom.when(
data: data:
(room) => Column( (room) => Column(
key: inputKey,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ChatInput( ChatInput(
@@ -978,10 +939,16 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value = null; messageEditingTo.value = null;
messageReplyingTo.value = null; messageReplyingTo.value = null;
messageForwardingTo.value = null; messageForwardingTo.value = null;
selectedPoll.value = null;
selectedFund.value = null;
}, },
messageEditingTo: messageEditingTo.value, messageEditingTo: messageEditingTo.value,
messageReplyingTo: messageReplyingTo.value, messageReplyingTo: messageReplyingTo.value,
messageForwardingTo: messageForwardingTo.value, messageForwardingTo: messageForwardingTo.value,
selectedPoll: selectedPoll.value,
onPollSelected: (poll) => selectedPoll.value = poll,
selectedFund: selectedFund.value,
onFundSelected: (fund) => selectedFund.value = fund,
onPickFile: (bool isPhoto) { onPickFile: (bool isPhoto) {
if (isPhoto) { if (isPhoto) {
pickPhotoMedia(); pickPhotoMedia();

View File

@@ -16,6 +16,7 @@ import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/screens/chat/chat_form.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -447,10 +448,17 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
if ((chatIdentity.value?.role ?? 0) >= 50) if ((chatIdentity.value?.role ?? 0) >= 50)
PopupMenuItem( PopupMenuItem(
onTap: () { onTap: () {
context.pushReplacementNamed( showModalBottomSheet(
'chatEdit', context: context,
pathParameters: {'id': id}, 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( child: Row(
children: [ 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

@@ -403,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( ListTile(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
@@ -585,7 +600,7 @@ class CreatorHubScreen extends HookConsumerWidget {
).padding(horizontal: 12), ).padding(horizontal: 12),
buildNavigationWidget(true), buildNavigationWidget(true),
], ],
) ).padding(vertical: 24)
: Column( : Column(
spacing: 12, spacing: 12,
children: [ children: [
@@ -831,7 +846,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
try { try {
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
await apiClient.post( await apiClient.post(
'/publishers/$publisherUname/invites', '/sphere/publishers/invites/$publisherUname',
data: {'related_user_id': result.id, 'role': 0}, data: {'related_user_id': result.id, 'role': 0},
); );
// Refresh both providers // Refresh both providers
@@ -962,7 +977,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
apiClientProvider, apiClientProvider,
); );
await apiClient.delete( await apiClient.delete(
'/publishers/$publisherUname/members/${member.accountId}', '/sphere/publishers/$publisherUname/members/${member.accountId}',
); );
// Refresh both providers // Refresh both providers
memberNotifier.reset(); memberNotifier.reset();
@@ -1087,7 +1102,7 @@ class _PublisherMemberRoleSheet extends HookConsumerWidget {
final apiClient = ref.read(apiClientProvider); final apiClient = ref.read(apiClientProvider);
await apiClient.patch( await apiClient.patch(
'/publishers/$publisherUname/members/${member.accountId}/role', '/sphere/publishers/$publisherUname/members/${member.accountId}/role',
data: newRole, data: newRole,
); );
@@ -1119,7 +1134,7 @@ class _PublisherInviteSheet extends HookConsumerWidget {
try { try {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
await client.post( await client.post(
'/publishers/invites/${invite.publisher!.name}/accept', '/sphere/publishers/invites/${invite.publisher!.name}/accept',
); );
ref.invalidate(publisherInvitesProvider); ref.invalidate(publisherInvitesProvider);
ref.invalidate(publishersManagedProvider); ref.invalidate(publishersManagedProvider);
@@ -1132,7 +1147,7 @@ class _PublisherInviteSheet extends HookConsumerWidget {
try { try {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
await client.post( await client.post(
'/publishers/invites/${invite.publisher!.name}/decline', '/sphere/publishers/invites/${invite.publisher!.name}/decline',
); );
ref.invalidate(publisherInvitesProvider); ref.invalidate(publisherInvitesProvider);
} catch (err) { } catch (err) {

View File

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

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

@@ -11,8 +11,10 @@ import 'package:island/models/realm.dart';
import 'package:island/models/webfeed.dart'; import 'package:island/models/webfeed.dart';
import 'package:island/pods/event_calendar.dart'; import 'package:island/pods/event_calendar.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/auth/login_modal.dart';
import 'package:island/screens/notification.dart'; import 'package:island/screens/notification.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/friends_overview.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/widgets/check_in.dart'; import 'package:island/widgets/check_in.dart';
@@ -340,6 +342,7 @@ class ExploreScreen extends HookConsumerWidget {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
), ),
PostFeaturedList(), PostFeaturedList(),
FriendsOverviewWidget(),
], ],
), ),
), ),
@@ -348,21 +351,39 @@ class ExploreScreen extends HookConsumerWidget {
else else
Flexible( Flexible(
flex: 2, flex: 2,
child: Column( child:
mainAxisAlignment: MainAxisAlignment.center, Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center,
children: [ crossAxisAlignment: CrossAxisAlignment.center,
Text( children: [
'Welcome to\nthe Solar Network', const Icon(Symbols.emoji_people_rounded, size: 40),
style: Theme.of(context).textTheme.titleLarge, const Gap(8),
).bold(), Text(
const Gap(2), 'Welcome to\nthe Solar Network',
Text( style: Theme.of(context).textTheme.titleLarge,
'Login to explore more!', textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge, ).bold(),
), const Gap(2),
], Text(
).padding(horizontal: 36, vertical: 16), '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); ).padding(horizontal: 12);
@@ -521,6 +542,12 @@ class ExploreScreen extends HookConsumerWidget {
child: PostFeaturedList(), child: PostFeaturedList(),
), ),
), ),
SliverToBoxAdapter(
child: FriendsOverviewWidget(
padding: const EdgeInsets.only(bottom: 8),
hideWhenEmpty: true,
),
),
if (notificationCount.value != null && if (notificationCount.value != null &&
notificationCount.value! > 0) notificationCount.value! > 0)
SliverToBoxAdapter( SliverToBoxAdapter(

View File

@@ -1,31 +1,29 @@
import 'dart:io'; import 'dart:io';
import 'dart:math' as math;
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_saver/file_saver.dart'; import 'package:file_saver/file_saver.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gal/gal.dart'; import 'package:gal/gal.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/file_references.dart';
import 'package:island/pods/network.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/responsive.dart';
import 'package:island/utils/format.dart'; import 'package:island/services/time.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/audio.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/file_info_sheet.dart'; import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:island/widgets/content/video.dart'; import 'package:island/widgets/content/file_viewer_contents.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:path/path.dart' show extension; import 'package:path/path.dart' show extension;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:photo_view/photo_view.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
class FileDetailScreen extends HookConsumerWidget { class FileDetailScreen extends HookConsumerWidget {
final SnCloudFile item; final SnCloudFile item;
@@ -85,7 +83,7 @@ class FileDetailScreen extends HookConsumerWidget {
}, [animationController]); }, [animationController]);
return AppScaffold( return AppScaffold(
isNoBackground: true, isNoBackground: false,
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
leading: IconButton( leading: IconButton(
@@ -95,26 +93,47 @@ class FileDetailScreen extends HookConsumerWidget {
title: Text(item.name.isEmpty ? 'File Details' : item.name), title: Text(item.name.isEmpty ? 'File Details' : item.name),
actions: _buildAppBarActions(context, ref, showInfoSheet), actions: _buildAppBarActions(context, ref, showInfoSheet),
), ),
body: AnimatedBuilder( body: LayoutBuilder(
animation: animation, builder: (context, constraints) {
builder: (context, child) { return AnimatedBuilder(
return Row( animation: animation,
children: [ builder: (context, child) {
// Main content area return Stack(
Expanded(child: _buildContent(context, ref, serverUrl)), children: [
// Animated drawer panel // Main content area - resizes with animation
if (isWide) Positioned(
SizedBox( left: 0,
height: double.infinity, top: 0,
width: animation.value * 400, // Max width of 400px bottom: 0,
child: Container( width: constraints.maxWidth - animation.value * 400,
child: child: _buildContent(context, ref, serverUrl),
animation.value > 0.1
? FileInfoSheet(item: item, onClose: showInfoSheet)
: const SizedBox.shrink(),
), ),
), // 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,
),
),
),
),
),
],
);
},
); );
}, },
), ),
@@ -153,6 +172,24 @@ class FileDetailScreen extends HookConsumerWidget {
break; 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 // Always add info button
actions.add( actions.add(
IconButton(icon: Icon(Icons.info_outline), onPressed: showInfoSheet), IconButton(icon: Icon(Icons.info_outline), onPressed: showInfoSheet),
@@ -196,6 +233,8 @@ class FileDetailScreen extends HookConsumerWidget {
} }
Future<void> _downloadFile(WidgetRef ref) async { Future<void> _downloadFile(WidgetRef ref) async {
final taskNotifier = ref.read(uploadTasksProvider.notifier);
final taskId = taskNotifier.addLocalDownloadTask(item);
try { try {
showSnackBar('Downloading file...'); showSnackBar('Downloading file...');
@@ -211,14 +250,26 @@ class FileDetailScreen extends HookConsumerWidget {
'/drive/files/${item.id}', '/drive/files/${item.id}',
filePath, filePath,
queryParameters: {'original': true}, queryParameters: {'original': true},
onReceiveProgress: (count, total) {
if (total > 0) {
taskNotifier.updateDownloadProgress(taskId, count, total);
taskNotifier.updateTransmissionProgress(taskId, count / total);
}
},
); );
await FileSaver.instance.saveFile( await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name, name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath), file: File(filePath),
); );
taskNotifier.updateTaskStatus(taskId, DriveTaskStatus.completed);
showSnackBar('File saved to downloads'); showSnackBar('File saved to downloads');
} catch (e) { } catch (e) {
taskNotifier.updateTaskStatus(
taskId,
DriveTaskStatus.failed,
errorMessage: e.toString(),
);
showErrorAlert(e); showErrorAlert(e);
} }
} }
@@ -227,312 +278,65 @@ class FileDetailScreen extends HookConsumerWidget {
final uri = '$serverUrl/drive/files/${item.id}'; final uri = '$serverUrl/drive/files/${item.id}';
return switch (item.mimeType?.split('/').firstOrNull) { return switch (item.mimeType?.split('/').firstOrNull) {
'image' => _buildImageContent(context, ref, uri), 'image' => ImageFileContent(item: item, uri: uri),
'video' => _buildVideoContent(context, ref, uri), 'video' => VideoFileContent(item: item, uri: uri),
'audio' => _buildAudioContent(context, ref, uri), 'audio' => AudioFileContent(item: item, uri: uri),
_ when item.mimeType == 'application/pdf' => _PdfContent(uri: uri), _ when item.mimeType == 'application/pdf' => PdfFileContent(uri: uri),
_ when item.mimeType?.startsWith('text/') == true => _TextContent( _ when item.mimeType?.startsWith('text/') == true => TextFileContent(
uri: uri, uri: uri,
), ),
_ => _buildGenericContent(context, ref), _ => GenericFileContent(item: item),
}; };
} }
Widget _buildImageContent(BuildContext context, WidgetRef ref, String uri) {
final photoViewController = useMemoized(() => PhotoViewController(), []);
final rotation = useState(0);
final showOriginal = useState(false);
final shadow = [
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
];
return DismissiblePage(
isFullScreen: true,
backgroundColor: Colors.transparent,
direction: DismissiblePageDismissDirection.down,
onDismissed: () {
Navigator.of(context).pop();
},
child: Stack(
children: [
Positioned.fill(
child: PhotoView(
backgroundDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
),
controller: photoViewController,
imageProvider: CloudImageWidget.provider(
fileId: item.id,
serverUrl: ref.watch(serverUrlProvider),
original: showOriginal.value,
),
customSize: MediaQuery.of(context).size,
basePosition: Alignment.center,
filterQuality: FilterQuality.high,
),
),
// Controls overlay
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
children: [
IconButton(
icon: Icon(
Icons.remove,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
photoViewController.scale =
(photoViewController.scale ?? 1) - 0.05;
},
),
IconButton(
icon: Icon(Icons.add, color: Colors.white, shadows: shadow),
onPressed: () {
photoViewController.scale =
(photoViewController.scale ?? 1) + 0.05;
},
),
const Gap(8),
IconButton(
icon: Icon(
Icons.rotate_left,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
rotation.value = (rotation.value - 1) % 4;
photoViewController.rotation =
rotation.value * -math.pi / 2;
},
),
IconButton(
icon: Icon(
Icons.rotate_right,
color: Colors.white,
shadows: shadow,
),
onPressed: () {
rotation.value = (rotation.value + 1) % 4;
photoViewController.rotation =
rotation.value * -math.pi / 2;
},
),
const Spacer(),
IconButton(
onPressed: () {
showOriginal.value = !showOriginal.value;
},
icon: Icon(
showOriginal.value ? Symbols.hd : Symbols.sd,
color: Colors.white,
shadows: shadow,
),
),
],
),
),
],
),
);
}
Widget _buildVideoContent(BuildContext context, WidgetRef ref, String uri) {
var ratio =
item.fileMeta?['ratio'] is num
? item.fileMeta!['ratio'].toDouble()
: 1.0;
if (ratio == 0) ratio = 1.0;
return DismissiblePage(
isFullScreen: true,
backgroundColor: Colors.black,
direction: DismissiblePageDismissDirection.down,
onDismissed: () {
Navigator.of(context).pop();
},
child: Center(
child: AspectRatio(
aspectRatio: ratio,
child: UniversalVideo(uri: uri, autoplay: true),
),
),
);
}
Widget _buildAudioContent(BuildContext context, WidgetRef ref, String uri) {
return DismissiblePage(
isFullScreen: true,
backgroundColor: Theme.of(context).colorScheme.surface,
direction: DismissiblePageDismissDirection.down,
onDismissed: () {
Navigator.of(context).pop();
},
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
),
child: UniversalAudio(uri: uri, filename: item.name),
),
),
);
}
Widget _buildGenericContent(BuildContext context, WidgetRef ref) {
Future<void> downloadFile() async {
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},
);
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
showSnackBar('File saved to downloads');
} catch (e) {
showErrorAlert(e);
}
}
return DismissiblePage(
isFullScreen: true,
backgroundColor: Theme.of(context).colorScheme.surface,
direction: DismissiblePageDismissDirection.down,
onDismissed: () {
Navigator.of(context).pop();
},
child: Center(
child: Container(
margin: const EdgeInsets.all(32),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.insert_drive_file,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const Gap(16),
Text(
item.name,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
formatFileSize(item.size),
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const Gap(24),
Row(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
onPressed: downloadFile,
icon: const Icon(Symbols.download),
label: Text('download').tr(),
),
const Gap(16),
OutlinedButton.icon(
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
},
icon: const Icon(Symbols.info),
label: Text('info').tr(),
),
],
),
],
),
),
),
);
}
} }
class _PdfContent extends HookConsumerWidget { class ReferencesList extends ConsumerWidget {
final String uri; const ReferencesList({super.key, required this.fileId});
const _PdfContent({required this.uri}); final String fileId;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final pdfViewer = useMemoized(() => SfPdfViewer.network(uri), [uri]); final asyncReferences = ref.watch(fileReferencesProvider(fileId));
return pdfViewer;
}
}
class _TextContent extends HookConsumerWidget { return asyncReferences.when(
final String uri; data:
(references) => ListView.builder(
const _TextContent({required this.uri}); itemCount: references.length,
itemBuilder: (context, index) {
@override final reference = references[index];
Widget build(BuildContext context, WidgetRef ref) { return ListTile(
final textFuture = useMemoized( leading: const Icon(Icons.link),
() => ref title: Row(
.read(apiClientProvider) spacing: 6,
.get(uri) children: [
.then((response) => response.data as String), Text(
[uri], reference.usage,
); style: GoogleFonts.robotoMono(
fontWeight: FontWeight.bold,
return FutureBuilder<String>( fontSize: 13,
future: textFuture, ),
builder: (context, snapshot) { ),
if (snapshot.connectionState == ConnectionState.waiting) { Text(
return const Center(child: CircularProgressIndicator()); reference.id,
} else if (snapshot.hasError) { style: GoogleFonts.robotoMono(fontSize: 13),
return Center(child: Text('Error loading text: ${snapshot.error}')); ),
} else if (snapshot.hasData) { ],
return SingleChildScrollView( ),
padding: EdgeInsets.all(20), subtitle: Row(
child: SelectableText( spacing: 8,
snapshot.data!, children: [
style: const TextStyle(fontFamily: 'monospace', fontSize: 14), Text(reference.createdAt.formatRelative(context)),
), const VerticalDivider(width: 1, thickness: 1).height(12),
); Text(reference.createdAt.formatSystem()),
} ],
return const Center(child: Text('No content')); ),
}, );
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => Center(child: Text('Error loading references: $error')),
); );
} }
} }

View File

@@ -1,10 +1,12 @@
import 'package:cross_file/cross_file.dart'; import 'package:cross_file/cross_file.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/file_pool.dart';
import 'package:island/pods/file_list.dart'; import 'package:island/pods/file_list.dart';
import 'package:island/services/file_uploader.dart'; import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
@@ -13,6 +15,7 @@ import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/file_list_view.dart'; import 'package:island/widgets/file_list_view.dart';
import 'package:island/widgets/usage_overview.dart'; import 'package:island/widgets/usage_overview.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class FileListScreen extends HookConsumerWidget { class FileListScreen extends HookConsumerWidget {
const FileListScreen({super.key}); const FileListScreen({super.key});
@@ -22,14 +25,17 @@ class FileListScreen extends HookConsumerWidget {
// Path navigation state // Path navigation state
final currentPath = useState<String>('/'); final currentPath = useState<String>('/');
final mode = useState<FileListMode>(FileListMode.normal); final mode = useState<FileListMode>(FileListMode.normal);
final selectedPool = useState<SnFilePool?>(null);
final usageAsync = ref.watch(billingUsageProvider); final usageAsync = ref.watch(billingUsageProvider);
final quotaAsync = ref.watch(billingQuotaProvider); final quotaAsync = ref.watch(billingQuotaProvider);
final viewMode = useState(FileListViewMode.list);
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: AppBar( appBar: AppBar(
title: Text('Files'), title: Text('files').tr(),
leading: const PageBackButton(), leading: const PageBackButton(),
actions: [ actions: [
IconButton( IconButton(
@@ -52,10 +58,16 @@ class FileListScreen extends HookConsumerWidget {
usage: usage, usage: usage,
quota: quota, quota: quota,
currentPath: currentPath, currentPath: currentPath,
selectedPool: selectedPool,
onPickAndUpload: onPickAndUpload:
() => _pickAndUploadFile(ref, currentPath.value), () => _pickAndUploadFile(
ref,
currentPath.value,
selectedPool.value?.id,
),
onShowCreateDirectory: _showCreateDirectoryDialog, onShowCreateDirectory: _showCreateDirectoryDialog,
mode: mode, mode: mode,
viewMode: viewMode,
), ),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading quota')), error: (e, _) => Center(child: Text('Error loading quota')),
@@ -66,7 +78,11 @@ class FileListScreen extends HookConsumerWidget {
); );
} }
Future<void> _pickAndUploadFile(WidgetRef ref, String currentPath) async { Future<void> _pickAndUploadFile(
WidgetRef ref,
String currentPath,
String? poolId,
) async {
try { try {
final result = await FilePicker.platform.pickFiles( final result = await FilePicker.platform.pickFiles(
allowMultiple: true, allowMultiple: true,
@@ -88,6 +104,7 @@ class FileListScreen extends HookConsumerWidget {
fileData: universalFile, fileData: universalFile,
ref: ref, ref: ref,
path: currentPath, path: currentPath,
poolId: poolId,
onProgress: (progress, _) { onProgress: (progress, _) {
// Progress is handled by the upload tasks system // Progress is handled by the upload tasks system
if (progress != null) { if (progress != null) {
@@ -193,7 +210,10 @@ class FileListScreen extends HookConsumerWidget {
builder: builder:
(context) => SheetScaffold( (context) => SheetScaffold(
titleText: 'Usage Overview', titleText: 'Usage Overview',
child: UsageOverviewWidget(usage: usage, quota: quota), 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), const Gap(4),
Text( Text(
'The last selected number will be your special number.', 'lotteryLastNumberSpecial'.tr(),
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.secondary, color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -738,11 +738,11 @@ class _LotteryPurchaseSheetState extends State<LotteryPurchaseSheet> {
}, },
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return 'Please enter a multiplier'; return 'lotteryMultiplierRequired'.tr();
} }
final parsed = int.tryParse(value); final parsed = int.tryParse(value);
if (parsed == null || parsed < 1 || parsed > 10) { if (parsed == null || parsed < 1 || parsed > 10) {
return 'Multiplier must be between 1 and 10'; return 'lotteryMultiplierRange'.tr();
} }
return null; return null;
}, },

View File

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

View File

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

View File

@@ -73,10 +73,33 @@ class _PublisherBasisWidget extends StatelessWidget {
Positioned( Positioned(
bottom: -24, bottom: -24,
left: 16, left: 16,
child: ProfilePictureWidget( child: GestureDetector(
file: data.picture, child: Badge(
radius: 32, isLabelVisible: data.type == 0,
borderRadius: data.type == 0 ? null : 12, 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

@@ -24,6 +24,7 @@ const kTabRoutes = [
'/realms', '/realms',
'/account', '/account',
'/files', '/files',
'/thought',
'/creators', '/creators',
'/developers', '/developers',
]; ];
@@ -90,6 +91,10 @@ class TabsScreen extends HookConsumerWidget {
label: 'files'.tr(), label: 'files'.tr(),
icon: const Icon(Symbols.folder_rounded), icon: const Icon(Symbols.folder_rounded),
), ),
NavigationDestination(
label: 'aiThought'.tr(),
icon: const Icon(Symbols.bubble_chart),
),
NavigationDestination( NavigationDestination(
label: 'creatorHub'.tr(), label: 'creatorHub'.tr(),
icon: const Icon(Symbols.design_services_rounded), icon: const Icon(Symbols.design_services_rounded),

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:easy_localization/easy_localization.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
@@ -9,18 +6,22 @@ import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/models/thought.dart"; import "package:island/models/thought.dart";
import "package:island/pods/network.dart"; import "package:island/pods/network.dart";
import "package:island/pods/userinfo.dart";
import "package:island/widgets/alert.dart"; import "package:island/widgets/alert.dart";
import "package:island/widgets/app_scaffold.dart"; import "package:island/widgets/app_scaffold.dart";
import "package:island/widgets/response.dart"; import "package:island/widgets/response.dart";
import "package:island/widgets/thought/thought_sequence_list.dart"; import "package:island/widgets/thought/thought_sequence_list.dart";
import "package:island/widgets/thought/thought_shared.dart"; import "package:island/widgets/thought/thought_shared.dart";
import "package:material_symbols_icons/material_symbols_icons.dart"; import "package:material_symbols_icons/material_symbols_icons.dart";
import "package:super_sliver_list/super_sliver_list.dart";
import "package:collection/collection.dart";
part 'think.g.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 @riverpod
Future<List<SnThinkingThought>> thoughtSequence( Future<List<SnThinkingThought>> thoughtSequence(
Ref ref, Ref ref,
@@ -35,6 +36,13 @@ Future<List<SnThinkingThought>> thoughtSequence(
.toList(); .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 { class ThoughtScreen extends HookConsumerWidget {
const ThoughtScreen({super.key}); const ThoughtScreen({super.key});
@@ -46,203 +54,32 @@ class ThoughtScreen extends HookConsumerWidget {
? ref.watch(thoughtSequenceProvider(selectedSequenceId.value!)) ? ref.watch(thoughtSequenceProvider(selectedSequenceId.value!))
: const AsyncValue<List<SnThinkingThought>>.data([]); : const AsyncValue<List<SnThinkingThought>>.data([]);
final localThoughts = useState<List<SnThinkingThought>>([]); // Extract sequence ID from loaded thoughts for the chat interface
final currentTopic = useState<String?>('aiThought'.tr()); final sequenceIdFromThoughts = thoughts.maybeWhen(
data: (thoughts) {
final messageController = useTextEditingController(); if (thoughts.isNotEmpty && thoughts.first.sequenceId.isNotEmpty) {
final scrollController = useScrollController(); return thoughts.first.sequenceId;
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();
} }
}); return null;
return null; },
}, [thoughts]); orElse: () => null,
);
// Scroll to bottom when thoughts change or streaming state changes // Get initial thoughts and topic from provider
useEffect(() { final initialThoughts = thoughts.valueOrNull;
if (localThoughts.value.isNotEmpty || isStreaming.value) { final initialTopic =
WidgetsBinding.instance.addPostFrameCallback((_) { (initialThoughts?.isNotEmpty ?? false) &&
scrollController.animateTo( initialThoughts!.first.sequence?.topic != null
0, ? initialThoughts.first.sequence!.topic
duration: const Duration(milliseconds: 300), : 'aiThought'.tr();
curve: Curves.easeOut,
);
});
}
return null;
}, [localThoughts.value.length, isStreaming.value]);
// Add scroll listener for gradient animations final statusAsync = ref.watch(thoughtAvailableStausProvider);
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);
}
}
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: AppBar( appBar: AppBar(
title: Text(currentTopic.value ?? 'aiThought'.tr()), title: Text(initialTopic ?? 'aiThought'.tr()),
leading: const PageBackButton(),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.history), 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), const Gap(8),
], ],
), ),
body: Stack( body: statusAsync.maybeWhen(
children: [ data: (status) {
// Thoughts list final retry = useMemoized(
Center( () => () async {
child: Container( showLoadingModal(context);
constraints: BoxConstraints(maxWidth: 640), try {
child: Column( 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: [ children: [
Expanded( MaterialBanner(
child: thoughts.when( leading: const Icon(Symbols.error),
data: content: const Text(
(thoughtList) => SuperListView.builder( 'You have unpaid orders. Please settle your payment to continue using the service.',
listController: listController, style: TextStyle(fontWeight: FontWeight.bold),
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,
),
), ),
actions: [
TextButton(
onPressed: () {
retry();
},
child: Text('retry'.tr()),
),
],
), ),
Expanded(child: thoughtsBody),
], ],
), );
), },
), orElse:
// Bottom gradient - appears when scrolling towards newer thoughts (behind thought input) () => thoughts.when(
AnimatedBuilder( data:
animation: bottomGradientNotifier.value, (thoughtList) => ThoughtChatInterface(
builder: initialThoughts: thoughtList,
(context, child) => Positioned( initialTopic: initialTopic,
left: 0, ),
right: 0, loading: () => const Center(child: CircularProgressIndicator()),
bottom: 0, error:
child: Opacity( (error, _) => ResponseErrorWidget(
opacity: bottomGradientNotifier.value.value, error: error,
child: Container( onRetry:
height: math.min( () =>
MediaQuery.of(context).size.height * 0.1, selectedSequenceId.value != null
128, ? ref.invalidate(
), thoughtSequenceProvider(
decoration: BoxDecoration( selectedSequenceId.value!,
gradient: LinearGradient( ),
begin: Alignment.bottomCenter, )
end: Alignment.topCenter, : null,
colors: [
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.8),
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.0),
],
),
),
),
), ),
),
),
// 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,
),
),
), ),
),
],
), ),
); );
} }

View File

@@ -6,6 +6,25 @@ part of 'think.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$thoughtAvailableStausHash() =>
r'720e04e56bff8c4d4ca6854ce997da4e7926c84c';
/// See also [thoughtAvailableStaus].
@ProviderFor(thoughtAvailableStaus)
final thoughtAvailableStausProvider = AutoDisposeFutureProvider<bool>.internal(
thoughtAvailableStaus,
name: r'thoughtAvailableStausProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$thoughtAvailableStausHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ThoughtAvailableStausRef = AutoDisposeFutureProviderRef<bool>;
String _$thoughtSequenceHash() => r'2a93c0a04f9a720ba474c02a36502940fb7f3ed7'; String _$thoughtSequenceHash() => r'2a93c0a04f9a720ba474c02a36502940fb7f3ed7';
/// Copied from Dart SDK /// Copied from Dart SDK
@@ -152,5 +171,25 @@ class _ThoughtSequenceProviderElement
String get sequenceId => (origin as ThoughtSequenceProvider).sequenceId; String get sequenceId => (origin as ThoughtSequenceProvider).sequenceId;
} }
String _$thoughtServicesHash() => r'0ddeaec713ecfcdc9786c197f3d4cb41d36c26a5';
/// See also [thoughtServices].
@ProviderFor(thoughtServices)
final thoughtServicesProvider =
AutoDisposeFutureProvider<ThoughtServicesResponse>.internal(
thoughtServices,
name: r'thoughtServicesProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$thoughtServicesHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ThoughtServicesRef =
AutoDisposeFutureProviderRef<ThoughtServicesResponse>;
// ignore_for_file: type=lint // ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,17 +1,13 @@
import "dart:convert";
import "dart:math" as math;
import "package:dio/dio.dart";
import "package:easy_localization/easy_localization.dart"; import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/models/thought.dart";
import "package:island/pods/network.dart"; import "package:island/pods/network.dart";
import "package:island/pods/userinfo.dart"; import "package:island/screens/thought/think.dart";
import "package:island/widgets/alert.dart"; import "package:island/widgets/alert.dart";
import "package:island/widgets/content/sheet.dart"; import "package:island/widgets/content/sheet.dart";
import "package:island/widgets/thought/thought_shared.dart"; import "package:island/widgets/thought/thought_shared.dart";
import "package:super_sliver_list/super_sliver_list.dart"; import "package:material_symbols_icons/material_symbols_icons.dart";
class ThoughtSheet extends HookConsumerWidget { class ThoughtSheet extends HookConsumerWidget {
final List<Map<String, dynamic>> attachedMessages; final List<Map<String, dynamic>> attachedMessages;
@@ -42,275 +38,68 @@ class ThoughtSheet extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final sequenceId = useState<String?>(null); final chatState = useThoughtChat(
final localThoughts = useState<List<SnThinkingThought>>([]); ref,
final currentTopic = useState<String?>('aiThought'.tr()); attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
);
final messageController = useTextEditingController(); final statusAsync = ref.watch(thoughtAvailableStausProvider);
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));
// 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]);
// 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: sequenceId.value ?? '',
createdAt: now,
updatedAt: now,
sequence: SnThinkingSequence(
id: sequenceId.value ?? '',
accountId: userInfo.value!.id,
createdAt: now,
updatedAt: now,
),
);
localThoughts.value = [userThought, ...localThoughts.value];
final request = StreamThinkingRequest(
userMessage: userMessage,
sequenceId: sequenceId.value,
accpetProposals: ['post_create'],
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
);
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 (sequenceId.value == null &&
aiThought.sequenceId.isNotEmpty) {
sequenceId.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);
}
}
return SheetScaffold( return SheetScaffold(
titleText: currentTopic.value ?? 'aiThought'.tr(), titleText: chatState.currentTopic.value ?? 'aiThought'.tr(),
child: Stack( child: statusAsync.maybeWhen(
children: [ data: (status) {
// Thoughts list final retry = useMemoized(
Center( () => () async {
child: Container( showLoadingModal(context);
constraints: BoxConstraints(maxWidth: 640), try {
child: Column( 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 chatInterface = ThoughtChatInterface(
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
isDisabled: !status,
);
return status
? chatInterface
: Column(
children: [ children: [
Expanded( MaterialBanner(
child: SuperListView.builder( leading: const Icon(Symbols.error),
listController: listController, content: const Text(
controller: scrollController, 'You have unpaid orders. Please settle your payment to continue using the service.',
padding: EdgeInsets.only( style: TextStyle(fontWeight: FontWeight.bold),
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,
);
},
), ),
actions: [
TextButton(
onPressed: () {
retry();
},
child: Text('retry'.tr()),
),
],
), ),
Expanded(child: chatInterface),
], ],
), );
},
orElse:
() => ThoughtChatInterface(
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
), ),
),
// 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),
],
),
),
),
),
),
),
// 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,
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
),
),
),
),
],
), ),
); );
} }

View File

@@ -55,6 +55,7 @@ class CreateFundSheet extends StatefulWidget {
class _CreateFundSheetState extends State<CreateFundSheet> { class _CreateFundSheetState extends State<CreateFundSheet> {
final amountController = TextEditingController(); final amountController = TextEditingController();
final splitsController = TextEditingController(text: '1');
final messageController = TextEditingController(); final messageController = TextEditingController();
String selectedCurrency = 'golds'; String selectedCurrency = 'golds';
int selectedSplitType = 0; // 0: even, 1: random int selectedSplitType = 0; // 0: even, 1: random
@@ -64,6 +65,7 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
void dispose() { void dispose() {
amountController.dispose(); amountController.dispose();
messageController.dispose(); messageController.dispose();
splitsController.dispose();
super.dispose(); super.dispose();
} }
@@ -103,17 +105,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
labelText: 'enterAmount'.tr(), labelText: 'enterAmount'.tr(),
hintText: '0.00', hintText: '0.00',
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]), prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
border: OutlineInputBorder(), border: OutlineInputBorder(
enabledBorder: OutlineInputBorder( borderRadius: const BorderRadius.all(
borderSide: BorderSide( Radius.circular(12),
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
), ),
), ),
), ),
@@ -136,17 +130,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: selectedCurrency, value: selectedCurrency,
decoration: InputDecoration( decoration: InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(
enabledBorder: OutlineInputBorder( borderRadius: const BorderRadius.all(
borderSide: BorderSide( Radius.circular(12),
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
), ),
), ),
), ),
@@ -173,49 +159,84 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
}, },
), ),
// Split Type Section (only show when there are 2+ recipients) const Gap(16),
if (selectedRecipients.length >= 2) ...[
const Gap(16), // Amount of Splits Section
Text( Text(
'splitType'.tr(), 'amountOfSplits'.tr(),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
),
),
const Gap(8),
TextField(
controller: splitsController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
labelText: 'enterNumberOfSplits'.tr(),
hintText:
selectedRecipients.isNotEmpty
? selectedRecipients.length.toString()
: '1',
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
), ),
), ),
const Gap(8), onTapOutside:
Row( (_) => FocusManager.instance.primaryFocus?.unfocus(),
children: [ onChanged: (value) {
Expanded( if (value.isEmpty && selectedRecipients.isNotEmpty) {
child: RadioListTile<int>( splitsController.text =
title: Text('evenSplit'.tr()), selectedRecipients.length.toString();
subtitle: Text('equalAmountEach'.tr()), }
value: 0, },
groupValue: selectedSplitType, ),
onChanged: (value) {
if (value != null) { const Gap(16),
setState(() => selectedSplitType = value); Text(
} 'splitType'.tr(),
}, style: TextStyle(
), fontSize: 16,
), fontWeight: FontWeight.w600,
Expanded( color: Theme.of(context).colorScheme.primary,
child: RadioListTile<int>(
title: Text('randomSplit'.tr()),
subtitle: Text('randomAmountEach'.tr()),
value: 1,
groupValue: selectedSplitType,
onChanged: (value) {
if (value != null) {
setState(() => selectedSplitType = value);
}
},
),
),
],
), ),
], ),
const Gap(8),
Row(
children: [
Expanded(
child: RadioListTile<int>(
title: Text('evenSplit'.tr()),
subtitle: Text('equalAmountEach'.tr()),
value: 0,
groupValue: selectedSplitType,
onChanged: (value) {
if (value != null) {
setState(() => selectedSplitType = value);
}
},
),
),
Expanded(
child: RadioListTile<int>(
title: Text('randomSplit'.tr()),
subtitle: Text('randomAmountEach'.tr()),
value: 1,
groupValue: selectedSplitType,
onChanged: (value) {
if (value != null) {
setState(() => selectedSplitType = value);
}
},
),
),
],
),
const Gap(16), const Gap(16),
@@ -370,17 +391,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
labelText: 'personalMessage'.tr(), labelText: 'personalMessage'.tr(),
hintText: 'addPersonalMessageForRecipients'.tr(), hintText: 'addPersonalMessageForRecipients'.tr(),
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder(), border: OutlineInputBorder(
enabledBorder: OutlineInputBorder( borderRadius: const BorderRadius.all(
borderSide: BorderSide( Radius.circular(12),
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
), ),
), ),
), ),
@@ -520,14 +533,15 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
Future<void> _createFund() async { Future<void> _createFund() async {
final amount = double.tryParse(amountController.text); final amount = double.tryParse(amountController.text);
final splits = int.tryParse(splitsController.text);
if (amount == null || amount <= 0) { if (amount == null || amount <= 0) {
showErrorAlert('invalidAmount'.tr()); showErrorAlert('invalidAmount'.tr());
return; return;
} }
if (selectedRecipients.isEmpty) { if (splits == null || splits <= 0) {
showErrorAlert('noRecipientsSelected'.tr()); showErrorAlert('invalidNumberOfSplits'.tr());
return; return;
} }
@@ -535,6 +549,7 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
'currency': selectedCurrency, 'currency': selectedCurrency,
'total_amount': amount, 'total_amount': amount,
'split_type': selectedSplitType, 'split_type': selectedSplitType,
'amount_of_splits': splits,
'recipient_account_ids': selectedRecipients.map((r) => r.id).toList(), 'recipient_account_ids': selectedRecipients.map((r) => r.id).toList(),
'message': 'message':
messageController.text.trim().isEmpty messageController.text.trim().isEmpty
@@ -610,17 +625,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
labelText: 'enterAmount'.tr(), labelText: 'enterAmount'.tr(),
hintText: '0.00', hintText: '0.00',
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]), prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
border: OutlineInputBorder(), border: OutlineInputBorder(
enabledBorder: OutlineInputBorder( borderRadius: const BorderRadius.all(
borderSide: BorderSide( Radius.circular(12),
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
), ),
), ),
), ),
@@ -643,17 +650,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: selectedCurrency, value: selectedCurrency,
decoration: InputDecoration( decoration: InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(
enabledBorder: OutlineInputBorder( borderRadius: const BorderRadius.all(
borderSide: BorderSide( Radius.circular(12),
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
), ),
), ),
), ),
@@ -817,17 +816,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
labelText: 'transferRemark'.tr(), labelText: 'transferRemark'.tr(),
hintText: 'addRemarkForTransfer'.tr(), hintText: 'addRemarkForTransfer'.tr(),
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder(), border: OutlineInputBorder(
enabledBorder: OutlineInputBorder( borderRadius: const BorderRadius.all(
borderSide: BorderSide( Radius.circular(12),
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
), ),
), ),
), ),
@@ -1863,6 +1854,6 @@ class WalletScreen extends HookConsumerWidget {
} }
const Map<String, IconData> kCurrencyIconData = { const Map<String, IconData> kCurrencyIconData = {
'points': Symbols.toll, 'points': Symbols.bolt,
'golds': Symbols.attach_money, 'golds': Symbols.diamond,
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -16,3 +16,10 @@ class PostCreatedEvent {
class ChatRoomsRefreshEvent { class ChatRoomsRefreshEvent {
const ChatRoomsRefreshEvent(); const ChatRoomsRefreshEvent();
} }
/// Event fired when OIDC auth callback is received
class OidcAuthCallbackEvent {
final String challengeId;
const OidcAuthCallbackEvent(this.challengeId);
}

View File

@@ -82,12 +82,32 @@ class _ParsedVersion implements Comparable<_ParsedVersion> {
return _ParsedVersion(major, minor, patch, build); return _ParsedVersion(major, minor, patch, build);
} }
/// Normalize Android build numbers by removing architecture-based offsets
/// Android adds 1000 for x86, 2000 for ARMv7, 4000 for ARMv8
int get normalizedBuild {
// Check if build number has an architecture offset
// We detect this by checking if the build % 1000 is the base build
if (build >= 4000) {
// Likely ARMv8 (arm64-v8a) with +4000 offset
return build % 4000;
} else if (build >= 2000) {
// Likely ARMv7 (armeabi-v7a) with +2000 offset
return build % 2000;
} else if (build >= 1000) {
// Likely x86/x86_64 with +1000 offset
return build % 1000;
}
// No offset, return as-is
return build;
}
@override @override
int compareTo(_ParsedVersion other) { int compareTo(_ParsedVersion other) {
if (major != other.major) return major.compareTo(other.major); if (major != other.major) return major.compareTo(other.major);
if (minor != other.minor) return minor.compareTo(other.minor); if (minor != other.minor) return minor.compareTo(other.minor);
if (patch != other.patch) return patch.compareTo(other.patch); if (patch != other.patch) return patch.compareTo(other.patch);
return build.compareTo(other.build); // Use normalized build numbers for comparison to handle Android arch offsets
return normalizedBuild.compareTo(other.normalizedBuild);
} }
@override @override
@@ -244,13 +264,14 @@ class UpdateService {
showDialog( showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => _WindowsUpdateDialog( builder:
updateUrl: url, (context) => _WindowsUpdateDialog(
onComplete: () { updateUrl: url,
// Close the update sheet onComplete: () {
Navigator.of(context).pop(); // Close the update sheet
}, Navigator.of(context).pop();
), },
),
); );
} }
@@ -321,7 +342,9 @@ class _WindowsUpdateDialog extends StatefulWidget {
class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> { class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
final ValueNotifier<double?> progressNotifier = ValueNotifier<double?>(null); final ValueNotifier<double?> progressNotifier = ValueNotifier<double?>(null);
final ValueNotifier<String> messageNotifier = ValueNotifier<String>('Downloading installer...'); final ValueNotifier<String> messageNotifier = ValueNotifier<String>(
'Downloading installer...',
);
@override @override
void initState() { void initState() {
@@ -392,16 +415,17 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
Navigator.of(context).pop(); Navigator.of(context).pop();
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder:
title: const Text('Update Failed'), (context) => AlertDialog(
content: Text(message), title: const Text('Update Failed'),
actions: [ content: Text(message),
TextButton( actions: [
onPressed: () => Navigator.of(context).pop(), TextButton(
child: const Text('OK'), onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
), ),
],
),
); );
} }
@@ -458,7 +482,9 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
talker.info('[Update] Windows installer downloaded successfully to: $filePath'); talker.info(
'[Update] Windows installer downloaded successfully to: $filePath',
);
return filePath; return filePath;
} else { } else {
talker.error( talker.error(
@@ -500,7 +526,9 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
} }
} }
talker.info('[Update] Windows installer extracted successfully to: $extractDir'); talker.info(
'[Update] Windows installer extracted successfully to: $extractDir',
);
return extractDir; return extractDir;
} catch (e) { } catch (e) {
talker.error('[Update] Error extracting Windows installer: $e'); talker.error('[Update] Error extracting Windows installer: $e');
@@ -514,10 +542,11 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
talker.info('[Update] Running Windows installer from: $extractDir'); talker.info('[Update] Running Windows installer from: $extractDir');
final dir = Directory(extractDir); final dir = Directory(extractDir);
final exeFiles = dir final exeFiles =
.listSync() dir
.where((f) => f is File && f.path.endsWith('.exe')) .listSync()
.toList(); .where((f) => f is File && f.path.endsWith('.exe'))
.toList();
if (exeFiles.isEmpty) { if (exeFiles.isEmpty) {
talker.info('[Update] No .exe file found in extracted directory'); talker.info('[Update] No .exe file found in extracted directory');

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import '../models/file.dart';
import '../widgets/content/cloud_files.dart';
/// Returns an appropriate icon widget for the given file based on its MIME type
Widget getFileIcon(
SnCloudFile file, {
required double size,
bool tinyPreview = true,
}) {
final itemType = file.mimeType?.split('/').firstOrNull;
final mimeType = file.mimeType ?? '';
final extension = file.name.split('.').lastOrNull?.toLowerCase() ?? '';
// For images, show the actual image thumbnail
if (itemType == 'image' && tinyPreview) {
return CloudImageWidget(file: file);
}
// Return icon based on MIME type or file extension
final icon = switch ((itemType, mimeType, extension)) {
('image', _, _) => Symbols.image,
('audio', _, _) => Symbols.audio_file,
('video', _, _) => Symbols.video_file,
('application', 'application/pdf', _) => Symbols.picture_as_pdf,
('application', 'application/zip', _) => Symbols.archive,
('application', 'application/x-rar-compressed', _) => Symbols.archive,
(
'application',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
_,
) ||
('application', 'application/msword', _) => Symbols.description,
(
'application',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
_,
) ||
('application', 'application/vnd.ms-excel', _) => Symbols.table_chart,
(
'application',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
_,
) ||
('application', 'application/vnd.ms-powerpoint', _) => Symbols.slideshow,
('text', _, _) => Symbols.article,
('application', _, 'js') ||
('application', _, 'dart') ||
('application', _, 'py') ||
('application', _, 'java') ||
('application', _, 'cpp') ||
('application', _, 'c') ||
('application', _, 'cs') => Symbols.code,
('application', _, 'json') ||
('application', _, 'xml') => Symbols.data_object,
(_, _, 'md') => Symbols.article,
(_, _, 'html') => Symbols.web,
(_, _, 'css') => Symbols.css,
_ => Symbols.description, // Default icon
};
return Icon(icon, size: size, fill: 1).center();
}

View File

@@ -5,5 +5,11 @@ String formatFileSize(int bytes) {
if (bytes < 1024 * 1024 * 1024) { if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
} }
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; if (bytes < 1024 * 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
if (bytes < 1024 * 1024 * 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024 * 1024 * 1024)).toStringAsFixed(2)} TB';
}
return '${(bytes / (1024 * 1024 * 1024 * 1024 * 1024)).toStringAsFixed(2)} PB';
} }

View File

@@ -39,6 +39,7 @@ class AccountName extends StatelessWidget {
final String? textOverride; final String? textOverride;
final bool ignorePermissions; final bool ignorePermissions;
final bool hideVerificationMark; final bool hideVerificationMark;
final bool hideOverlay;
const AccountName({ const AccountName({
super.key, super.key,
required this.account, required this.account,
@@ -46,6 +47,7 @@ class AccountName extends StatelessWidget {
this.textOverride, this.textOverride,
this.ignorePermissions = false, this.ignorePermissions = false,
this.hideVerificationMark = false, this.hideVerificationMark = false,
this.hideOverlay = false,
}); });
Alignment _parseGradientDirection(String direction) { Alignment _parseGradientDirection(String direction) {
@@ -189,20 +191,33 @@ class AccountName extends StatelessWidget {
), ),
), ),
if (account.perkSubscription != null) if (account.perkSubscription != null)
StellarMembershipMark(membership: account.perkSubscription!), StellarMembershipMark(
membership: account.perkSubscription!,
hideOverlay: hideOverlay,
),
if (account.profile.verification != null && if (account.profile.verification != null &&
!hideVerificationMark) !hideVerificationMark)
VerificationMark(mark: account.profile.verification!), VerificationMark(
if (account.automatedId != null) mark: account.profile.verification!,
Tooltip( hideOverlay: hideOverlay,
message: 'accountAutomated'.tr(),
child: Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
),
), ),
if (account.automatedId != null)
hideOverlay
? Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
)
: Tooltip(
message: 'accountAutomated'.tr(),
child: Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
),
),
], ],
); );
} }
@@ -226,26 +241,39 @@ class AccountName extends StatelessWidget {
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
account.nick, textOverride ?? account.nick,
style: nameStyle, style: nameStyle,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
if (account.perkSubscription != null) if (account.perkSubscription != null)
StellarMembershipMark(membership: account.perkSubscription!), StellarMembershipMark(
if (account.profile.verification != null) membership: account.perkSubscription!,
VerificationMark(mark: account.profile.verification!), hideOverlay: hideOverlay,
if (account.automatedId != null)
Tooltip(
message: 'accountAutomated'.tr(),
child: Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
),
), ),
if (account.profile.verification != null)
VerificationMark(
mark: account.profile.verification!,
hideOverlay: hideOverlay,
),
if (account.automatedId != null)
hideOverlay
? Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
)
: Tooltip(
message: 'accountAutomated'.tr(),
child: Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
),
),
], ],
); );
} }
@@ -253,39 +281,53 @@ class AccountName extends StatelessWidget {
class VerificationMark extends StatelessWidget { class VerificationMark extends StatelessWidget {
final SnVerificationMark mark; final SnVerificationMark mark;
const VerificationMark({super.key, required this.mark}); final bool hideOverlay;
const VerificationMark({
super.key,
required this.mark,
this.hideOverlay = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Tooltip( final icon = Icon(
richMessage: TextSpan( mark.type == 4
text: mark.title ?? 'No title', ? Symbols.play_circle
children: [ : mark.type == 0
TextSpan(text: '\n'), ? Symbols.build_circle
TextSpan( : Symbols.verified,
text: mark.description ?? 'descriptionNone'.tr(), size: 16,
style: TextStyle(fontWeight: FontWeight.normal), color: kVerificationMarkColors[mark.type],
), fill: 1,
],
style: TextStyle(fontWeight: FontWeight.bold),
),
child: Icon(
mark.type == 4
? Symbols.play_circle
: mark.type == 0
? Symbols.build_circle
: Symbols.verified,
size: 16,
color: kVerificationMarkColors[mark.type],
fill: 1,
),
); );
return hideOverlay
? icon
: Tooltip(
richMessage: TextSpan(
text: mark.title ?? 'No title',
children: [
TextSpan(text: '\n'),
TextSpan(
text: mark.description ?? 'descriptionNone'.tr(),
style: TextStyle(fontWeight: FontWeight.normal),
),
],
style: TextStyle(fontWeight: FontWeight.bold),
),
child: icon,
);
} }
} }
class StellarMembershipMark extends StatelessWidget { class StellarMembershipMark extends StatelessWidget {
final SnWalletSubscriptionRef membership; final SnWalletSubscriptionRef membership;
const StellarMembershipMark({super.key, required this.membership}); final bool hideOverlay;
const StellarMembershipMark({
super.key,
required this.membership,
this.hideOverlay = false,
});
String _getMembershipTierName(String identifier) { String _getMembershipTierName(String identifier) {
switch (identifier) { switch (identifier) {
@@ -321,20 +363,24 @@ class StellarMembershipMark extends StatelessWidget {
final tierColor = _getMembershipTierColor(membership.identifier); final tierColor = _getMembershipTierColor(membership.identifier);
final tierIcon = Symbols.kid_star; final tierIcon = Symbols.kid_star;
return Tooltip( final icon = Icon(tierIcon, size: 16, color: tierColor, fill: 1);
richMessage: TextSpan(
text: 'stellarMembership'.tr(), return hideOverlay
children: [ ? icon
TextSpan(text: '\n'), : Tooltip(
TextSpan( richMessage: TextSpan(
text: 'currentMembershipMember'.tr(args: [tierName]), text: 'stellarMembership'.tr(),
style: TextStyle(fontWeight: FontWeight.normal), children: [
TextSpan(text: '\n'),
TextSpan(
text: 'currentMembershipMember'.tr(args: [tierName]),
style: TextStyle(fontWeight: FontWeight.normal),
),
],
style: TextStyle(fontWeight: FontWeight.bold),
), ),
], child: icon,
style: TextStyle(fontWeight: FontWeight.bold), );
),
child: Icon(tierIcon, size: 16, color: tierColor, fill: 1),
);
} }
} }

View File

@@ -0,0 +1,208 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:island/models/account.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/config.dart';
import 'package:island/widgets/account/account_pfc.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:gap/gap.dart';
part 'friends_overview.g.dart';
@riverpod
Future<List<SnFriendOverviewItem>> friendsOverview(Ref ref) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/pass/friends/overview');
return (resp.data as List<dynamic>)
.map((e) => SnFriendOverviewItem.fromJson(e))
.toList();
}
class FriendsOverviewWidget extends HookConsumerWidget {
final bool hideWhenEmpty;
final EdgeInsetsGeometry? padding;
const FriendsOverviewWidget({
super.key,
this.hideWhenEmpty = false,
this.padding,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Set up periodic refresh every minute
useEffect(() {
final timer = Timer.periodic(const Duration(minutes: 1), (_) {
ref.invalidate(friendsOverviewProvider);
});
return () => timer.cancel(); // Cleanup when widget is disposed
}, const []);
final friendsOverviewAsync = ref.watch(friendsOverviewProvider);
return friendsOverviewAsync.when(
data: (friends) {
// Filter for online friends
final onlineFriends =
friends.where((friend) => friend.status.isOnline).toList();
if (onlineFriends.isEmpty && hideWhenEmpty) {
return const SizedBox.shrink();
}
final card = Card(
margin: EdgeInsets.zero,
child: Column(
children: [
Row(
spacing: 8,
children: [const Icon(Symbols.group), Text('Friends Online')],
).padding(horizontal: 16).height(48),
if (onlineFriends.isEmpty)
Container(
height: 80,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Center(
child: Text(
'No friends online',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
),
)
else
SizedBox(
height: 80,
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
scrollDirection: Axis.horizontal,
itemCount: onlineFriends.length,
itemBuilder: (context, index) {
final friend = onlineFriends[index];
return AccountPfcGestureDetector(
uname: friend.account.name,
child: _FriendTile(friend: friend),
);
},
),
),
],
),
);
Widget result = card;
if (padding != null) {
result = Padding(padding: padding!, child: result);
}
return result;
},
loading:
() => const SizedBox(
height: 80,
child: Center(child: CircularProgressIndicator()),
),
error: (error, stack) => const SizedBox.shrink(), // Hide on error
);
}
}
class _FriendTile extends ConsumerWidget {
final SnFriendOverviewItem friend;
const _FriendTile({required this.friend});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final serverUrl = ref.watch(serverUrlProvider);
String? uri;
if (friend.account.profile.picture != null) {
uri = '$serverUrl/drive/files/${friend.account.profile.picture!.id}';
}
return Container(
width: 60,
margin: const EdgeInsets.only(right: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Avatar with online indicator
Stack(
children: [
CircleAvatar(
radius: 24,
backgroundImage:
uri != null ? CachedNetworkImageProvider(uri) : null,
child:
uri == null
? Text(
friend.account.nick.isNotEmpty
? friend.account.nick[0].toUpperCase()
: friend.account.name[0].toUpperCase(),
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onPrimary,
),
)
: null,
),
// Online indicator - show play arrow if user has activities, otherwise green dot
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color:
friend.activities.isNotEmpty
? Colors.blue.withOpacity(0.8)
: Colors.green,
shape:
friend.activities.isNotEmpty
? BoxShape.rectangle
: BoxShape.circle,
borderRadius:
friend.activities.isNotEmpty
? BorderRadius.circular(4)
: null,
border: Border.all(
color: theme.colorScheme.surface,
width: 2,
),
),
child:
friend.activities.isNotEmpty
? Icon(
Symbols.play_arrow,
size: 10,
color: Colors.white,
)
: null,
),
),
],
),
const Gap(4),
// Name (truncated if too long)
Text(
friend.account.nick.isNotEmpty
? friend.account.nick
: friend.account.name,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
).center();
}
}

View File

@@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'friends_overview.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$friendsOverviewHash() => r'5ef86c6849804c97abd3df094f120c7dd5e938db';
/// See also [friendsOverview].
@ProviderFor(friendsOverview)
final friendsOverviewProvider =
AutoDisposeFutureProvider<List<SnFriendOverviewItem>>.internal(
friendsOverview,
name: r'friendsOverviewProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$friendsOverviewHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef FriendsOverviewRef =
AutoDisposeFutureProviderRef<List<SnFriendOverviewItem>>;
// 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

@@ -240,7 +240,11 @@ class _PurchaseGiftSheetState extends State<PurchaseGiftSheet> {
labelText: 'personalMessage'.tr(), labelText: 'personalMessage'.tr(),
hintText: 'addPersonalMessageForRecipient'.tr(), hintText: 'addPersonalMessageForRecipient'.tr(),
alignLabelWithHint: true, alignLabelWithHint: true,
border: OutlineInputBorder(), border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderSide: BorderSide( borderSide: BorderSide(
color: Theme.of( color: Theme.of(
@@ -925,7 +929,9 @@ class StellarProgramTab extends HookConsumerWidget {
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
hintText: 'enterGiftCode'.tr(), hintText: 'enterGiftCode'.tr(),
border: OutlineInputBorder(), border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderSide: BorderSide( borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2), color: Theme.of(context).colorScheme.outline.withOpacity(0.2),

View File

@@ -1,11 +1,24 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:fl_heatmap/fl_heatmap.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/heatmap.dart'; import 'package:island/models/heatmap.dart';
import '../services/responsive.dart'; import 'package:island/services/responsive.dart';
/// Custom data class for selected heatmap item
class SelectedHeatmapItem {
final double value;
final String unit;
final String dateString;
final String dayLabel;
SelectedHeatmapItem({
required this.value,
required this.unit,
required this.dateString,
required this.dayLabel,
});
}
/// A reusable heatmap widget for displaying activity data in GitHub-style layout. /// A reusable heatmap widget for displaying activity data in GitHub-style layout.
/// Shows exactly 365 days (wide screen) or 90 days (non-wide screen) of data ending at the current date. /// Shows exactly 365 days (wide screen) or 90 days (non-wide screen) of data ending at the current date.
@@ -21,7 +34,7 @@ class ActivityHeatmapWidget extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final selectedItem = useState<HeatmapItem?>(null); final selectedItem = useState<SelectedHeatmapItem?>(null);
final now = DateTime.now(); final now = DateTime.now();
@@ -101,48 +114,18 @@ class ActivityHeatmapWidget extends HookConsumerWidget {
} }
} }
final heatmapData = HeatmapData( // Find maximum value for color scaling
rows: [ final maxValue =
'Mon', dataMap.values.isNotEmpty
'Tue', ? dataMap.values.reduce((a, b) => a > b ? a : b)
'Wed', : 1.0;
'Thu',
'Fri', // Helper function to get color based on activity level
'Sat', Color getActivityColor(double value) {
'Sun', if (value == 0) return Colors.grey.withOpacity(0.1);
], // Days of week vertically final intensity = value / maxValue;
columns: return Colors.green.withOpacity(0.2 + (intensity * 0.8));
weeks }
.map(
(w) =>
'${w.year}-${w.month.toString().padLeft(2, '0')}-${w.day.toString().padLeft(2, '0')}',
)
.toList(), // Weeks horizontally
items: [
for (int day = 0; day < 7; day++) // For each day of week (Mon-Sun)
for (final week in weeks) // For each week
HeatmapItem(
value: dataMap[week.add(Duration(days: day))] ?? 0.0,
unit: heatmap.unit,
xAxisLabel:
'${week.year}-${week.month.toString().padLeft(2, '0')}-${week.day.toString().padLeft(2, '0')}',
yAxisLabel:
day == 0
? 'Mon'
: day == 1
? 'Tue'
: day == 2
? 'Wed'
: day == 3
? 'Thu'
: day == 4
? 'Fri'
: day == 5
? 'Sat'
: 'Sun',
),
],
);
return Card( return Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
@@ -151,39 +134,103 @@ class ActivityHeatmapWidget extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( // Month labels row - aligned with month start positions
'activityHeatmap',
style: Theme.of(context).textTheme.titleMedium,
).tr(),
const Gap(8),
// Month labels row
Row( Row(
children: [ children: [
const SizedBox(width: 30), // Space for day labels const SizedBox(width: 30), // Space for day labels
...monthLabels.asMap().entries.map((entry) { ...List.generate(weeks.length, (weekIndex) {
final month = entry.value; // Check if this week is the start of a month
final monthIndex = monthPositions.indexOf(weekIndex);
final monthText =
monthIndex != -1 ? monthLabels[monthIndex] : null;
return Expanded( return monthText != null
child: Container( ? Expanded(
alignment: Alignment.center, child: Text(
child: Text( monthText,
month, style: Theme.of(context).textTheme.bodySmall,
style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center,
textAlign: TextAlign.center, ),
), )
), : SizedBox.shrink();
);
}), }),
], ],
), ),
const Gap(4), const Gap(4),
Heatmap( // Custom heatmap grid
heatmapData: heatmapData, Column(
rowsVisible: 7, children: List.generate(7, (dayIndex) {
showXAxisLabels: false, final dayLabels = [
onItemSelectedListener: (item) { 'Mon',
selectedItem.value = item; 'Tue',
}, 'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
];
final dayLabel = dayLabels[dayIndex];
return Row(
children: [
// Day label
SizedBox(
width: 30,
child: Text(
dayLabel,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
// Activity squares for each week - evenly distributed
Expanded(
child: Row(
children: List.generate(weeks.length, (weekIndex) {
final week = weeks[weekIndex];
final date = week.add(Duration(days: dayIndex));
final value = dataMap[date] ?? 0.0;
final dateString =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
return Expanded(
child: GestureDetector(
onTap: () {
selectedItem.value = SelectedHeatmapItem(
value: value,
unit: heatmap.unit,
dateString: dateString,
dayLabel: dayLabel,
);
},
child: Container(
height: 12,
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: getActivityColor(value),
borderRadius: BorderRadius.circular(2),
border:
selectedItem.value != null &&
selectedItem.value!.dateString ==
dateString &&
selectedItem.value!.dayLabel ==
dayLabel
? Border.all(
color: Colors.blue,
width: 1,
)
: null,
),
),
),
);
}),
),
),
],
);
}),
), ),
const Gap(8), const Gap(8),
// Legend // Legend
@@ -203,9 +250,7 @@ class ActivityHeatmapWidget extends HookConsumerWidget {
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
TextSpan( TextSpan(
text: _formatDate( text: _formatDate(selectedItem.value!.dateString),
selectedItem.value!.xAxisLabel ?? '',
),
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],

View File

@@ -1,13 +1,14 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:island/main.dart'; import 'package:island/main.dart';
import 'package:island/talker.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:top_snackbar_flutter/top_snack_bar.dart'; import 'package:top_snackbar_flutter/top_snack_bar.dart';
export 'content/alert.native.dart'
if (dart.library.html) 'content/alert.web.dart';
void showSnackBar(String message, {SnackBarAction? action}) { void showSnackBar(String message, {SnackBarAction? action}) {
final context = globalOverlay.currentState!.context; final context = globalOverlay.currentState!.context;
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
@@ -29,43 +30,60 @@ void showSnackBar(String message, {SnackBarAction? action}) {
), ),
), ),
), ),
curve: Curves.easeInOut,
snackBarPosition: SnackBarPosition.bottom, snackBarPosition: SnackBarPosition.bottom,
); );
} }
void clearSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).clearSnackBars();
}
OverlayEntry? _loadingOverlay; OverlayEntry? _loadingOverlay;
GlobalKey<_FadeOverlayState> _loadingOverlayKey = GlobalKey(); GlobalKey<_FadeOverlayState> _loadingOverlayKey = GlobalKey();
class _FadeOverlay extends StatefulWidget { class _FadeOverlay extends StatefulWidget {
const _FadeOverlay({super.key, required this.child}); const _FadeOverlay({
final Widget child; super.key,
this.child,
this.builder,
this.duration = const Duration(milliseconds: 200),
this.curve = Curves.linear,
}) : assert(child != null || builder != null);
final Widget? child;
final Widget Function(BuildContext, Animation<double>)? builder;
final Duration duration;
final Curve curve;
@override @override
State<_FadeOverlay> createState() => _FadeOverlayState(); State<_FadeOverlay> createState() => _FadeOverlayState();
} }
class _FadeOverlayState extends State<_FadeOverlay> { class _FadeOverlayState extends State<_FadeOverlay>
bool _visible = false; with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { _controller = AnimationController(vsync: this, duration: widget.duration);
setState(() => _visible = true); _controller.forward();
}); }
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> animateOut() async {
await _controller.reverse();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedOpacity( final animation = CurvedAnimation(parent: _controller, curve: widget.curve);
opacity: _visible ? 1.0 : 0.0, if (widget.builder != null) {
duration: const Duration(milliseconds: 200), return widget.builder!(context, animation);
child: widget.child, }
); return FadeTransition(opacity: animation, child: widget.child);
} }
} }
@@ -109,10 +127,156 @@ void hideLoadingModal(BuildContext context) async {
final state = entry.mounted ? _loadingOverlayKey.currentState : null; final state = entry.mounted ? _loadingOverlayKey.currentState : null;
if (state != null) { if (state != null) {
// ignore: invalid_use_of_protected_member await state.animateOut();
state.setState(() => state._visible = false);
await Future.delayed(const Duration(milliseconds: 200));
} }
entry.remove(); entry.remove();
} }
String _parseRemoteError(DioException err) {
String? message;
if (err.response?.data is String) {
message = err.response?.data;
} else if (err.response?.data?['message'] != null) {
message = <String?>[
err.response?.data?['message']?.toString(),
err.response?.data?['detail']?.toString(),
].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n');
} else if (err.response?.data?['errors'] != null) {
final errors = err.response?.data['errors'] as Map<String, dynamic>;
message = errors.values
.map(
(ele) =>
(ele as List<dynamic>).map((ele) => ele.toString()).join('\n'),
)
.join('\n');
}
if (message == null || message.isEmpty) message = err.response?.statusMessage;
message ??= err.message;
return message ?? err.toString();
}
Future<T?> showOverlayDialog<T>({
required Widget Function(BuildContext context, void Function(T? result) close)
builder,
bool barrierDismissible = true,
}) {
final completer = Completer<T?>();
final key = GlobalKey<_FadeOverlayState>();
late OverlayEntry entry;
void close(T? result) async {
if (completer.isCompleted) return;
final state = key.currentState;
if (state != null) {
await state.animateOut();
}
entry.remove();
completer.complete(result);
}
entry = OverlayEntry(
builder:
(context) => _FadeOverlay(
key: key,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
builder: (context, animation) {
return Stack(
children: [
Positioned.fill(
child: FadeTransition(
opacity: animation,
child: GestureDetector(
onTap: barrierDismissible ? () => close(null) : null,
behavior: HitTestBehavior.opaque,
child: const ColoredBox(color: Colors.black54),
),
),
),
Center(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.05),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: builder(context, close),
),
),
),
],
);
},
),
);
globalOverlay.currentState!.insert(entry);
return completer.future;
}
void showErrorAlert(dynamic err) {
if (err is Error) {
talker.error('Something went wrong...', err, err.stackTrace);
}
final text = switch (err) {
String _ => err,
DioException _ => _parseRemoteError(err),
Exception _ => err.toString(),
_ => err.toString(),
};
showOverlayDialog<void>(
builder:
(context, close) => AlertDialog(
title: Text('somethingWentWrong'.tr()),
content: Text(text),
actions: [
TextButton(
onPressed: () => close(null),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
);
}
void showInfoAlert(String message, String title) {
showOverlayDialog<void>(
builder:
(context, close) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => close(null),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
);
}
Future<bool> showConfirmAlert(String message, String title) async {
final result = await showOverlayDialog<bool>(
builder:
(context, close) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => close(false),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => close(true),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
);
return result ?? false;
}

View File

@@ -8,6 +8,7 @@ import 'package:island/pods/activity/activity_rpc.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.dart'; import 'package:island/route.dart';
import 'package:island/screens/tray_manager.dart'; import 'package:island/screens/tray_manager.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/notify.dart'; import 'package:island/services/notify.dart';
import 'package:island/services/sharing_intent.dart'; import 'package:island/services/sharing_intent.dart';
import 'package:island/services/update_service.dart'; import 'package:island/services/update_service.dart';
@@ -115,8 +116,32 @@ class _AppWrapperState extends ConsumerState<AppWrapper>
} }
void _handleDeepLink(Uri uri, WidgetRef ref) { void _handleDeepLink(Uri uri, WidgetRef ref) {
final router = ref.read(routerProvider);
String path = '/${uri.host}${uri.path}'; String path = '/${uri.host}${uri.path}';
// Special handling for OIDC auth callback
if (path == '/auth/callback' &&
uri.queryParameters.containsKey('challenge')) {
final challenge = uri.queryParameters['challenge']!;
eventBus.fire(OidcAuthCallbackEvent(challenge));
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
windowManager.show();
}
return;
}
// Special handling for share intent deep links
// Share intents are handled by SharingIntentService showing a modal,
// not by routing to a page
if (path == '/share') {
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
windowManager.show();
}
return;
}
final router = ref.read(routerProvider);
if (uri.queryParameters.isNotEmpty) { if (uri.queryParameters.isNotEmpty) {
path = path =
Uri.parse( Uri.parse(

View File

@@ -1,6 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
@@ -28,12 +28,12 @@ Future<SnRealtimeCall?> ongoingCall(Ref ref, String roomId) async {
} }
class AudioCallButton extends HookConsumerWidget { class AudioCallButton extends HookConsumerWidget {
final String roomId; final SnChatRoom room;
const AudioCallButton({super.key, required this.roomId}); const AudioCallButton({super.key, required this.room});
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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 callState = ref.watch(callNotifierProvider);
final callNotifier = ref.read(callNotifierProvider.notifier); final callNotifier = ref.read(callNotifierProvider.notifier);
final isLoading = useState(false); final isLoading = useState(false);
@@ -42,10 +42,9 @@ class AudioCallButton extends HookConsumerWidget {
Future<void> handleJoin() async { Future<void> handleJoin() async {
isLoading.value = true; isLoading.value = true;
try { try {
await apiClient.post('/sphere/chat/realtime/$roomId'); await apiClient.post('/sphere/chat/realtime/${room.id}');
if (context.mounted) { // Just join the room, the overlay will handle the UI
context.pushNamed('chatCall', pathParameters: {'id': roomId}); await callNotifier.joinRoom(room);
}
} catch (e) { } catch (e) {
showErrorAlert(e); showErrorAlert(e);
} finally { } finally {
@@ -56,7 +55,7 @@ class AudioCallButton extends HookConsumerWidget {
Future<void> handleEnd() async { Future<void> handleEnd() async {
isLoading.value = true; isLoading.value = true;
try { try {
await apiClient.delete('/sphere/chat/realtime/$roomId'); await apiClient.delete('/sphere/chat/realtime/${room.id}');
callNotifier.dispose(); // Clean up call resources callNotifier.dispose(); // Clean up call resources
} catch (e) { } catch (e) {
showErrorAlert(e); showErrorAlert(e);
@@ -94,9 +93,14 @@ class AudioCallButton extends HookConsumerWidget {
return IconButton( return IconButton(
icon: const Icon(Icons.call), icon: const Icon(Icons.call),
tooltip: 'Join Ongoing Call', tooltip: 'Join Ongoing Call',
onPressed: () { onPressed: () async {
if (context.mounted) { isLoading.value = true;
context.pushNamed('chatCall', pathParameters: {'id': roomId}); try {
await callNotifier.joinRoom(room);
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
} }
}, },
); );
@@ -105,7 +109,7 @@ class AudioCallButton extends HookConsumerWidget {
// Show join/start call button // Show join/start call button
return IconButton( return IconButton(
icon: const Icon(Icons.call), icon: const Icon(Icons.call),
tooltip: 'Start/Join Call', tooltip: 'Start Call',
onPressed: handleJoin, onPressed: handleJoin,
); );
} }

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/chat/call.dart';
import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:styled_widget/styled_widget.dart';
class CallContent extends HookConsumerWidget {
const CallContent({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.watch(callNotifierProvider.notifier);
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;
final allAudioOnly = participants.every(
(p) =>
!(p.hasVideo &&
p.remoteParticipant.trackPublications.values.any(
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO &&
!pub.muted &&
!pub.isDisposed,
)),
);
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),
],
),
),
);
}
// Show all participants in a responsive grid
return LayoutBuilder(
builder: (context, constraints) {
// Calculate width for responsive 2-column layout
final itemWidth = (constraints.maxWidth / 2) - 16;
return Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
for (final participant in participants)
SizedBox(
width: itemWidth,
child: CallParticipantTile(live: participant),
),
],
);
},
);
}
}

View File

@@ -1,11 +1,18 @@
import 'package:animations/animations.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/chat/call.dart'; import 'package:island/pods/chat/call.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/chat/call.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/chat/call_button.dart';
import 'package:island/widgets/chat/call_content.dart';
import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -13,7 +20,8 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
class CallControlsBar extends HookConsumerWidget { class CallControlsBar extends HookConsumerWidget {
const CallControlsBar({super.key}); final bool isCompact;
const CallControlsBar({super.key, this.isCompact = false});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -21,11 +29,14 @@ class CallControlsBar extends HookConsumerWidget {
final callNotifier = ref.read(callNotifierProvider.notifier); final callNotifier = ref.read(callNotifierProvider.notifier);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), padding: EdgeInsets.symmetric(
horizontal: isCompact ? 12 : 20,
vertical: isCompact ? 8 : 16,
),
child: Wrap( child: Wrap(
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
runSpacing: 16, runSpacing: isCompact ? 12 : 16,
spacing: 16, spacing: isCompact ? 12 : 16,
children: [ children: [
_buildCircularButtonWithDropdown( _buildCircularButtonWithDropdown(
context: context, context: context,
@@ -73,12 +84,15 @@ class CallControlsBar extends HookConsumerWidget {
(innerContext) => Column( (innerContext) => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Gap(24),
ListTile( ListTile(
leading: const Icon(Symbols.logout, fill: 1), leading: const Icon(Symbols.logout, fill: 1),
title: Text('callLeave').tr(), title: Text('callLeave').tr(),
onTap: () { onTap: () {
callNotifier.disconnect(); callNotifier.disconnect();
Navigator.of(context).pop(); if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
Navigator.of(innerContext).pop(); Navigator.of(innerContext).pop();
}, },
), ),
@@ -96,7 +110,9 @@ class CallControlsBar extends HookConsumerWidget {
); );
callNotifier.dispose(); callNotifier.dispose();
if (context.mounted) { if (context.mounted) {
Navigator.of(context).pop(); if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
Navigator.of(innerContext).pop(); Navigator.of(innerContext).pop();
} }
} catch (err) { } catch (err) {
@@ -124,12 +140,14 @@ class CallControlsBar extends HookConsumerWidget {
required Color backgroundColor, required Color backgroundColor,
Color? iconColor, Color? iconColor,
}) { }) {
final size = isCompact ? 40.0 : 56.0;
final iconSize = isCompact ? 20.0 : 24.0;
return Container( return Container(
width: 56, width: size,
height: 56, height: size,
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
child: IconButton( child: IconButton(
icon: Icon(icon, color: iconColor ?? Colors.white, size: 24), icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize),
onPressed: onPressed, onPressed: onPressed,
), ),
); );
@@ -145,41 +163,51 @@ class CallControlsBar extends HookConsumerWidget {
Color? iconColor, Color? iconColor,
String? deviceType, // 'videoinput' or 'audioinput' String? deviceType, // 'videoinput' or 'audioinput'
}) { }) {
final size = isCompact ? 40.0 : 56.0;
final iconSize = isCompact ? 20.0 : 24.0;
return Stack( return Stack(
clipBehavior: Clip.none,
children: [ children: [
Container( Container(
width: 56, width: size,
height: 56, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: IconButton( child: IconButton(
icon: Icon(icon, color: iconColor ?? Colors.white, size: 24), icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize),
onPressed: onPressed, onPressed: onPressed,
), ),
), ),
if (hasDropdown && deviceType != null) if (hasDropdown && deviceType != null)
Positioned( Positioned(
bottom: 4, bottom: 0,
right: 4, right: isCompact ? 0 : -4,
child: GestureDetector( child: Material(
onTap: () => _showDeviceSelectionDialog(context, ref, deviceType), color:
child: Container( Colors
width: 16, .transparent, // Make Material transparent to show underlying color
height: 16, child: InkWell(
decoration: BoxDecoration( onTap:
color: backgroundColor.withOpacity(0.8), () => _showDeviceSelectionDialog(context, ref, deviceType),
shape: BoxShape.circle, borderRadius: BorderRadius.circular((isCompact ? 16 : 24) / 2),
border: Border.all( child: Container(
color: Colors.white.withOpacity(0.3), width: isCompact ? 16 : 24,
width: 0.5, height: isCompact ? 16 : 24,
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.8),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 0.5,
),
),
child: Icon(
Icons.arrow_drop_down,
color: Colors.white,
size: isCompact ? 12 : 20,
), ),
),
child: Icon(
Icons.arrow_drop_down,
color: Colors.white,
size: 12,
), ),
), ),
), ),
@@ -279,34 +307,150 @@ class CallControlsBar extends HookConsumerWidget {
} }
class CallOverlayBar extends HookConsumerWidget { class CallOverlayBar extends HookConsumerWidget {
const CallOverlayBar({super.key}); final SnChatRoom room;
const CallOverlayBar({super.key, required this.room});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callNotifierProvider); final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.read(callNotifierProvider.notifier); final callNotifier = ref.read(callNotifierProvider.notifier);
// Only show if connected and not on the call screen final ongoingCall = ref.watch(ongoingCallProvider(room.id));
if (!callState.isConnected) return const SizedBox.shrink();
// State for overlay mode: compact or preview
// Default to true (preview mode) so user sees video immediately after joining
final isExpanded = useState(true);
Widget child;
if (callState.isConnected) {
child = _buildActiveCallOverlay(
context,
ref,
callState,
callNotifier,
isExpanded,
);
} else if (ongoingCall.value != null) {
child = _buildJoinPrompt(context, ref);
} else {
child = const SizedBox.shrink(key: ValueKey('empty'));
}
return AnimatedSize(
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: child,
),
);
}
Widget _buildJoinPrompt(BuildContext context, WidgetRef ref) {
final isLoading = useState(false);
return Card(
key: const ValueKey('join_prompt'),
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.videocam,
color: Theme.of(context).colorScheme.onPrimary,
size: 20,
),
),
const Gap(12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Call in progress').bold(),
Text('Tap to join', style: Theme.of(context).textTheme.bodySmall),
],
),
const Spacer(),
if (isLoading.value)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
).padding(right: 8)
else
FilledButton.icon(
onPressed: () async {
isLoading.value = true;
try {
// Just join the room, don't navigate
await ref.read(callNotifierProvider.notifier).joinRoom(room);
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
},
icon: const Icon(Icons.call, size: 18),
label: const Text('Join'),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
).padding(all: 12),
);
}
String _getChatRoomName(SnChatRoom? room, SnAccount currentUser) {
if (room == null) return 'unnamed'.tr();
return room.name ??
(room.members ?? [])
.where((element) => element.id != currentUser.id)
.map((element) => element.account.nick)
.first;
}
Widget _buildActiveCallOverlay(
BuildContext context,
WidgetRef ref,
CallState callState,
CallNotifier callNotifier,
ValueNotifier<bool> isExpanded,
) {
final lastSpeaker = final lastSpeaker =
callNotifier.participants callNotifier.participants
.where( .where(
(element) => element.remoteParticipant.lastSpokeAt != null, (element) => element.remoteParticipant.lastSpokeAt != null,
) )
.isEmpty .isEmpty
? callNotifier.participants.first ? callNotifier.participants.firstOrNull
: callNotifier.participants : callNotifier.participants
.where( .where(
(element) => element.remoteParticipant.lastSpokeAt != null, (element) => element.remoteParticipant.lastSpokeAt != null,
) )
.fold( .fold(
callNotifier.participants.first, callNotifier.participants.firstOrNull,
(value, element) => (value, element) =>
element.remoteParticipant.lastSpokeAt != null && element.remoteParticipant.lastSpokeAt != null &&
(value.remoteParticipant.lastSpokeAt == null || (value?.remoteParticipant.lastSpokeAt == null ||
element.remoteParticipant.lastSpokeAt! element.remoteParticipant.lastSpokeAt!
.compareTo( .compareTo(
value value!
.remoteParticipant .remoteParticipant
.lastSpokeAt!, .lastSpokeAt!,
) > ) >
@@ -315,11 +459,76 @@ class CallOverlayBar extends HookConsumerWidget {
: value, : value,
); );
final actionButtonStyle = ButtonStyle( if (lastSpeaker == null) {
minimumSize: const MaterialStatePropertyAll(Size(24, 24)), return const SizedBox.shrink(key: ValueKey('active_waiting'));
); }
final userInfo = ref.watch(userInfoProvider).value!;
// Preview Mode (Expanded)
if (isExpanded.value) {
return Card(
key: const ValueKey('active_expanded'),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
children: [
const Gap(4),
Text(_getChatRoomName(callNotifier.chatRoom, userInfo)),
const Gap(4),
Text(formatDuration(callState.duration)).bold(),
const Spacer(),
OpenContainer(
closedElevation: 0,
closedColor: Colors.transparent,
openColor: Theme.of(context).scaffoldBackgroundColor,
middleColor: Theme.of(context).scaffoldBackgroundColor,
openBuilder: (context, action) => CallScreen(room: room),
closedBuilder:
(context, openContainer) => IconButton(
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Icons.fullscreen),
onPressed: openContainer,
tooltip: 'Full Screen',
),
),
IconButton(
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Icons.expand_less),
onPressed: () => isExpanded.value = false,
tooltip: 'Collapse',
),
],
).padding(horizontal: 12, vertical: 8),
// Video Preview
Container(
height: 200,
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const CallContent(),
),
const CallControlsBar(
isCompact: true,
).padding(vertical: 8, horizontal: 16),
],
),
);
}
// Compact Mode
return GestureDetector( return GestureDetector(
key: const ValueKey('active_collapsed'),
onTap: () => isExpanded.value = true,
child: Card( child: Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Row( child: Row(
@@ -328,30 +537,32 @@ class CallOverlayBar extends HookConsumerWidget {
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
Builder( SizedBox(
builder: (context) { width: 40,
if (callNotifier.localParticipant == null) { height: 40,
return CircularProgressIndicator().center(); child:
} SpeakingRippleAvatar(
return SizedBox( live: lastSpeaker,
width: 40, size: 36,
height: 40, ).center(),
child:
SpeakingRippleAvatar(
live: lastSpeaker,
size: 36,
).center(),
);
},
), ),
const Gap(8), const Gap(8),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('@${lastSpeaker.participant.identity}').bold(), Text('@${lastSpeaker.participant.identity}').bold(),
Text( Row(
formatDuration(callState.duration), spacing: 4,
style: Theme.of(context).textTheme.bodySmall, children: [
Text(
_getChatRoomName(callNotifier.chatRoom, userInfo),
style: Theme.of(context).textTheme.bodySmall,
),
Text(
formatDuration(callState.duration),
style: Theme.of(context).textTheme.bodySmall,
),
],
), ),
], ],
), ),
@@ -361,41 +572,20 @@ class CallOverlayBar extends HookConsumerWidget {
IconButton( IconButton(
icon: Icon( icon: Icon(
callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off, callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
size: 20,
), ),
onPressed: () { onPressed: () {
callNotifier.toggleMicrophone(); callNotifier.toggleMicrophone();
}, },
style: actionButtonStyle,
), ),
IconButton( IconButton(
icon: Icon( icon: const Icon(Icons.expand_more),
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off, onPressed: () => isExpanded.value = true,
), tooltip: 'Expand',
onPressed: () {
callNotifier.toggleCamera();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isScreenSharing
? Icons.stop_screen_share
: Icons.screen_share,
),
onPressed: () {
callNotifier.toggleScreenShare(context);
},
style: actionButtonStyle,
), ),
], ],
).padding(all: 16), ).padding(all: 12),
), ),
onTap: () {
context.pushNamed(
'chatCall',
pathParameters: {'id': callNotifier.roomId!},
);
},
); );
} }
} }

View File

@@ -8,6 +8,59 @@ import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
class SpeakingRipple extends StatelessWidget {
final double size;
final double audioLevel;
final bool isSpeaking;
final Widget child;
const SpeakingRipple({
super.key,
required this.size,
required this.audioLevel,
required this.isSpeaking,
required this.child,
});
@override
Widget build(BuildContext context) {
final avatarRadius = size / 2;
final clampedLevel = audioLevel.clamp(0.0, 1.0);
final rippleRadius = avatarRadius + clampedLevel * (size * 0.333);
return SizedBox(
width: size + 8,
height: size + 8,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: avatarRadius,
end: isSpeaking ? rippleRadius : avatarRadius,
),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
builder: (context, animatedRadius, child) {
return Stack(
alignment: Alignment.center,
children: [
if (isSpeaking)
Container(
width: animatedRadius * 2,
height: animatedRadius * 2,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel),
),
),
child!,
],
);
},
child: SizedBox(width: size, height: size, child: child),
),
);
}
}
class SpeakingRippleAvatar extends HookConsumerWidget { class SpeakingRippleAvatar extends HookConsumerWidget {
final CallParticipantLive live; final CallParticipantLive live;
final double size; final double size;
@@ -18,79 +71,58 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final account = ref.watch(accountProvider(live.participant.identity)); final account = ref.watch(accountProvider(live.participant.identity));
final avatarRadius = size / 2; return SpeakingRipple(
final clampedLevel = live.remoteParticipant.audioLevel.clamp(0.0, 1.0); size: size,
final rippleRadius = avatarRadius + clampedLevel * (size * 0.333); audioLevel: live.remoteParticipant.audioLevel,
return SizedBox( isSpeaking: live.remoteParticipant.isSpeaking,
width: size + 8, child: Stack(
height: size + 8, children: [
child: TweenAnimationBuilder<double>( Container(
tween: Tween<double>( width: size,
begin: avatarRadius, height: size,
end: live.remoteParticipant.isSpeaking ? rippleRadius : avatarRadius,
),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
builder: (context, animatedRadius, child) {
return Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ decoration: const BoxDecoration(shape: BoxShape.circle),
if (live.remoteParticipant.isSpeaking) child: account.when(
Container( data:
width: animatedRadius * 2, (value) => CallParticipantGestureDetector(
height: animatedRadius * 2, participant: live,
decoration: BoxDecoration( child: ProfilePictureWidget(
shape: BoxShape.circle, file: value.profile.picture,
color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel), radius: size / 2,
),
), ),
error:
(_, _) => CircleAvatar(
radius: size / 2,
child: const Icon(Symbols.person_remove),
),
loading:
() => CircleAvatar(
radius: size / 2,
child: CircularProgressIndicator(),
),
),
),
if (live.remoteParticipant.isMuted)
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 2),
), ),
Container( child: const Icon(
width: size, Symbols.mic_off,
height: size, size: 14,
alignment: Alignment.center, color: Colors.white,
decoration: BoxDecoration(shape: BoxShape.circle),
child: account.when(
data:
(value) => CallParticipantGestureDetector(
participant: live,
child: ProfilePictureWidget(
file: value.profile.picture,
radius: size / 2,
),
),
error:
(_, _) => CircleAvatar(
radius: size / 2,
child: const Icon(Symbols.person_remove),
),
loading:
() => CircleAvatar(
radius: size / 2,
child: CircularProgressIndicator(),
),
), ),
), ),
if (live.remoteParticipant.isMuted) ),
Positioned( ],
bottom: 4,
right: 4,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
child: const Icon(
Symbols.mic_off,
size: 14,
fill: 1,
).padding(left: 1.5, top: 1.5),
),
),
],
);
},
), ),
); );
} }
@@ -103,6 +135,8 @@ class CallParticipantTile extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final userInfo = ref.watch(accountProvider(live.participant.name));
final hasVideo = final hasVideo =
live.hasVideo && live.hasVideo &&
live.remoteParticipant.trackPublications.values live.remoteParticipant.trackPublications.values
@@ -110,42 +144,92 @@ class CallParticipantTile extends HookConsumerWidget {
.isNotEmpty; .isNotEmpty;
if (hasVideo) { if (hasVideo) {
return Stack( return Padding(
fit: StackFit.loose, padding: const EdgeInsets.all(8),
children: [ child: LayoutBuilder(
AspectRatio( builder: (context, constraints) {
aspectRatio: 16 / 9, // Use the smaller dimension to determine the "size" for the ripple calculation
child: VideoTrackRenderer( // effectively making the ripple relative to the tile size.
live.remoteParticipant.trackPublications.values // However, for a rectangular video, we might want a different approach.
.where((track) => track.kind == TrackType.VIDEO) // The user asked for "speaking ripple to the video as well".
.first // If we use the extracted SpeakingRipple, it expects a size and assumes a circle.
.track // We need to adapt it or create a rectangular version.
as VideoTrack, // Given the "image" likely shows a rectangular video with rounded corners,
renderMode: VideoRenderMode.platformView, // let's create a specific wrapper for the video tile that adds a border/glow when speaking.
),
), final isSpeaking = live.remoteParticipant.isSpeaking;
Positioned( final audioLevel = live.remoteParticipant.audioLevel;
left: 8,
right: 8, return AnimatedContainer(
bottom: 8, duration: const Duration(milliseconds: 200),
child: Text( decoration: BoxDecoration(
'@${live.participant.name}', color: Theme.of(context).colorScheme.surfaceContainerHighest,
textAlign: TextAlign.center, borderRadius: BorderRadius.circular(16),
style: const TextStyle( border: Border.all(
fontSize: 14, color:
color: Colors.white, isSpeaking
shadows: [ ? Colors.green.withOpacity(
BoxShadow( 0.5 + 0.5 * audioLevel.clamp(0.0, 1.0),
color: Colors.black54, )
offset: Offset(1, 1), : Theme.of(context).colorScheme.outlineVariant,
spreadRadius: 8, width: isSpeaking ? 4 : 1,
blurRadius: 8, ),
),
],
), ),
), child: ClipRRect(
), borderRadius: BorderRadius.circular(12),
], child: AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
VideoTrackRenderer(
live.remoteParticipant.trackPublications.values
.where((track) => track.kind == TrackType.VIDEO)
.first
.track
as VideoTrack,
renderMode: VideoRenderMode.platformView,
),
Positioned(
left: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (live.remoteParticipant.isMuted)
const Icon(
Symbols.mic_off,
size: 14,
color: Colors.redAccent,
).padding(right: 4),
Text(
userInfo.value?.nick ?? live.participant.name,
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
),
),
);
},
),
); );
} else { } else {
return SpeakingRippleAvatar(size: 84, live: live); return SpeakingRippleAvatar(size: 84, live: live);

View File

@@ -11,7 +11,9 @@ import "package:island/models/account.dart";
import "package:island/models/autocomplete_response.dart"; import "package:island/models/autocomplete_response.dart";
import "package:island/models/chat.dart"; import "package:island/models/chat.dart";
import "package:island/models/file.dart"; import "package:island/models/file.dart";
import "package:island/models/poll.dart";
import "package:island/models/publisher.dart"; import "package:island/models/publisher.dart";
import "package:island/models/wallet.dart";
import "package:island/models/realm.dart"; import "package:island/models/realm.dart";
import "package:island/models/sticker.dart"; import "package:island/models/sticker.dart";
import "package:island/pods/config.dart"; import "package:island/pods/config.dart";
@@ -26,6 +28,185 @@ import "package:styled_widget/styled_widget.dart";
import "package:material_symbols_icons/symbols.dart"; import "package:material_symbols_icons/symbols.dart";
import "package:island/widgets/stickers/sticker_picker.dart"; import "package:island/widgets/stickers/sticker_picker.dart";
import "package:island/pods/chat/chat_subscribe.dart"; import "package:island/pods/chat/chat_subscribe.dart";
import "package:island/widgets/post/compose_poll.dart";
import "package:island/widgets/post/compose_fund.dart";
void _insertPlaceholder(TextEditingController controller, String placeholder) {
final text = controller.text;
final selection = controller.selection;
final start = selection.start >= 0 ? selection.start : text.length;
final end = selection.end >= 0 ? selection.end : text.length;
final newText = text.replaceRange(start, end, placeholder);
controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: start + placeholder.length),
);
}
const kInputDrawerExpandedHeight = 180.0;
const kExpandedSectionTabHeight = 32.0;
class _ExpandedSection extends StatelessWidget {
final TextEditingController messageController;
final SnPoll? selectedPoll;
final Function(SnPoll?) onPollSelected;
final SnWalletFund? selectedFund;
final Function(SnWalletFund?) onFundSelected;
const _ExpandedSection({
required this.messageController,
this.selectedPoll,
required this.onPollSelected,
this.selectedFund,
required this.onFundSelected,
});
@override
Widget build(BuildContext context) {
return Container(
key: const ValueKey('expanded'),
decoration: BoxDecoration(
border: Border.all(width: 1, color: Theme.of(context).dividerColor),
borderRadius: const BorderRadius.all(Radius.circular(32)),
),
margin: const EdgeInsets.only(top: 8, bottom: 3),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(32)),
child: DefaultTabController(
length: 2,
child: Column(
children: [
PreferredSize(
preferredSize: const Size.fromHeight(kExpandedSectionTabHeight),
child: TabBar(
splashBorderRadius: const BorderRadius.all(
Radius.circular(40),
),
tabs: [
Tab(
text: 'features'.tr(),
height: kExpandedSectionTabHeight,
),
Tab(
text: 'stickers'.tr(),
height: kExpandedSectionTabHeight,
),
],
),
),
SizedBox(
height: kInputDrawerExpandedHeight,
child: TabBarView(
children: [
SizedBox(
height:
kInputDrawerExpandedHeight -
48, // subtract tab bar height approx
child: GridView(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 120,
childAspectRatio: 1, // 1:1 aspect ratio
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
children: [
InkWell(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
onTap: () async {
final poll = await showModalBottomSheet<SnPoll>(
context: context,
isScrollControlled: true,
builder: (context) => const ComposePollSheet(),
);
if (poll != null) {
onPollSelected(poll);
}
},
child: Card(
margin: EdgeInsets.zero,
color:
Theme.of(
context,
).colorScheme.surfaceContainer,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Symbols.poll),
const Gap(4),
Text(
'Poll',
style:
Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
InkWell(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
onTap: () async {
final fund =
await showModalBottomSheet<SnWalletFund>(
context: context,
isScrollControlled: true,
builder:
(context) => const ComposeFundSheet(),
);
if (fund != null) {
onFundSelected(fund);
}
},
child: Card(
margin: EdgeInsets.zero,
color:
Theme.of(
context,
).colorScheme.surfaceContainer,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Symbols.currency_exchange),
const Gap(4),
Text(
'fund'.tr(),
style:
Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
),
),
StickerPickerEmbedded(
height: kInputDrawerExpandedHeight,
onPick:
(placeholder) => _insertPlaceholder(
messageController,
placeholder,
),
),
],
),
),
],
),
),
),
);
}
}
class ChatInput extends HookConsumerWidget { class ChatInput extends HookConsumerWidget {
final TextEditingController messageController; final TextEditingController messageController;
@@ -45,6 +226,10 @@ class ChatInput extends HookConsumerWidget {
final Function(int, int) onMoveAttachment; final Function(int, int) onMoveAttachment;
final Function(List<UniversalFile>) onAttachmentsChanged; final Function(List<UniversalFile>) onAttachmentsChanged;
final Map<String, Map<int, double?>> attachmentProgress; final Map<String, Map<int, double?>> attachmentProgress;
final SnPoll? selectedPoll;
final Function(SnPoll?) onPollSelected;
final SnWalletFund? selectedFund;
final Function(SnWalletFund?) onFundSelected;
const ChatInput({ const ChatInput({
super.key, super.key,
@@ -65,15 +250,21 @@ class ChatInput extends HookConsumerWidget {
required this.onMoveAttachment, required this.onMoveAttachment,
required this.onAttachmentsChanged, required this.onAttachmentsChanged,
required this.attachmentProgress, required this.attachmentProgress,
this.selectedPoll,
required this.onPollSelected,
this.selectedFund,
required this.onFundSelected,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final inputFocusNode = useFocusNode(); final inputFocusNode = useFocusNode();
final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id)); final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id));
final isExpanded = useState(false);
void send() { void send() {
inputFocusNode.requestFocus(); inputFocusNode.requestFocus();
if (isExpanded.value) isExpanded.value = false;
onSend.call(); onSend.call();
} }
@@ -281,6 +472,195 @@ class ChatInput extends HookConsumerWidget {
key: ValueKey('no-attachments'), key: ValueKey('no-attachments'),
), ),
), ),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (Widget child, Animation<double> animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, -0.25),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1.0,
child: child,
),
),
);
},
child:
selectedPoll != null
? Container(
key: const ValueKey('selected-poll'),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color:
Theme.of(
context,
).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
width: 1,
),
),
margin: const EdgeInsets.only(
left: 8,
right: 8,
top: 8,
bottom: 8,
),
child: Row(
children: [
Icon(
Symbols.how_to_vote,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Expanded(
child: Text(
selectedPoll!.title ?? 'Poll',
style: Theme.of(context).textTheme.bodySmall!
.copyWith(fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(
width: 24,
height: 24,
child: IconButton(
padding: EdgeInsets.zero,
icon: const Icon(Icons.close, size: 18),
onPressed: () => onPollSelected(null),
tooltip: 'clear'.tr(),
),
),
],
),
)
: const SizedBox.shrink(
key: ValueKey('no-selected-poll'),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (Widget child, Animation<double> animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, -0.25),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1.0,
child: child,
),
),
);
},
child:
selectedFund != null
? Container(
key: const ValueKey('selected-fund'),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color:
Theme.of(
context,
).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
width: 1,
),
),
margin: const EdgeInsets.only(
left: 8,
right: 8,
top: 8,
bottom: 8,
),
child: Row(
children: [
Icon(
Symbols.currency_exchange,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${selectedFund!.totalAmount.toStringAsFixed(2)} ${selectedFund!.currency}',
style: Theme.of(
context,
).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (selectedFund!.message != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
selectedFund!.message!,
style: Theme.of(
context,
).textTheme.bodySmall!.copyWith(
fontSize: 10,
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
SizedBox(
width: 24,
height: 24,
child: IconButton(
padding: EdgeInsets.zero,
icon: const Icon(Icons.close, size: 18),
onPressed: () => onFundSelected(null),
tooltip: 'clear'.tr(),
),
),
],
),
)
: const SizedBox.shrink(
key: ValueKey('no-selected-fund'),
),
),
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic, switchInCurve: Curves.easeOutCubic,
@@ -426,43 +806,28 @@ class ChatInput extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
tooltip: 'stickers'.tr(), tooltip:
icon: const Icon(Symbols.add_reaction), isExpanded.value ? 'collapse'.tr() : 'more'.tr(),
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder:
(child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child:
isExpanded.value
? const Icon(
Symbols.close,
key: ValueKey('close'),
)
: const Icon(
Symbols.add,
key: ValueKey('add'),
),
),
onPressed: () { onPressed: () {
final size = MediaQuery.of(context).size; isExpanded.value = !isExpanded.value;
showStickerPickerPopover(
context,
Offset(
20,
size.height -
480 -
MediaQuery.of(context).padding.bottom,
),
onPick: (placeholder) {
// Insert placeholder at current cursor position
final text = messageController.text;
final selection = messageController.selection;
final start =
selection.start >= 0
? selection.start
: text.length;
final end =
selection.end >= 0
? selection.end
: text.length;
final newText = text.replaceRange(
start,
end,
placeholder,
);
messageController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: start + placeholder.length,
),
);
},
);
}, },
), ),
UploadMenu( UploadMenu(
@@ -659,6 +1024,37 @@ class ChatInput extends HookConsumerWidget {
), ),
], ],
), ),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (Widget child, Animation<double> animation) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1.0,
child: child,
),
),
);
},
child:
isExpanded.value
? _ExpandedSection(
messageController: messageController,
selectedPoll: selectedPoll,
onPollSelected: onPollSelected,
selectedFund: selectedFund,
onFundSelected: onFundSelected,
)
: const SizedBox.shrink(key: ValueKey('collapsed')),
),
], ],
), ),
), ),

View File

@@ -144,7 +144,11 @@ class ChatLinkAttachment extends HookConsumerWidget {
helperText: 'fileIdHint'.tr(), helperText: 'fileIdHint'.tr(),
helperMaxLines: 3, helperMaxLines: 3,
errorText: errorMessage.value, errorText: errorMessage.value,
border: OutlineInputBorder(), border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
), ),
onTapOutside: onTapOutside:
(_) => (_) =>

View File

@@ -1,64 +0,0 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_platform_alert/flutter_platform_alert.dart';
import 'package:island/talker.dart';
String _parseRemoteError(DioException err) {
String? message;
if (err.response?.data is String) {
message = err.response?.data;
} else if (err.response?.data?['message'] != null) {
message = <String?>[
err.response?.data?['message']?.toString(),
err.response?.data?['detail']?.toString(),
].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n');
} else if (err.response?.data?['errors'] != null) {
final errors = err.response?.data['errors'] as Map<String, dynamic>;
message = errors.values
.map(
(ele) =>
(ele as List<dynamic>).map((ele) => ele.toString()).join('\n'),
)
.join('\n');
}
if (message == null || message.isEmpty) message = err.response?.statusMessage;
message ??= err.message;
return message ?? err.toString();
}
void showErrorAlert(dynamic err) async {
if (err is Error) {
talker.error('Something went wrong...', err, err.stackTrace);
}
final text = switch (err) {
String _ => err,
DioException _ => _parseRemoteError(err),
Exception _ => err.toString(),
_ => err.toString(),
};
FlutterPlatformAlert.showAlert(
windowTitle: 'somethingWentWrong'.tr(),
text: text,
alertStyle: AlertButtonStyle.ok,
iconStyle: IconStyle.error,
);
}
void showInfoAlert(String message, String title) async {
FlutterPlatformAlert.showAlert(
windowTitle: title,
text: message,
alertStyle: AlertButtonStyle.ok,
iconStyle: IconStyle.information,
);
}
Future<bool> showConfirmAlert(String message, String title) async {
final result = await FlutterPlatformAlert.showAlert(
windowTitle: title,
text: message,
alertStyle: AlertButtonStyle.okCancel,
iconStyle: IconStyle.question,
);
return result == AlertButton.okButton;
}

View File

@@ -1,53 +0,0 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:js' as js;
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
String _parseRemoteError(DioException err) {
String? message;
if (err.response?.data is String) {
message = err.response?.data;
} else if (err.response?.data?['message'] != null) {
message = <String?>[
err.response?.data?['message']?.toString(),
err.response?.data?['detail']?.toString(),
].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n');
} else if (err.response?.data?['errors'] != null) {
final errors = err.response?.data['errors'] as Map<String, dynamic>;
message = errors.values
.map(
(ele) =>
(ele as List<dynamic>).map((ele) => ele.toString()).join('\n'),
)
.join('\n');
}
if (message == null || message.isEmpty) message = err.response?.statusMessage;
message ??= err.message;
return message ?? err.toString();
}
void showErrorAlert(dynamic err) async {
final text = switch (err) {
String _ => err,
DioException _ => _parseRemoteError(err),
Exception _ => err.toString(),
_ => err.toString(),
};
js.context.callMethod('swal', ['somethingWentWrong'.tr(), text, 'error']);
}
void showInfoAlert(String message, String title) async {
js.context.callMethod('swal', [title, message, 'info']);
}
Future<bool> showConfirmAlert(String message, String title) async {
final result = await js.context.callMethod('swal', [
title,
message,
'question',
{'buttons': true},
]);
return result == true;
}

View File

@@ -216,6 +216,7 @@ class CloudFileList extends HookConsumerWidget {
if (files.length == 1) { if (files.length == 1) {
final isImage = files.first.mimeType?.startsWith('image') ?? false; final isImage = files.first.mimeType?.startsWith('image') ?? false;
final isAudio = files.first.mimeType?.startsWith('audio') ?? false; final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
final ratio = files.first.fileMeta?['ratio'] as num?;
final widgetItem = ClipRRect( final widgetItem = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _CloudFileListEntry( child: _CloudFileListEntry(
@@ -243,11 +244,15 @@ class CloudFileList extends HookConsumerWidget {
minWidth: minWidth ?? 0, minWidth: minWidth ?? 0,
maxWidth: files.length == 1 ? maxWidth : double.infinity, maxWidth: files.length == 1 ? maxWidth : double.infinity,
), ),
height: isAudio ? 120 : null,
child: child:
isAudio (ratio == null && isImage)
? widgetItem ? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
: IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)), : (ratio == null && isAudio)
? IntrinsicHeight(child: widgetItem)
: AspectRatio(
aspectRatio: ratio?.toDouble() ?? 1,
child: widgetItem,
),
); );
} }
@@ -451,9 +456,7 @@ class _CloudFileListEntry extends HookConsumerWidget {
fit: fit, fit: fit,
useInternalGate: false, useInternalGate: false,
)) ))
: IntrinsicWidth( : const SizedBox.shrink();
child: IntrinsicHeight(child: const SizedBox.shrink()),
);
Widget overlays; Widget overlays;
if (lockedByDS) { if (lockedByDS) {

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