Compare commits
134 Commits
ea8e7ead2d
...
3.4.0+149
| Author | SHA1 | Date | |
|---|---|---|---|
|
5fc8859f3b
|
|||
|
e30e7adbe2
|
|||
|
68be4db160
|
|||
|
aa91e376ca
|
|||
|
caffb85588
|
|||
|
521b192205
|
|||
|
77ac0428ea
|
|||
|
88c8227c66
|
|||
|
b20d8350a8
|
|||
|
98b27bed0e
|
|||
|
3a7d8b1a0d
|
|||
|
b4801d6af6
|
|||
|
aab5b957af
|
|||
|
43d706a184
|
|||
|
98df275f88
|
|||
|
5663df6ef1
|
|||
|
e996a0c95f
|
|||
|
a090e93f57
|
|||
|
c69034c071
|
|||
|
369ea6cf5b
|
|||
|
2e371b5296
|
|||
|
2e9d61bcfa
|
|||
|
9c2b5b0dfa
|
|||
|
3b40f515b3
|
|||
|
5ee61dbef2
|
|||
|
b151ef6686
|
|||
|
ff934d0f08
|
|||
|
abe5ded896
|
|||
|
f1d72a5215
|
|||
|
864cbe73b7
|
|||
|
108a6da074
|
|||
|
f9a09599c9
|
|||
|
9067dadd3e
|
|||
|
09f8df1e78
|
|||
|
2c5f246c55
|
|||
|
a66c6ea654
|
|||
|
3ad4bb4518
|
|||
|
53f0dcb825
|
|||
|
557f5a2389
|
|||
|
78f14f890f
|
|||
|
77b2effb34
|
|||
|
f02b4abf65
|
|||
|
3f37c4f761
|
|||
|
5deb910fa4
|
|||
|
f50a19f573
|
|||
|
98c8a356e8
|
|||
|
d0c16ea08f
|
|||
|
f2c1b2a531
|
|||
|
3061f0c5a9
|
|||
|
98f7f33c65
|
|||
|
d9af5d32fd
|
|||
|
f2031697ec
|
|||
|
9b85b7573c
|
|||
|
4fb739b33b
|
|||
|
c03ba3bc3a
|
|||
|
fc65440420
|
|||
|
7b85533184
|
|||
|
77d9eb60c6
|
|||
|
4d8953cd22
|
|||
|
fafa460fe8
|
|||
|
faf3a677d4
|
|||
|
0f644a0234
|
|||
|
18d16fdd57
|
|||
|
18e890d63c
|
|||
|
9c5e50c16a
|
|||
|
96a2c8182e
|
|||
|
56b27c3e82
|
|||
|
ad4bf94195
|
|||
|
b77a832d8a
|
|||
|
5e61805db7
|
|||
|
35b96b0bd2
|
|||
|
c8ad791ff3
|
|||
|
1e908502dc
|
|||
|
715ce1a368
|
|||
|
548c9963ee
|
|||
|
db5199438a
|
|||
|
4409a6fb1e
|
|||
|
26a24b0e41
|
|||
|
9b948d259b
|
|||
|
1f713b5b2b
|
|||
|
f92cfafda4
|
|||
|
fa208b44d7
|
|||
|
94adecafbb
|
|||
|
0303ef4a93
|
|||
|
c2b18ce10b
|
|||
|
0767bb53ce
|
|||
|
b233f9a410
|
|||
|
256024fb46
|
|||
|
4a80aaf24d
|
|||
|
aafd160c44
|
|||
|
4a800725e3
|
|||
|
24791b3293
|
|||
|
3ac263d483
|
|||
|
2445d8adf8
|
|||
|
d4f95bbbf4
|
|||
|
943e4b7b5c
|
|||
|
7edc02a1d3
|
|||
|
3f9881e943
|
|||
|
50c25e919c
|
|||
|
99fb08dd55
|
|||
|
e43bc6b8a8
|
|||
|
c247cdf81c
|
|||
|
3ffa730505
|
|||
|
1cc34d3073
|
|||
|
96a919cc4e
|
|||
|
e7e3bfcadf
|
|||
|
a8617a5040
|
|||
|
d94f8d004f
|
|||
|
d93b066979
|
|||
|
320664a547
|
|||
|
98f4698d5b
|
|||
|
82397dd087
|
|||
|
4ec10ceb47
|
|||
|
4b03b45a0d
|
|||
|
7a72d32649
|
|||
|
5152dd13ea
|
|||
|
fd377aa7af
|
|||
|
67044148f1
|
|||
|
92bc43e4df
|
|||
|
a1a7b34c86
|
|||
|
40c0e052cf
|
|||
|
9a75228e38
|
|||
|
a9fd75cc45
|
|||
|
a713b30d93
|
|||
|
e516f0a862
|
|||
|
429b966c4b
|
|||
|
f14da0d3a2
|
|||
|
d201182bd2
|
|||
|
6f6422c15e
|
|||
|
9f6ae639ee
|
|||
|
35f4d7d885
|
|||
|
a9c8f49797
|
|||
|
5e9341a19c
|
|||
|
645a6dca93
|
@@ -136,6 +136,7 @@
|
||||
"reactionNegative": "Negative",
|
||||
"reactionNeutral": "Neutral",
|
||||
"customReaction": "Custom Reaction",
|
||||
"customReactionHint": "Custom Reaction allow you to use user uploaded stickers as the symbol of the reaction for the post. Exclusive for Stellar Program members.",
|
||||
"customReactions": "Custom Reactions",
|
||||
"stickerPlaceholder": "Sticker Placeholder",
|
||||
"reactionAttitude": "Reaction Attitude",
|
||||
@@ -179,6 +180,7 @@
|
||||
"noFortuneData": "No fortune data available for this month.",
|
||||
"creatorHub": "Creator Hub",
|
||||
"creatorHubDescription": "Manage posts, analytics, and more.",
|
||||
"publicationSites": "Publication Sites",
|
||||
"developerPortal": "Developer Portal",
|
||||
"developerPortalDescription": "Build with Solar Network™.",
|
||||
"statusCreateHint": "What's on your mind? Add a status.",
|
||||
@@ -231,6 +233,9 @@
|
||||
"pickFile": "Pick a file",
|
||||
"uploading": "Uploading",
|
||||
"uploadingProgress": "Uploading {} of {}",
|
||||
"upload": "Upload",
|
||||
"uploadSuccess": "Upload successful!",
|
||||
"wouldYouLikeToViewFile": "Would you like to view the file?",
|
||||
"uploadAll": "Upload All",
|
||||
"stickerCopyPlaceholder": "Copy Placeholder",
|
||||
"realmSelection": "Select a Realm",
|
||||
@@ -1108,7 +1113,6 @@
|
||||
"deleteRecycledFiles": "Delete Recycled Files",
|
||||
"recycledFilesDeleted": "Recycled files deleted successfully",
|
||||
"failedToDeleteRecycledFiles": "Failed to delete recycled files",
|
||||
"upload": "Upload",
|
||||
"updateAvailable": "Update available",
|
||||
"noChangelogProvided": "No changelog provided.",
|
||||
"useSecondarySourceForDownload": "Use secondary source for download",
|
||||
@@ -1302,7 +1306,9 @@
|
||||
"thoughtInputHint": "Ask sn-chan anything...",
|
||||
"thoughtNewConversation": "Start New Conversation",
|
||||
"thoughtParseError": "Failed to parse AI response",
|
||||
"thoughtFunctionCall": "Function Call",
|
||||
"thoughtFunctionCall": "Use {}",
|
||||
"thoughtFunctionCallBegin": "Calling tool {}",
|
||||
"thoughtFunctionCallFinish": "{} responded",
|
||||
"aiThought": "AI Thought",
|
||||
"aiThoughtTitle": "Let sn-chan think",
|
||||
"postReferenceUnavailable": "Referenced post is unavailable",
|
||||
@@ -1321,5 +1327,152 @@
|
||||
"popularity": "Popularity",
|
||||
"descendingOrder": "Descending Order",
|
||||
"selectDate": "Select Date",
|
||||
"pinnedPosts": "Pinned Posts"
|
||||
}
|
||||
"pinnedPosts": "Pinned Posts",
|
||||
"thoughtUnpaidHint": "Thinking unavaiable due to unpaid orders",
|
||||
"more": "More",
|
||||
"collapse": "Collapse",
|
||||
"pollConfirmDiscard": "Are you sure you want to leave? All the poll data you're editing will not be saved.",
|
||||
"discard": "Discard",
|
||||
"fund": "Fund",
|
||||
"fundsRecent": "Recent Funds",
|
||||
"fundCreateNew": "Create New",
|
||||
"fundCreateNewHint": "Create a new fund for your message. Select recipients and amount.",
|
||||
"amountOfSplits": "Amount of Splits",
|
||||
"enterNumberOfSplits": "Enter Splits Amount",
|
||||
"orCreateWith": "Or\ncreate with",
|
||||
"unindexedFiles": "Unindexed files",
|
||||
"folder": "Folder",
|
||||
"clearCompleted": "Clear Completed",
|
||||
"contentCantEmpty": "Content cannot be empty",
|
||||
"features": "Features",
|
||||
"unnamed": "Unnamed",
|
||||
"fundEnvelopeLoadFailed": "Failed to load fund envelope",
|
||||
"fundEnvelope": "Fund Envelope",
|
||||
"fundEnvelopeRemaining": "Remaining: {} {}",
|
||||
"fundEnvelopeSplit": "Split: {}",
|
||||
"fundEnvelopeSplitEvenly": "Evenly",
|
||||
"fundEnvelopeSplitRandomly": "Randomly",
|
||||
"fundEnvelopeClaimSuccess": "Fund claimed successfully!",
|
||||
"fundEnvelopeStatusCreated": "Created",
|
||||
"fundEnvelopeStatusPartial": "Partially Claimed",
|
||||
"fundEnvelopeStatusCompleted": "Fully Claimed",
|
||||
"fundEnvelopeStatusExpired": "Expired",
|
||||
"fundEnvelopeStatusUnknown": "Unknown",
|
||||
"fundEnvelopeRecipients": "Recipients ({}/{} claimed)",
|
||||
"fundEnvelopeExpiredDaysAgo": {
|
||||
"one": "Expired {} day ago",
|
||||
"other": "Expired {} days ago"
|
||||
},
|
||||
"fundEnvelopeExpiresSoon": "Expires soon",
|
||||
"fundEnvelopeExpiresInHours": {
|
||||
"one": "Expires in {} hour",
|
||||
"other": "Expires in {} hours"
|
||||
},
|
||||
"fundEnvelopeExpiresInDays": {
|
||||
"one": "Expires in {} day",
|
||||
"other": "Expires in {} days"
|
||||
},
|
||||
"fundEnvelopeRemainingWithSplits": "{} {} / {} splits",
|
||||
"fundEnvelopeUnknownUser": "Unknown User",
|
||||
"deleteSite": "Delete Site",
|
||||
"deleteSiteConfirm": "Are you sure you want to delete this site?",
|
||||
"siteDeletedSuccess": "Site deleted successfully",
|
||||
"siteSlug": "Slug",
|
||||
"siteSlugHint": "my-site",
|
||||
"siteSlugRequired": "Please enter a slug",
|
||||
"siteSlugInvalid": "Slug can only contain lowercase letters, numbers, and dashes",
|
||||
"siteName": "Site Name",
|
||||
"siteNameHint": "My Publication Site",
|
||||
"siteNameRequired": "Please enter a site name",
|
||||
"siteMode": "Mode",
|
||||
"siteModeFullyManaged": "Fully Managed",
|
||||
"siteModeSelfManaged": "Self-Managed",
|
||||
"editPublicationSite": "Edit Publication Site",
|
||||
"deletePublicationSite": "Delete Publication Site",
|
||||
"publicationSiteSavedSuccess": "Publication site saved successfully",
|
||||
"publicationSiteDeleteConfirm": "Are you sure you want to delete this publication site? This action cannot be undone.",
|
||||
"publicationSiteDeletedSuccess": "Publication site deleted successfully",
|
||||
"newPublicationSite": "New Publication Site",
|
||||
"siteDetails": "Site Details",
|
||||
"siteInformation": "Site Information",
|
||||
"siteDomain": "Domain",
|
||||
"siteCreated": "Created",
|
||||
"siteUpdated": "Updated",
|
||||
"failedToLoadSite": "Failed to load site",
|
||||
"sitePages": "Pages",
|
||||
"noPagesYet": "No pages yet",
|
||||
"createFirstPage": "Create your first page to get started",
|
||||
"failedToLoadPages": "Failed to load pages",
|
||||
"fileManagement": "File Management",
|
||||
"siteFiles": "Files",
|
||||
"siteFolder": "Folder",
|
||||
"siteRoot": "Root",
|
||||
"noFilesUploadedYet": "No files uploaded yet",
|
||||
"uploadFirstFile": "Upload your first file to get started",
|
||||
"failedToLoadFiles": "Failed to load files",
|
||||
"noFilesFoundInFolder": "No files found in the selected folder",
|
||||
"fileActions": "File Actions",
|
||||
"purgeFiles": "Purge Files",
|
||||
"purgeFilesDescription": "Remove all uploaded files from the site",
|
||||
"deploySite": "Deploy Site",
|
||||
"deploySiteDescription": "Upload and deploy a new version from ZIP archive",
|
||||
"confirmPurge": "Confirm Purge",
|
||||
"purgeFilesConfirm": "This will permanently delete all files uploaded to this site. This action cannot be undone. Are you sure you want to continue?",
|
||||
"purgeAllFiles": "Purge All Files",
|
||||
"allFilesPurgedSuccess": "All files purged successfully",
|
||||
"failedToPurgeFiles": "Failed to purge files: {}",
|
||||
"siteDeployedSuccess": "Site deployed successfully",
|
||||
"failedToDeploySite": "Failed to deploy site: {}",
|
||||
"createPage": "Create Page",
|
||||
"editPage": "Edit Page",
|
||||
"pageType": "Page Type",
|
||||
"htmlPage": "HTML Page",
|
||||
"redirectPage": "Redirect Page",
|
||||
"pageTypeRequired": "Please select a page type",
|
||||
"pagePath": "Page Path",
|
||||
"pagePathHint": "/about, /contact, etc.",
|
||||
"pagePathRequired": "Please enter a page path",
|
||||
"pagePathInvalid": "Page path can only contain letters, numbers, hyphens, underscores, and slashes",
|
||||
"pagePathMustStartWithSlash": "Page path must start with /",
|
||||
"pagePathNoConsecutiveSlashes": "Page path cannot have consecutive slashes",
|
||||
"pageTitle": "Page Title",
|
||||
"pageTitleHint": "About Us, Contact, etc.",
|
||||
"pageTitleRequired": "Please enter a page title",
|
||||
"pageContentHtml": "Page Content (HTML)",
|
||||
"pageContentHint": "<h1>Hello World</h1><p>This is my page content...</p>",
|
||||
"pageContentRequired": "Please enter HTML content for the page",
|
||||
"redirectTarget": "Redirect Target",
|
||||
"redirectTargetHint": "/new-page, https://example.com, etc.",
|
||||
"redirectTargetRequired": "Please enter a redirect target",
|
||||
"redirectTargetInvalid": "Target must be a relative path (/) or absolute URL (http/https)",
|
||||
"deletePage": "Delete Page",
|
||||
"deletePageConfirm": "Are you sure you want to delete this page?",
|
||||
"savePage": "Save Page",
|
||||
"pageCreatedSuccess": "Page created successfully",
|
||||
"pageUpdatedSuccess": "Page updated successfully",
|
||||
"pageDeletedSuccess": "Page deleted successfully",
|
||||
"uploadFiles": "Upload Files",
|
||||
"uploadPath": "Upload Path",
|
||||
"uploadPathHint": "/ (root) or /assets/images/",
|
||||
"uploadPathRequired": "Please enter an upload path",
|
||||
"uploadPathMustStartWithSlash": "Path must start with /",
|
||||
"uploadPathNoSpaces": "Path cannot contain spaces",
|
||||
"uploadPathNoConsecutiveSlashes": "Path cannot have consecutive slashes",
|
||||
"percentCompleted": "{}% completed",
|
||||
"filesToUpload": "{} files to upload",
|
||||
"fileSizeKb": "Size: {} KB",
|
||||
"uploadingEllipsis": "Uploading...",
|
||||
"uploadFilesCount": {
|
||||
"one": "Upload {} File",
|
||||
"other": "Upload {} Files"
|
||||
},
|
||||
"allUploadsCompleted": "All uploads completed",
|
||||
"someUploadsFailed": "Some uploads failed",
|
||||
"uploadingInProgress": "Uploading in progress",
|
||||
"readyToUpload": "Ready to upload",
|
||||
"allFilesUploadedSuccess": "All files uploaded successfully",
|
||||
"lotteryLastNumberSpecial": "The last selected number will be your special number.",
|
||||
"lotteryMultiplierRequired": "Please enter a multiplier",
|
||||
"lotteryMultiplierRange": "Multiplier must be between 1 and 10",
|
||||
"dropToShare": "Drop to share"
|
||||
}
|
||||
@@ -251,10 +251,10 @@
|
||||
"translatorBadgeName": "翻译者",
|
||||
"translatorBadgeDescription": "协助将 Solar Network 翻译成不同语言",
|
||||
"wallet": "钱包",
|
||||
"walletCurrencyPoints": "新太阳点",
|
||||
"walletCurrencyPoints": "源能点",
|
||||
"walletCurrencyShortPoints": "NSP",
|
||||
"walletCurrencyGolds": "太阳币",
|
||||
"walletCurrencyShortGolds": "TSD",
|
||||
"walletCurrencyGolds": "星辰碎片",
|
||||
"walletCurrencyShortGolds": "SHD",
|
||||
"retry": "重试",
|
||||
"creatorHubUnselectedHint": "选择/创建一个发布者以开始使用。",
|
||||
"relationships": "关系",
|
||||
@@ -585,10 +585,10 @@
|
||||
"unknownChat": "未知聊天",
|
||||
"addAdditionalMessage": "添加附加消息……",
|
||||
"uploadingFiles": "上传文件中……",
|
||||
"sharedSuccessfully": "分享成功!",
|
||||
"shareSuccess": "分享成功!",
|
||||
"shareToSpecificChatSuccess": "成功分享至 {}!",
|
||||
"wouldYouLikeToGoToChat": "是否前往该聊天?",
|
||||
"sharedSuccessfully": "分享成功",
|
||||
"shareSuccess": "分享成功",
|
||||
"shareToSpecificChatSuccess": "成功分享至 {}",
|
||||
"wouldYouLikeToGoToChat": "是否前往该聊天页面?",
|
||||
"no": "否",
|
||||
"yes": "是",
|
||||
"navigateToChat": "前往聊天",
|
||||
@@ -1090,5 +1090,6 @@
|
||||
"thoughtNewConversation": "开始新对话",
|
||||
"thoughtParseError": "解析 AI 响应失败",
|
||||
"aiThought": "寻思",
|
||||
"aiThoughtTitle": "让 SN 酱寻思寻思"
|
||||
}
|
||||
"aiThoughtTitle": "让 SN 酱寻思寻思",
|
||||
"thoughtUnpaidHint": "寻思因为有未支付的订单而被禁用"
|
||||
}
|
||||
1
drift_schemas/app_database/drift_schema_v7.json
Normal file
1
drift_schemas/app_database/drift_schema_v7.json
Normal file
File diff suppressed because one or more lines are too long
@@ -57,7 +57,7 @@ PODS:
|
||||
- firebase_core (4.2.1):
|
||||
- Firebase/CoreOnly (= 12.4.0)
|
||||
- Flutter
|
||||
- firebase_crashlytics (5.0.4):
|
||||
- firebase_crashlytics (5.0.5):
|
||||
- Firebase/Crashlytics (= 12.4.0)
|
||||
- firebase_core
|
||||
- Flutter
|
||||
@@ -140,15 +140,13 @@ PODS:
|
||||
- Flutter
|
||||
- flutter_native_splash (2.4.3):
|
||||
- Flutter
|
||||
- flutter_platform_alert (0.0.1):
|
||||
- Flutter
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- Flutter
|
||||
- flutter_timezone (0.0.1):
|
||||
- Flutter
|
||||
- flutter_udid (0.0.1):
|
||||
- Flutter
|
||||
- SAMKeychain
|
||||
- KeychainAccess
|
||||
- flutter_webrtc (1.2.0):
|
||||
- Flutter
|
||||
- WebRTC-SDK (= 137.7151.04)
|
||||
@@ -216,7 +214,8 @@ PODS:
|
||||
- Flutter
|
||||
- irondash_engine_context (0.0.1):
|
||||
- Flutter
|
||||
- Kingfisher (8.6.1)
|
||||
- KeychainAccess (4.2.2)
|
||||
- Kingfisher (8.6.2)
|
||||
- KingfisherWebP (1.7.2):
|
||||
- Kingfisher (~> 8.0)
|
||||
- libwebp (>= 1.1.0)
|
||||
@@ -250,14 +249,13 @@ PODS:
|
||||
- nanopb/encode (3.30910.0)
|
||||
- native_exif (0.0.1):
|
||||
- Flutter
|
||||
- objective_c (0.0.1):
|
||||
- Flutter
|
||||
- OrderedSet (6.0.3)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- pasteboard (0.0.1):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- pointer_interceptor_ios (0.0.1):
|
||||
- Flutter
|
||||
- PromisesObjC (2.4.0)
|
||||
@@ -269,7 +267,6 @@ PODS:
|
||||
- Flutter
|
||||
- record_ios (1.1.0):
|
||||
- Flutter
|
||||
- SAMKeychain (1.5.3)
|
||||
- SDWebImage (5.21.3):
|
||||
- SDWebImage/Core (= 5.21.3)
|
||||
- SDWebImage/Core (5.21.3)
|
||||
@@ -315,8 +312,6 @@ PODS:
|
||||
- Flutter
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- volume_controller (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
- WebRTC-SDK (137.7151.04)
|
||||
@@ -338,7 +333,6 @@ DEPENDENCIES:
|
||||
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_platform_alert (from `.symlinks/plugins/flutter_platform_alert/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`)
|
||||
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
||||
@@ -353,9 +347,9 @@ DEPENDENCIES:
|
||||
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
||||
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
|
||||
- native_exif (from `.symlinks/plugins/native_exif/ios`)
|
||||
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
|
||||
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
@@ -368,7 +362,6 @@ DEPENDENCIES:
|
||||
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
|
||||
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
@@ -390,6 +383,7 @@ SPEC REPOS:
|
||||
- GoogleAppMeasurement
|
||||
- GoogleDataTransport
|
||||
- GoogleUtilities
|
||||
- KeychainAccess
|
||||
- Kingfisher
|
||||
- KingfisherWebP
|
||||
- libwebp
|
||||
@@ -397,7 +391,6 @@ SPEC REPOS:
|
||||
- OrderedSet
|
||||
- PromisesObjC
|
||||
- PromisesSwift
|
||||
- SAMKeychain
|
||||
- SDWebImage
|
||||
- sqlite3
|
||||
- SwiftyGif
|
||||
@@ -434,8 +427,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
flutter_platform_alert:
|
||||
:path: ".symlinks/plugins/flutter_platform_alert/ios"
|
||||
flutter_secure_storage:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
flutter_timezone:
|
||||
@@ -460,12 +451,12 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/media_kit_video/ios"
|
||||
native_exif:
|
||||
:path: ".symlinks/plugins/native_exif/ios"
|
||||
objective_c:
|
||||
:path: ".symlinks/plugins/objective_c/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
pasteboard:
|
||||
:path: ".symlinks/plugins/pasteboard/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
pointer_interceptor_ios:
|
||||
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
|
||||
protocol_handler_ios:
|
||||
@@ -490,8 +481,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
volume_controller:
|
||||
:path: ".symlinks/plugins/volume_controller/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
@@ -507,7 +496,7 @@ SPEC CHECKSUMS:
|
||||
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
|
||||
firebase_analytics: 67fbdd9f3c04e55048024f3da21cfc36f05e56cf
|
||||
firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594
|
||||
firebase_crashlytics: 83c7467d7534975a4d779af43bd226d0a4616464
|
||||
firebase_crashlytics: c039028126cb45e32f4c217aa392408b0963d081
|
||||
firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde
|
||||
FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f
|
||||
FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
|
||||
@@ -524,10 +513,9 @@ SPEC CHECKSUMS:
|
||||
flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619
|
||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_platform_alert: bf3b5fcd4ac14bd637e20527e9c471633071afd3
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
|
||||
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
|
||||
flutter_udid: 92a5d31fe0526b7b6002a2318df702e12e7eb300
|
||||
flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22
|
||||
gal: baecd024ebfd13c441269ca7404792a7152fde89
|
||||
GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1
|
||||
@@ -536,7 +524,8 @@ SPEC CHECKSUMS:
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
||||
Kingfisher: 7ac7a7288653787a54206b11a3c74f49ab650f1f
|
||||
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
|
||||
Kingfisher: 23d18f54677d973b713e54ce6a8f5eef6e7056ba
|
||||
KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
livekit_client: 86c8af579274e4b7a215185a8080db2d4e176f40
|
||||
@@ -545,17 +534,16 @@ SPEC CHECKSUMS:
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
native_exif: 0eb73d3d5b3ca892719228df8d2d1b13d1ae396c
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
|
||||
protocol_handler_ios: 59f23ee71f3ec602d67902ca7f669a80957888d5
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
@@ -567,7 +555,6 @@ SPEC CHECKSUMS:
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
|
||||
|
||||
|
||||
@@ -140,21 +140,29 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
guard !attachmentUrls.isEmpty else {
|
||||
print("Invalid URLs for attachments: \(attachmentUrls)")
|
||||
self.contentHandler?(content)
|
||||
return
|
||||
}
|
||||
|
||||
let targetSize = 512
|
||||
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
var attachments: [UNNotificationAttachment] = []
|
||||
let lock = NSLock() // To synchronize access to the attachments array
|
||||
|
||||
for attachmentUrl in attachmentUrls {
|
||||
guard let remoteUrl = URL(string: attachmentUrl) else {
|
||||
print("Invalid URL for attachment: \(attachmentUrl)")
|
||||
continue // Skip this URL and move to the next one
|
||||
continue
|
||||
}
|
||||
|
||||
dispatchGroup.enter()
|
||||
|
||||
KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [
|
||||
.processor(scaleProcessor)
|
||||
] : nil) { [weak self] result in
|
||||
defer { dispatchGroup.leave() }
|
||||
guard let self = self else { return }
|
||||
|
||||
switch result {
|
||||
@@ -166,49 +174,34 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
do {
|
||||
// Write the image data to a temporary file for UNNotificationAttachment
|
||||
try retrievalResult.image.pngData()?.write(to: cachedFileUrl)
|
||||
self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: attachmentUrl)
|
||||
|
||||
if let attachment = try? UNNotificationAttachment(identifier: attachmentUrl, url: cachedFileUrl, options: [
|
||||
UNNotificationAttachmentOptionsTypeHintKey: type?.identifier as Any,
|
||||
UNNotificationAttachmentOptionsThumbnailHiddenKey: 0,
|
||||
]) {
|
||||
lock.lock()
|
||||
attachments.append(attachment)
|
||||
lock.unlock()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to write media to temporary file: \(error.localizedDescription)")
|
||||
self.contentHandler?(content)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
print("Failed to retrieve image: \(error.localizedDescription)")
|
||||
self.contentHandler?(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func attachLocalMedia(to content: UNMutableNotificationContent, fileType type: String?, from localUrl: URL, withIdentifier identifier: String) {
|
||||
do {
|
||||
let attachment = try UNNotificationAttachment(identifier: identifier, url: localUrl, options: [
|
||||
UNNotificationAttachmentOptionsTypeHintKey: type as Any,
|
||||
UNNotificationAttachmentOptionsThumbnailHiddenKey: 0,
|
||||
])
|
||||
content.attachments = [attachment]
|
||||
} catch let error as NSError {
|
||||
// Log detailed error information
|
||||
print("Failed to create attachment from file at \(localUrl.path)")
|
||||
print("Error: \(error.localizedDescription)")
|
||||
|
||||
// Check specific error codes if needed
|
||||
if error.domain == NSCocoaErrorDomain {
|
||||
switch error.code {
|
||||
case NSFileReadNoSuchFileError:
|
||||
print("File does not exist at \(localUrl.path)")
|
||||
case NSFileReadNoPermissionError:
|
||||
print("No permission to read file at \(localUrl.path)")
|
||||
default:
|
||||
print("Unhandled file error: \(error.code)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call content handler regardless of success or failure
|
||||
self.contentHandler?(content)
|
||||
dispatchGroup.notify(queue: .main) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
content.attachments = attachments
|
||||
self.contentHandler?(content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private func createMessageIntent(with sender: INPerson, meta: [AnyHashable: Any], body: String) -> INSendMessageIntent {
|
||||
INSendMessageIntent(
|
||||
recipients: nil,
|
||||
|
||||
@@ -2,17 +2,19 @@ import 'dart:convert';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/database/draft.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
|
||||
part 'drift_db.g.dart';
|
||||
|
||||
// Define the database
|
||||
@DriftDatabase(tables: [ChatMessages, PostDrafts])
|
||||
@DriftDatabase(tables: [ChatRooms, ChatMembers, ChatMessages, PostDrafts])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase(super.e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 7;
|
||||
int get schemaVersion => 8;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -55,6 +57,11 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (from < 8) {
|
||||
// Add new tables for separate sender and room data
|
||||
await m.createTable(chatRooms);
|
||||
await m.createTable(chatMembers);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -153,6 +160,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
String roomId,
|
||||
String query, {
|
||||
bool? withAttachments,
|
||||
Future<SnAccount?> Function(String accountId)? fetchAccount,
|
||||
}) async {
|
||||
var selectStatement = select(chatMessages)
|
||||
..where((m) => m.roomId.equals(roomId));
|
||||
@@ -178,7 +186,11 @@ class AppDatabase extends _$AppDatabase {
|
||||
await (selectStatement
|
||||
..orderBy([(m) => OrderingTerm.desc(m.createdAt)]))
|
||||
.get();
|
||||
return messages.map((msg) => companionToMessage(msg)).toList();
|
||||
final messageFutures =
|
||||
messages
|
||||
.map((msg) => companionToMessage(msg, fetchAccount: fetchAccount))
|
||||
.toList();
|
||||
return await Future.wait(messageFutures);
|
||||
}
|
||||
|
||||
// Convert between Drift and model objects
|
||||
@@ -206,12 +218,88 @@ class AppDatabase extends _$AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
LocalChatMessage companionToMessage(ChatMessage dbMessage) {
|
||||
Future<LocalChatMessage> companionToMessage(
|
||||
ChatMessage dbMessage, {
|
||||
Future<SnAccount?> Function(String accountId)? fetchAccount,
|
||||
}) async {
|
||||
final data = jsonDecode(dbMessage.data);
|
||||
SnChatMember? sender;
|
||||
try {
|
||||
final senderRow =
|
||||
await (select(chatMembers)
|
||||
..where((m) => m.id.equals(dbMessage.senderId))).getSingle();
|
||||
SnAccount senderAccount;
|
||||
senderAccount = SnAccount.fromJson(senderRow.account);
|
||||
|
||||
sender = SnChatMember(
|
||||
id: senderRow.id,
|
||||
chatRoomId: senderRow.chatRoomId,
|
||||
accountId: senderRow.accountId,
|
||||
account: senderAccount,
|
||||
nick: senderRow.nick,
|
||||
role: senderRow.role,
|
||||
notify: senderRow.notify,
|
||||
joinedAt: senderRow.joinedAt,
|
||||
breakUntil: senderRow.breakUntil,
|
||||
timeoutUntil: senderRow.timeoutUntil,
|
||||
isBot: senderRow.isBot,
|
||||
status: null,
|
||||
lastTyped: senderRow.lastTyped,
|
||||
createdAt: senderRow.createdAt,
|
||||
updatedAt: senderRow.updatedAt,
|
||||
deletedAt: senderRow.deletedAt,
|
||||
chatRoom: null,
|
||||
);
|
||||
} catch (err) {
|
||||
// Fallback to dummy sender with senderId as display name
|
||||
sender = SnChatMember(
|
||||
id: 'unknown',
|
||||
chatRoomId: dbMessage.roomId,
|
||||
accountId: dbMessage.senderId,
|
||||
account: SnAccount(
|
||||
id: 'unknown',
|
||||
name: 'unknown',
|
||||
nick: dbMessage.senderId, // Show the ID instead of Unknown
|
||||
profile: SnAccountProfile(
|
||||
picture: null,
|
||||
id: 'unknown',
|
||||
experience: 0,
|
||||
level: 1,
|
||||
levelingProgress: 0.0,
|
||||
background: null,
|
||||
verification: null,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
deletedAt: null,
|
||||
),
|
||||
language: '',
|
||||
isSuperuser: false,
|
||||
automatedId: null,
|
||||
perkSubscription: null,
|
||||
deletedAt: null,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
nick: dbMessage.senderId, // Show the senderId as fallback
|
||||
role: 0,
|
||||
notify: 0,
|
||||
joinedAt: null,
|
||||
breakUntil: null,
|
||||
timeoutUntil: null,
|
||||
isBot: false,
|
||||
status: null,
|
||||
lastTyped: null,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
deletedAt: null,
|
||||
chatRoom: null,
|
||||
);
|
||||
}
|
||||
return LocalChatMessage(
|
||||
id: dbMessage.id,
|
||||
roomId: dbMessage.roomId,
|
||||
senderId: dbMessage.senderId,
|
||||
sender: sender,
|
||||
data: data,
|
||||
createdAt: dbMessage.createdAt,
|
||||
status: dbMessage.status,
|
||||
@@ -231,6 +319,85 @@ class AppDatabase extends _$AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
ChatRoomsCompanion companionFromRoom(SnChatRoom room) {
|
||||
return ChatRoomsCompanion(
|
||||
id: Value(room.id),
|
||||
name: Value(room.name),
|
||||
description: Value(room.description),
|
||||
type: Value(room.type),
|
||||
isPublic: Value(room.isPublic),
|
||||
isCommunity: Value(room.isCommunity),
|
||||
picture: Value(room.picture?.toJson()),
|
||||
background: Value(room.background?.toJson()),
|
||||
realmId: Value(room.realmId),
|
||||
createdAt: Value(room.createdAt),
|
||||
updatedAt: Value(room.updatedAt),
|
||||
deletedAt: Value(room.deletedAt),
|
||||
);
|
||||
}
|
||||
|
||||
ChatMembersCompanion companionFromMember(SnChatMember member) {
|
||||
return ChatMembersCompanion(
|
||||
id: Value(member.id),
|
||||
chatRoomId: Value(member.chatRoomId),
|
||||
accountId: Value(member.accountId),
|
||||
account: Value(member.account.toJson()),
|
||||
nick: Value(member.nick),
|
||||
role: Value(member.role),
|
||||
notify: Value(member.notify),
|
||||
joinedAt: Value(member.joinedAt),
|
||||
breakUntil: Value(member.breakUntil),
|
||||
timeoutUntil: Value(member.timeoutUntil),
|
||||
isBot: Value(member.isBot),
|
||||
status: Value(
|
||||
member.status == null ? null : jsonEncode(member.status!.toJson()),
|
||||
),
|
||||
lastTyped: Value(member.lastTyped),
|
||||
createdAt: Value(member.createdAt),
|
||||
updatedAt: Value(member.updatedAt),
|
||||
deletedAt: Value(member.deletedAt),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveChatRooms(List<SnChatRoom> rooms) async {
|
||||
await transaction(() async {
|
||||
// 1. Identify rooms to remove
|
||||
final remoteRoomIds = rooms.map((r) => r.id).toSet();
|
||||
final currentRooms = await select(chatRooms).get();
|
||||
final currentRoomIds = currentRooms.map((r) => r.id).toSet();
|
||||
final idsToRemove = currentRoomIds.difference(remoteRoomIds);
|
||||
|
||||
if (idsToRemove.isNotEmpty) {
|
||||
final idsList = idsToRemove.toList();
|
||||
// Remove messages
|
||||
await (delete(chatMessages)..where((t) => t.roomId.isIn(idsList))).go();
|
||||
// Remove members
|
||||
await (delete(chatMembers)
|
||||
..where((t) => t.chatRoomId.isIn(idsList))).go();
|
||||
// Remove rooms
|
||||
await (delete(chatRooms)..where((t) => t.id.isIn(idsList))).go();
|
||||
}
|
||||
|
||||
// 2. Upsert remote rooms
|
||||
await batch((batch) {
|
||||
for (final room in rooms) {
|
||||
batch.insert(
|
||||
chatRooms,
|
||||
companionFromRoom(room),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
for (final member in room.members ?? []) {
|
||||
batch.insert(
|
||||
chatMembers,
|
||||
companionFromMember(member),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Methods for post drafts
|
||||
Future<List<SnPost>> getAllPostDrafts() async {
|
||||
final drafts = await select(postDrafts).get();
|
||||
@@ -276,4 +443,19 @@ class AppDatabase extends _$AppDatabase {
|
||||
return await (select(postDrafts)
|
||||
..where((tbl) => tbl.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> saveMember(SnChatMember member) async {
|
||||
await into(
|
||||
chatMembers,
|
||||
).insert(companionFromMember(member), mode: InsertMode.insertOrReplace);
|
||||
}
|
||||
|
||||
Future<int> saveMessageWithSender(LocalChatMessage message) async {
|
||||
// First save the sender if it exists
|
||||
if (message.sender != null) {
|
||||
await saveMember(message.sender!);
|
||||
}
|
||||
// Then save the message
|
||||
return await saveMessage(messageToCompanion(message));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
616
lib/database/drift_db.steps.dart
Normal file
616
lib/database/drift_db.steps.dart
Normal 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),
|
||||
);
|
||||
@@ -36,10 +36,52 @@ class ListMapConverter
|
||||
String toSql(List<Map<String, dynamic>> value) => json.encode(value);
|
||||
}
|
||||
|
||||
class ChatRooms extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get name => text().nullable()();
|
||||
TextColumn get description => text().nullable()();
|
||||
IntColumn get type => integer()();
|
||||
BoolColumn get isPublic =>
|
||||
boolean().nullable().withDefault(const Constant(false))();
|
||||
BoolColumn get isCommunity =>
|
||||
boolean().nullable().withDefault(const Constant(false))();
|
||||
TextColumn get picture => text().map(const MapConverter()).nullable()();
|
||||
TextColumn get background => text().map(const MapConverter()).nullable()();
|
||||
TextColumn get realmId => text().nullable()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get deletedAt => dateTime().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class ChatMembers extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get chatRoomId => text().references(ChatRooms, #id)();
|
||||
TextColumn get accountId => text()();
|
||||
TextColumn get account => text().map(const MapConverter())();
|
||||
TextColumn get nick => text().nullable()();
|
||||
IntColumn get role => integer()();
|
||||
IntColumn get notify => integer()();
|
||||
DateTimeColumn get joinedAt => dateTime().nullable()();
|
||||
DateTimeColumn get breakUntil => dateTime().nullable()();
|
||||
DateTimeColumn get timeoutUntil => dateTime().nullable()();
|
||||
BoolColumn get isBot => boolean()();
|
||||
TextColumn get status => text().nullable()();
|
||||
DateTimeColumn get lastTyped => dateTime().nullable()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get deletedAt => dateTime().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class ChatMessages extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get roomId => text()();
|
||||
TextColumn get senderId => text()();
|
||||
TextColumn get roomId => text().references(ChatRooms, #id)();
|
||||
TextColumn get senderId => text().references(ChatMembers, #id)();
|
||||
TextColumn get content => text().nullable()();
|
||||
TextColumn get nonce => text().nullable()();
|
||||
TextColumn get data => text()();
|
||||
@@ -72,6 +114,7 @@ class LocalChatMessage {
|
||||
final String id;
|
||||
final String roomId;
|
||||
final String senderId;
|
||||
final SnChatMember? sender;
|
||||
final Map<String, dynamic> data;
|
||||
final DateTime createdAt;
|
||||
MessageStatus status;
|
||||
@@ -94,6 +137,7 @@ class LocalChatMessage {
|
||||
required this.id,
|
||||
required this.roomId,
|
||||
required this.senderId,
|
||||
required this.sender,
|
||||
required this.data,
|
||||
required this.createdAt,
|
||||
required this.nonce,
|
||||
@@ -114,7 +158,12 @@ class LocalChatMessage {
|
||||
});
|
||||
|
||||
SnChatMessage toRemoteMessage() {
|
||||
return SnChatMessage.fromJson(data);
|
||||
if (sender == null) {
|
||||
throw Exception('Cannot create remote message without sender');
|
||||
}
|
||||
final msgData = Map<String, dynamic>.from(data);
|
||||
msgData['sender'] = sender!.toJson();
|
||||
return SnChatMessage.fromJson(msgData);
|
||||
}
|
||||
|
||||
static LocalChatMessage fromRemoteMessage(
|
||||
@@ -122,11 +171,26 @@ class LocalChatMessage {
|
||||
MessageStatus status, {
|
||||
String? nonce,
|
||||
}) {
|
||||
final jsonData = message.toJson();
|
||||
jsonData.remove('sender');
|
||||
// Ensure proper defaults for collections to prevent type cast errors
|
||||
if (jsonData['meta'] == null) jsonData['meta'] = <String, dynamic>{};
|
||||
if (jsonData['members_mentioned'] == null) {
|
||||
jsonData['members_mentioned'] = <String>[];
|
||||
}
|
||||
if (jsonData['attachments'] == null) {
|
||||
jsonData['attachments'] = <Map<String, dynamic>>[];
|
||||
}
|
||||
if (jsonData['reactions'] == null) {
|
||||
jsonData['reactions'] = <Map<String, dynamic>>[];
|
||||
}
|
||||
final msgData = Map<String, dynamic>.from(jsonData);
|
||||
return LocalChatMessage(
|
||||
id: message.id,
|
||||
roomId: message.chatRoomId,
|
||||
senderId: message.senderId,
|
||||
data: message.toJson(),
|
||||
sender: message.sender,
|
||||
data: msgData,
|
||||
createdAt: message.createdAt,
|
||||
status: status,
|
||||
nonce: nonce ?? message.nonce,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:island/models/activity.dart';
|
||||
import 'package:island/models/auth.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/wallet.dart';
|
||||
@@ -263,3 +264,15 @@ sealed class SnSocialCreditRecord with _$SnSocialCreditRecord {
|
||||
factory SnSocialCreditRecord.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnSocialCreditRecordFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnFriendOverviewItem with _$SnFriendOverviewItem {
|
||||
const factory SnFriendOverviewItem({
|
||||
required SnAccount account,
|
||||
required SnAccountStatus status,
|
||||
required List<SnPresenceActivity> activities,
|
||||
}) = _SnFriendOverviewItem;
|
||||
|
||||
factory SnFriendOverviewItem.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnFriendOverviewItemFromJson(json);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -449,3 +449,22 @@ Map<String, dynamic> _$SnSocialCreditRecordToJson(
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_SnFriendOverviewItem _$SnFriendOverviewItemFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _SnFriendOverviewItem(
|
||||
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
|
||||
status: SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
|
||||
activities:
|
||||
(json['activities'] as List<dynamic>)
|
||||
.map((e) => SnPresenceActivity.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnFriendOverviewItemToJson(
|
||||
_SnFriendOverviewItem instance,
|
||||
) => <String, dynamic>{
|
||||
'account': instance.account.toJson(),
|
||||
'status': instance.status.toJson(),
|
||||
'activities': instance.activities.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
@@ -46,6 +46,18 @@ sealed class SnPoll with _$SnPoll {
|
||||
}) = _SnPoll;
|
||||
|
||||
factory SnPoll.fromJson(Map<String, dynamic> json) => _$SnPollFromJson(json);
|
||||
|
||||
factory SnPoll.fromPollWithStats(SnPollWithStats pollWithStats) => SnPoll(
|
||||
id: pollWithStats.id,
|
||||
questions: pollWithStats.questions,
|
||||
title: pollWithStats.title,
|
||||
description: pollWithStats.description,
|
||||
endedAt: pollWithStats.endedAt,
|
||||
publisherId: pollWithStats.publisherId,
|
||||
createdAt: pollWithStats.createdAt,
|
||||
updatedAt: pollWithStats.updatedAt,
|
||||
deletedAt: pollWithStats.deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
41
lib/models/publication_site.dart
Normal file
41
lib/models/publication_site.dart
Normal 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 }
|
||||
587
lib/models/publication_site.freezed.dart
Normal file
587
lib/models/publication_site.freezed.dart
Normal 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
|
||||
60
lib/models/publication_site.g.dart
Normal file
60
lib/models/publication_site.g.dart
Normal 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
23
lib/models/reference.dart
Normal 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);
|
||||
}
|
||||
319
lib/models/reference.freezed.dart
Normal file
319
lib/models/reference.freezed.dart
Normal 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
|
||||
41
lib/models/reference.g.dart
Normal file
41
lib/models/reference.g.dart
Normal 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
25
lib/models/site_file.dart
Normal 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);
|
||||
}
|
||||
539
lib/models/site_file.freezed.dart
Normal file
539
lib/models/site_file.freezed.dart
Normal 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
|
||||
29
lib/models/site_file.g.dart
Normal file
29
lib/models/site_file.g.dart
Normal 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};
|
||||
@@ -38,6 +38,31 @@ class ThinkingChunkTypeConverter
|
||||
int toJson(ThinkingChunkType object) => object.value;
|
||||
}
|
||||
|
||||
enum ThinkingMessagePartType {
|
||||
text(0),
|
||||
functionCall(1),
|
||||
functionResult(2);
|
||||
|
||||
const ThinkingMessagePartType(this.value);
|
||||
final int value;
|
||||
|
||||
static ThinkingMessagePartType fromValue(int value) {
|
||||
return values.firstWhere((e) => e.value == value, orElse: () => text);
|
||||
}
|
||||
}
|
||||
|
||||
class ThinkingMessagePartTypeConverter
|
||||
implements JsonConverter<ThinkingMessagePartType, int> {
|
||||
const ThinkingMessagePartTypeConverter();
|
||||
|
||||
@override
|
||||
ThinkingMessagePartType fromJson(int json) =>
|
||||
ThinkingMessagePartType.fromValue(json);
|
||||
|
||||
@override
|
||||
int toJson(ThinkingMessagePartType object) => object.value;
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class StreamThinkingRequest with _$StreamThinkingRequest {
|
||||
const factory StreamThinkingRequest({
|
||||
@@ -46,6 +71,7 @@ sealed class StreamThinkingRequest with _$StreamThinkingRequest {
|
||||
@Default([]) List<String> accpetProposals,
|
||||
List<String>? attachedPosts,
|
||||
List<Map<String, dynamic>>? attachedMessages,
|
||||
@JsonKey(name: 'service_id') String? serviceId,
|
||||
}) = _StreamThinkingRequest;
|
||||
|
||||
factory StreamThinkingRequest.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -77,6 +103,43 @@ sealed class SnThinkingChunk with _$SnThinkingChunk {
|
||||
_$SnThinkingChunkFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnFunctionCall with _$SnFunctionCall {
|
||||
const factory SnFunctionCall({
|
||||
required String id,
|
||||
required String name,
|
||||
required String arguments,
|
||||
}) = _SnFunctionCall;
|
||||
|
||||
factory SnFunctionCall.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnFunctionCallFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnFunctionResult with _$SnFunctionResult {
|
||||
const factory SnFunctionResult({
|
||||
required String callId,
|
||||
required dynamic result,
|
||||
required bool isError,
|
||||
}) = _SnFunctionResult;
|
||||
|
||||
factory SnFunctionResult.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnFunctionResultFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnThinkingMessagePart with _$SnThinkingMessagePart {
|
||||
const factory SnThinkingMessagePart({
|
||||
@ThinkingMessagePartTypeConverter() required ThinkingMessagePartType type,
|
||||
String? text,
|
||||
SnFunctionCall? functionCall,
|
||||
SnFunctionResult? functionResult,
|
||||
}) = _SnThinkingMessagePart;
|
||||
|
||||
factory SnThinkingMessagePart.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnThinkingMessagePartFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnThinkingSequence with _$SnThinkingSequence {
|
||||
const factory SnThinkingSequence({
|
||||
@@ -98,9 +161,8 @@ sealed class SnThinkingSequence with _$SnThinkingSequence {
|
||||
sealed class SnThinkingThought with _$SnThinkingThought {
|
||||
const factory SnThinkingThought({
|
||||
required String id,
|
||||
String? content,
|
||||
@Default([]) List<SnThinkingMessagePart> parts,
|
||||
@Default([]) List<SnCloudFile> files,
|
||||
@Default([]) List<SnThinkingChunk> chunks,
|
||||
@ThinkingThoughtRoleConverter() required ThinkingThoughtRole role,
|
||||
int? tokenCount,
|
||||
String? modelName,
|
||||
@@ -114,3 +176,26 @@ sealed class SnThinkingThought with _$SnThinkingThought {
|
||||
factory SnThinkingThought.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnThinkingThoughtFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class ThoughtService with _$ThoughtService {
|
||||
const factory ThoughtService({
|
||||
@JsonKey(name: 'service_id') required String serviceId,
|
||||
required double billingMultiplier,
|
||||
required int perkLevel,
|
||||
}) = _ThoughtService;
|
||||
|
||||
factory ThoughtService.fromJson(Map<String, dynamic> json) =>
|
||||
_$ThoughtServiceFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class ThoughtServicesResponse with _$ThoughtServicesResponse {
|
||||
const factory ThoughtServicesResponse({
|
||||
@JsonKey(name: 'default_service') required String defaultService,
|
||||
required List<ThoughtService> services,
|
||||
}) = _ThoughtServicesResponse;
|
||||
|
||||
factory ThoughtServicesResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$ThoughtServicesResponseFromJson(json);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ _StreamThinkingRequest _$StreamThinkingRequestFromJson(
|
||||
(json['attached_messages'] as List<dynamic>?)
|
||||
?.map((e) => e as Map<String, dynamic>)
|
||||
.toList(),
|
||||
serviceId: json['service_id'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$StreamThinkingRequestToJson(
|
||||
@@ -34,6 +35,7 @@ Map<String, dynamic> _$StreamThinkingRequestToJson(
|
||||
'accpet_proposals': instance.accpetProposals,
|
||||
'attached_posts': instance.attachedPosts,
|
||||
'attached_messages': instance.attachedMessages,
|
||||
'service_id': instance.serviceId,
|
||||
};
|
||||
|
||||
_SnThinkingChunk _$SnThinkingChunkFromJson(Map<String, dynamic> json) =>
|
||||
@@ -50,6 +52,64 @@ Map<String, dynamic> _$SnThinkingChunkToJson(_SnThinkingChunk instance) =>
|
||||
'data': instance.data,
|
||||
};
|
||||
|
||||
_SnFunctionCall _$SnFunctionCallFromJson(Map<String, dynamic> json) =>
|
||||
_SnFunctionCall(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
arguments: json['arguments'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnFunctionCallToJson(_SnFunctionCall instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'arguments': instance.arguments,
|
||||
};
|
||||
|
||||
_SnFunctionResult _$SnFunctionResultFromJson(Map<String, dynamic> json) =>
|
||||
_SnFunctionResult(
|
||||
callId: json['call_id'] as String,
|
||||
result: json['result'],
|
||||
isError: json['is_error'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnFunctionResultToJson(_SnFunctionResult instance) =>
|
||||
<String, dynamic>{
|
||||
'call_id': instance.callId,
|
||||
'result': instance.result,
|
||||
'is_error': instance.isError,
|
||||
};
|
||||
|
||||
_SnThinkingMessagePart _$SnThinkingMessagePartFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _SnThinkingMessagePart(
|
||||
type: const ThinkingMessagePartTypeConverter().fromJson(
|
||||
(json['type'] as num).toInt(),
|
||||
),
|
||||
text: json['text'] as String?,
|
||||
functionCall:
|
||||
json['function_call'] == null
|
||||
? null
|
||||
: SnFunctionCall.fromJson(
|
||||
json['function_call'] as Map<String, dynamic>,
|
||||
),
|
||||
functionResult:
|
||||
json['function_result'] == null
|
||||
? null
|
||||
: SnFunctionResult.fromJson(
|
||||
json['function_result'] as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnThinkingMessagePartToJson(
|
||||
_SnThinkingMessagePart instance,
|
||||
) => <String, dynamic>{
|
||||
'type': const ThinkingMessagePartTypeConverter().toJson(instance.type),
|
||||
'text': instance.text,
|
||||
'function_call': instance.functionCall?.toJson(),
|
||||
'function_result': instance.functionResult?.toJson(),
|
||||
};
|
||||
|
||||
_SnThinkingSequence _$SnThinkingSequenceFromJson(Map<String, dynamic> json) =>
|
||||
_SnThinkingSequence(
|
||||
id: json['id'] as String,
|
||||
@@ -80,17 +140,19 @@ Map<String, dynamic> _$SnThinkingSequenceToJson(_SnThinkingSequence instance) =>
|
||||
_SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
|
||||
_SnThinkingThought(
|
||||
id: json['id'] as String,
|
||||
content: json['content'] as String?,
|
||||
parts:
|
||||
(json['parts'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) =>
|
||||
SnThinkingMessagePart.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList() ??
|
||||
const [],
|
||||
files:
|
||||
(json['files'] as List<dynamic>?)
|
||||
?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
chunks:
|
||||
(json['chunks'] as List<dynamic>?)
|
||||
?.map((e) => SnThinkingChunk.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
role: const ThinkingThoughtRoleConverter().fromJson(
|
||||
(json['role'] as num).toInt(),
|
||||
),
|
||||
@@ -114,9 +176,8 @@ _SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
|
||||
Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'content': instance.content,
|
||||
'parts': instance.parts.map((e) => e.toJson()).toList(),
|
||||
'files': instance.files.map((e) => e.toJson()).toList(),
|
||||
'chunks': instance.chunks.map((e) => e.toJson()).toList(),
|
||||
'role': const ThinkingThoughtRoleConverter().toJson(instance.role),
|
||||
'token_count': instance.tokenCount,
|
||||
'model_name': instance.modelName,
|
||||
@@ -126,3 +187,34 @@ Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_ThoughtService _$ThoughtServiceFromJson(Map<String, dynamic> json) =>
|
||||
_ThoughtService(
|
||||
serviceId: json['service_id'] as String,
|
||||
billingMultiplier: (json['billing_multiplier'] as num).toDouble(),
|
||||
perkLevel: (json['perk_level'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ThoughtServiceToJson(_ThoughtService instance) =>
|
||||
<String, dynamic>{
|
||||
'service_id': instance.serviceId,
|
||||
'billing_multiplier': instance.billingMultiplier,
|
||||
'perk_level': instance.perkLevel,
|
||||
};
|
||||
|
||||
_ThoughtServicesResponse _$ThoughtServicesResponseFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _ThoughtServicesResponse(
|
||||
defaultService: json['default_service'] as String,
|
||||
services:
|
||||
(json['services'] as List<dynamic>)
|
||||
.map((e) => ThoughtService.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ThoughtServicesResponseToJson(
|
||||
_ThoughtServicesResponse instance,
|
||||
) => <String, dynamic>{
|
||||
'default_service': instance.defaultService,
|
||||
'services': instance.services.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
@@ -176,6 +176,8 @@ sealed class SnWalletFund with _$SnWalletFund {
|
||||
required String id,
|
||||
required String currency,
|
||||
required double totalAmount,
|
||||
required double remainingAmount,
|
||||
required int amountOfSplits,
|
||||
required int splitType, // 0: even, 1: random
|
||||
required int
|
||||
status, // 0: created, 1: partially claimed, 2: fully claimed, 3: expired
|
||||
@@ -184,6 +186,7 @@ sealed class SnWalletFund with _$SnWalletFund {
|
||||
required SnAccount? creatorAccount,
|
||||
required DateTime expiredAt,
|
||||
required List<SnWalletFundRecipient> recipients,
|
||||
required bool isOpen,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
|
||||
@@ -2553,9 +2553,9 @@ $SnWalletSubscriptionCopyWith<$Res>? get subscription {
|
||||
/// @nodoc
|
||||
mixin _$SnWalletFund {
|
||||
|
||||
String get id; String get currency; double get totalAmount; int get splitType;// 0: even, 1: random
|
||||
String get id; String get currency; double get totalAmount; double get remainingAmount; int get amountOfSplits; int get splitType;// 0: even, 1: random
|
||||
int get status;// 0: created, 1: partially claimed, 2: fully claimed, 3: expired
|
||||
String? get message; String get creatorAccountId; SnAccount? get creatorAccount; DateTime get expiredAt; List<SnWalletFundRecipient> get recipients; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
|
||||
String? get message; String get creatorAccountId; SnAccount? get creatorAccount; DateTime get expiredAt; List<SnWalletFundRecipient> get recipients; bool get isOpen; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
|
||||
/// Create a copy of SnWalletFund
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -2568,16 +2568,16 @@ $SnWalletFundCopyWith<SnWalletFund> get copyWith => _$SnWalletFundCopyWithImpl<S
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other.recipients, recipients)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.remainingAmount, remainingAmount) || other.remainingAmount == remainingAmount)&&(identical(other.amountOfSplits, amountOfSplits) || other.amountOfSplits == amountOfSplits)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other.recipients, recipients)&&(identical(other.isOpen, isOpen) || other.isOpen == isOpen)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(recipients),createdAt,updatedAt,deletedAt);
|
||||
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,remainingAmount,amountOfSplits,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(recipients),isOpen,createdAt,updatedAt,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, remainingAmount: $remainingAmount, amountOfSplits: $amountOfSplits, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, isOpen: $isOpen, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
@@ -2588,7 +2588,7 @@ abstract mixin class $SnWalletFundCopyWith<$Res> {
|
||||
factory $SnWalletFundCopyWith(SnWalletFund value, $Res Function(SnWalletFund) _then) = _$SnWalletFundCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
@@ -2605,19 +2605,22 @@ class _$SnWalletFundCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnWalletFund
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? remainingAmount = null,Object? amountOfSplits = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? isOpen = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
|
||||
as String,totalAmount: null == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable
|
||||
as double,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
|
||||
as double,remainingAmount: null == remainingAmount ? _self.remainingAmount : remainingAmount // ignore: cast_nullable_to_non_nullable
|
||||
as double,amountOfSplits: null == amountOfSplits ? _self.amountOfSplits : amountOfSplits // ignore: cast_nullable_to_non_nullable
|
||||
as int,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
|
||||
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||
as int,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||
as String?,creatorAccountId: null == creatorAccountId ? _self.creatorAccountId : creatorAccountId // ignore: cast_nullable_to_non_nullable
|
||||
as String,creatorAccount: freezed == creatorAccount ? _self.creatorAccount : creatorAccount // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount?,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,recipients: null == recipients ? _self.recipients : recipients // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnWalletFundRecipient>,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnWalletFundRecipient>,isOpen: null == isOpen ? _self.isOpen : isOpen // ignore: cast_nullable_to_non_nullable
|
||||
as bool,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
@@ -2714,10 +2717,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnWalletFund() when $default != null:
|
||||
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -2735,10 +2738,10 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnWalletFund():
|
||||
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
||||
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
@@ -2752,10 +2755,10 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnWalletFund() when $default != null:
|
||||
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -2767,12 +2770,14 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
|
||||
@JsonSerializable()
|
||||
|
||||
class _SnWalletFund implements SnWalletFund {
|
||||
const _SnWalletFund({required this.id, required this.currency, required this.totalAmount, required this.splitType, required this.status, required this.message, required this.creatorAccountId, required this.creatorAccount, required this.expiredAt, required final List<SnWalletFundRecipient> recipients, required this.createdAt, required this.updatedAt, required this.deletedAt}): _recipients = recipients;
|
||||
const _SnWalletFund({required this.id, required this.currency, required this.totalAmount, required this.remainingAmount, required this.amountOfSplits, required this.splitType, required this.status, required this.message, required this.creatorAccountId, required this.creatorAccount, required this.expiredAt, required final List<SnWalletFundRecipient> recipients, required this.isOpen, required this.createdAt, required this.updatedAt, required this.deletedAt}): _recipients = recipients;
|
||||
factory _SnWalletFund.fromJson(Map<String, dynamic> json) => _$SnWalletFundFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override final String currency;
|
||||
@override final double totalAmount;
|
||||
@override final double remainingAmount;
|
||||
@override final int amountOfSplits;
|
||||
@override final int splitType;
|
||||
// 0: even, 1: random
|
||||
@override final int status;
|
||||
@@ -2788,6 +2793,7 @@ class _SnWalletFund implements SnWalletFund {
|
||||
return EqualUnmodifiableListView(_recipients);
|
||||
}
|
||||
|
||||
@override final bool isOpen;
|
||||
@override final DateTime createdAt;
|
||||
@override final DateTime updatedAt;
|
||||
@override final DateTime? deletedAt;
|
||||
@@ -2805,16 +2811,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other._recipients, _recipients)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.remainingAmount, remainingAmount) || other.remainingAmount == remainingAmount)&&(identical(other.amountOfSplits, amountOfSplits) || other.amountOfSplits == amountOfSplits)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other._recipients, _recipients)&&(identical(other.isOpen, isOpen) || other.isOpen == isOpen)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(_recipients),createdAt,updatedAt,deletedAt);
|
||||
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,remainingAmount,amountOfSplits,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(_recipients),isOpen,createdAt,updatedAt,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, remainingAmount: $remainingAmount, amountOfSplits: $amountOfSplits, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, isOpen: $isOpen, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
@@ -2825,7 +2831,7 @@ abstract mixin class _$SnWalletFundCopyWith<$Res> implements $SnWalletFundCopyWi
|
||||
factory _$SnWalletFundCopyWith(_SnWalletFund value, $Res Function(_SnWalletFund) _then) = __$SnWalletFundCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
@@ -2842,19 +2848,22 @@ class __$SnWalletFundCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnWalletFund
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? remainingAmount = null,Object? amountOfSplits = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? isOpen = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_SnWalletFund(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
|
||||
as String,totalAmount: null == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable
|
||||
as double,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
|
||||
as double,remainingAmount: null == remainingAmount ? _self.remainingAmount : remainingAmount // ignore: cast_nullable_to_non_nullable
|
||||
as double,amountOfSplits: null == amountOfSplits ? _self.amountOfSplits : amountOfSplits // ignore: cast_nullable_to_non_nullable
|
||||
as int,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
|
||||
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||
as int,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||
as String?,creatorAccountId: null == creatorAccountId ? _self.creatorAccountId : creatorAccountId // ignore: cast_nullable_to_non_nullable
|
||||
as String,creatorAccount: freezed == creatorAccount ? _self.creatorAccount : creatorAccount // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount?,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,recipients: null == recipients ? _self._recipients : recipients // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnWalletFundRecipient>,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnWalletFundRecipient>,isOpen: null == isOpen ? _self.isOpen : isOpen // ignore: cast_nullable_to_non_nullable
|
||||
as bool,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
|
||||
@@ -336,6 +336,8 @@ _SnWalletFund _$SnWalletFundFromJson(
|
||||
id: json['id'] as String,
|
||||
currency: json['currency'] as String,
|
||||
totalAmount: (json['total_amount'] as num).toDouble(),
|
||||
remainingAmount: (json['remaining_amount'] as num).toDouble(),
|
||||
amountOfSplits: (json['amount_of_splits'] as num).toInt(),
|
||||
splitType: (json['split_type'] as num).toInt(),
|
||||
status: (json['status'] as num).toInt(),
|
||||
message: json['message'] as String?,
|
||||
@@ -349,6 +351,7 @@ _SnWalletFund _$SnWalletFundFromJson(
|
||||
(json['recipients'] as List<dynamic>)
|
||||
.map((e) => SnWalletFundRecipient.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
isOpen: json['is_open'] as bool,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt:
|
||||
@@ -362,6 +365,8 @@ Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
|
||||
'id': instance.id,
|
||||
'currency': instance.currency,
|
||||
'total_amount': instance.totalAmount,
|
||||
'remaining_amount': instance.remainingAmount,
|
||||
'amount_of_splits': instance.amountOfSplits,
|
||||
'split_type': instance.splitType,
|
||||
'status': instance.status,
|
||||
'message': instance.message,
|
||||
@@ -369,6 +374,7 @@ Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
|
||||
'creator_account': instance.creatorAccount?.toJson(),
|
||||
'expired_at': instance.expiredAt.toIso8601String(),
|
||||
'recipients': instance.recipients.map((e) => e.toJson()).toList(),
|
||||
'is_open': instance.isOpen,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
|
||||
@@ -212,8 +212,14 @@ class CallNotifier extends _$CallNotifier {
|
||||
String? _roomId;
|
||||
String? get roomId => _roomId;
|
||||
|
||||
Future<void> joinRoom(String roomId) async {
|
||||
if (_roomId == roomId && _room != null) {
|
||||
SnChatRoom? _chatRoom;
|
||||
SnChatRoom? get chatRoom => _chatRoom;
|
||||
|
||||
Future<void> joinRoom(SnChatRoom room) async {
|
||||
var roomId = room.id;
|
||||
if (_roomId == roomId &&
|
||||
_room != null &&
|
||||
_room?.connectionState == lk.ConnectionState.connected) {
|
||||
talker.info('[Call] Call skipped. Already has data');
|
||||
return;
|
||||
} else if (_room != null) {
|
||||
@@ -223,6 +229,7 @@ class CallNotifier extends _$CallNotifier {
|
||||
}
|
||||
}
|
||||
_roomId = roomId;
|
||||
_chatRoom = room;
|
||||
if (_room != null) {
|
||||
await _room!.disconnect();
|
||||
await _room!.dispose();
|
||||
@@ -355,6 +362,7 @@ class CallNotifier extends _$CallNotifier {
|
||||
sourceId: source.id,
|
||||
maxFrameRate: 30.0,
|
||||
captureScreenAudio: true,
|
||||
useiOSBroadcastExtension: true,
|
||||
),
|
||||
);
|
||||
await _localParticipant!.publishVideoTrack(track);
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'call.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$callNotifierHash() => r'a8ca3f625c0db3ad9992033ae70864ce15efc281';
|
||||
String _$callNotifierHash() => r'ef4e3e9c9d411cf9dce1ceb456a3b866b2c87db3';
|
||||
|
||||
/// See also [CallNotifier].
|
||||
@ProviderFor(CallNotifier)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
@@ -6,6 +8,58 @@ import 'package:island/pods/chat/chat_subscribe.dart';
|
||||
|
||||
part 'chat_summary.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ChatUnreadCountNotifier extends _$ChatUnreadCountNotifier {
|
||||
StreamSubscription<WebSocketPacket>? _subscription;
|
||||
|
||||
@override
|
||||
Future<int> build() async {
|
||||
// Subscribe to websocket events when this provider is built
|
||||
_subscribeToWebSocket();
|
||||
|
||||
// Dispose the subscription when this provider is disposed
|
||||
ref.onDispose(() {
|
||||
_subscription?.cancel();
|
||||
});
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/sphere/chat/unread');
|
||||
return (response.data as num).toInt();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribeToWebSocket() {
|
||||
final webSocketService = ref.read(websocketProvider);
|
||||
_subscription = webSocketService.dataStream.listen((packet) {
|
||||
if (packet.type == 'messages.new' && packet.data != null) {
|
||||
final message = SnChatMessage.fromJson(packet.data!);
|
||||
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
|
||||
// Only increment if the message is not from the currently subscribed chat
|
||||
if (message.chatRoomId != currentSubscribed) {
|
||||
_incrementCounter();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _incrementCounter() async {
|
||||
final current = await future;
|
||||
state = AsyncData(current + 1);
|
||||
}
|
||||
|
||||
Future<void> decrement(int count) async {
|
||||
final current = await future;
|
||||
state = AsyncData(math.max(current - count, 0));
|
||||
}
|
||||
|
||||
void clear() async {
|
||||
state = AsyncData(0);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ChatSummary extends _$ChatSummary {
|
||||
@override
|
||||
@@ -41,6 +95,14 @@ class ChatSummary extends _$ChatSummary {
|
||||
state.whenData((summaries) {
|
||||
final summary = summaries[chatId];
|
||||
if (summary != null) {
|
||||
// Decrement global unread count
|
||||
final unreadToDecrement = summary.unreadCount;
|
||||
if (unreadToDecrement > 0) {
|
||||
ref
|
||||
.read(chatUnreadCountNotifierProvider.notifier)
|
||||
.decrement(unreadToDecrement);
|
||||
}
|
||||
|
||||
state = AsyncData({
|
||||
...summaries,
|
||||
chatId: SnChatSummary(
|
||||
|
||||
@@ -6,6 +6,24 @@ part of 'chat_summary.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatUnreadCountNotifierHash() =>
|
||||
r'b8d93589dc37f772d4c3a07d9afd81c37026e57d';
|
||||
|
||||
/// See also [ChatUnreadCountNotifier].
|
||||
@ProviderFor(ChatUnreadCountNotifier)
|
||||
final chatUnreadCountNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<ChatUnreadCountNotifier, int>.internal(
|
||||
ChatUnreadCountNotifier.new,
|
||||
name: r'chatUnreadCountNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatUnreadCountNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ChatUnreadCountNotifier = AutoDisposeAsyncNotifier<int>;
|
||||
String _$chatSummaryHash() => r'33815a3bd81d20902b7063e8194fe336930df9b4';
|
||||
|
||||
/// See also [ChatSummary].
|
||||
|
||||
@@ -6,8 +6,11 @@ import "package:flutter/material.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:island/database/drift_db.dart";
|
||||
import "package:island/database/message.dart";
|
||||
import "package:island/models/account.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/models/file.dart";
|
||||
import "package:island/models/poll.dart";
|
||||
import "package:island/models/wallet.dart";
|
||||
import "package:island/pods/database.dart";
|
||||
import "package:island/pods/lifecycle.dart";
|
||||
import "package:island/pods/network.dart";
|
||||
@@ -18,6 +21,7 @@ import "package:riverpod_annotation/riverpod_annotation.dart";
|
||||
import "package:uuid/uuid.dart";
|
||||
import "package:island/screens/chat/chat.dart";
|
||||
import "package:island/pods/chat/chat_rooms.dart";
|
||||
import "package:island/screens/account/profile.dart";
|
||||
|
||||
part 'messages_notifier.g.dart';
|
||||
|
||||
@@ -41,8 +45,11 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
bool _isSyncing = false;
|
||||
bool _isJumping = false;
|
||||
bool _isUpdatingState = false;
|
||||
bool _allRemoteMessagesFetched = false;
|
||||
DateTime? _lastPauseTime;
|
||||
|
||||
late final Future<SnAccount?> Function(String) _fetchAccount;
|
||||
|
||||
@override
|
||||
FutureOr<List<LocalChatMessage>> build(String roomId) async {
|
||||
_roomId = roomId;
|
||||
@@ -51,6 +58,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
final room = await ref.watch(chatroomProvider(roomId).future);
|
||||
final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
|
||||
|
||||
// Initialize fetch account method for corrupted data recovery
|
||||
_fetchAccount = (String accountId) async {
|
||||
try {
|
||||
return await ref.watch(accountProvider(accountId).future);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (room == null) {
|
||||
throw Exception('Room not found');
|
||||
}
|
||||
@@ -131,6 +147,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_roomId,
|
||||
searchQuery,
|
||||
withAttachments: withAttachments,
|
||||
fetchAccount: _fetchAccount,
|
||||
);
|
||||
} else {
|
||||
final chatMessagesFromDb = await _database.getMessagesForRoom(
|
||||
@@ -138,8 +155,16 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
offset: offset,
|
||||
limit: take,
|
||||
);
|
||||
dbMessages =
|
||||
chatMessagesFromDb.map(_database.companionToMessage).toList();
|
||||
dbMessages = await Future.wait(
|
||||
chatMessagesFromDb
|
||||
.map(
|
||||
(msg) => _database.companionToMessage(
|
||||
msg,
|
||||
fetchAccount: _fetchAccount,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
List<LocalChatMessage> filteredMessages = dbMessages;
|
||||
@@ -200,8 +225,14 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
offset: offset,
|
||||
limit: take,
|
||||
);
|
||||
final dbMessages =
|
||||
chatMessagesFromDb.map(_database.companionToMessage).toList();
|
||||
final dbMessages = await Future.wait(
|
||||
chatMessagesFromDb
|
||||
.map(
|
||||
(msg) =>
|
||||
_database.companionToMessage(msg, fetchAccount: _fetchAccount),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
// Always ensure unique messages to prevent duplicate keys
|
||||
final uniqueMessages = <LocalChatMessage>[];
|
||||
@@ -248,6 +279,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
if (offset >= _totalCount!) {
|
||||
_allRemoteMessagesFetched = true;
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -269,7 +301,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}).toList();
|
||||
|
||||
for (final message in messages) {
|
||||
await _database.saveMessage(_database.messageToCompanion(message));
|
||||
await _database.saveMessageWithSender(message);
|
||||
if (message.nonce != null) {
|
||||
_pendingMessages.removeWhere(
|
||||
(_, pendingMsg) => pendingMsg.nonce == message.nonce,
|
||||
@@ -277,6 +309,11 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we've fetched all remote messages
|
||||
if (offset + messages.length >= _totalCount!) {
|
||||
_allRemoteMessagesFetched = true;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -286,6 +323,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
return;
|
||||
}
|
||||
_isSyncing = true;
|
||||
_allRemoteMessagesFetched = false;
|
||||
|
||||
talker.log('Starting message sync');
|
||||
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
|
||||
@@ -298,7 +336,10 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
final lastMessage =
|
||||
dbMessages.isEmpty
|
||||
? null
|
||||
: _database.companionToMessage(dbMessages.first);
|
||||
: await _database.companionToMessage(
|
||||
dbMessages.first,
|
||||
fetchAccount: _fetchAccount,
|
||||
);
|
||||
|
||||
if (lastMessage == null) {
|
||||
talker.log('No local messages, fetching from network');
|
||||
@@ -310,19 +351,48 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final resp = await _apiClient.post(
|
||||
'/sphere/chat/${_room.id}/sync',
|
||||
data: {
|
||||
'last_sync_timestamp':
|
||||
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch,
|
||||
},
|
||||
);
|
||||
// Sync with pagination support using timestamp-based cursor
|
||||
int? totalMessages;
|
||||
int syncedCount = 0;
|
||||
int lastSyncTimestamp =
|
||||
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch;
|
||||
|
||||
final response = MessageSyncResponse.fromJson(resp.data);
|
||||
talker.log('Sync response: ${response.messages.length} changes');
|
||||
for (final message in response.messages) {
|
||||
await receiveMessage(message);
|
||||
}
|
||||
do {
|
||||
final resp = await _apiClient.post(
|
||||
'/sphere/chat/${_room.id}/sync',
|
||||
data: {'last_sync_timestamp': lastSyncTimestamp},
|
||||
);
|
||||
|
||||
// Read total count from header on first request
|
||||
if (totalMessages == null) {
|
||||
totalMessages = int.parse(
|
||||
resp.headers['x-total']?.firstOrNull ?? '0',
|
||||
);
|
||||
talker.log('Total messages to sync: $totalMessages');
|
||||
}
|
||||
|
||||
final response = MessageSyncResponse.fromJson(resp.data);
|
||||
final messagesCount = response.messages.length;
|
||||
talker.log(
|
||||
'Sync page: synced=$syncedCount/$totalMessages, count=$messagesCount',
|
||||
);
|
||||
|
||||
for (final message in response.messages) {
|
||||
await receiveMessage(message);
|
||||
}
|
||||
|
||||
syncedCount += messagesCount;
|
||||
|
||||
// Update cursor to the last message's createdAt for next page
|
||||
if (response.messages.isNotEmpty) {
|
||||
lastSyncTimestamp =
|
||||
response.messages.last.createdAt.millisecondsSinceEpoch;
|
||||
}
|
||||
|
||||
// Continue if there are more messages to fetch
|
||||
} while (syncedCount < totalMessages);
|
||||
|
||||
talker.log('Sync complete: synced $syncedCount messages');
|
||||
} catch (err, stackTrace) {
|
||||
talker.log(
|
||||
'Error syncing messages',
|
||||
@@ -361,14 +431,35 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
withAttachments: _withAttachments,
|
||||
);
|
||||
|
||||
if (localMessages.isNotEmpty) {
|
||||
// If we have local messages AND we've fetched all remote messages, return local
|
||||
if (localMessages.isNotEmpty && _allRemoteMessagesFetched) {
|
||||
return localMessages;
|
||||
}
|
||||
|
||||
// If we haven't fetched all remote messages, check remote even if we have local
|
||||
// OR if we have no local messages at all
|
||||
if (_searchQuery == null || _searchQuery!.isEmpty) {
|
||||
return await _fetchAndCacheMessages(offset: offset, take: take);
|
||||
final remoteMessages = await _fetchAndCacheMessages(
|
||||
offset: offset,
|
||||
take: take,
|
||||
);
|
||||
|
||||
// If we got remote messages, re-fetch from cache to get merged result
|
||||
if (remoteMessages.isNotEmpty) {
|
||||
return await _getCachedMessages(
|
||||
offset: offset,
|
||||
take: take,
|
||||
searchQuery: _searchQuery,
|
||||
withLinks: _withLinks,
|
||||
withAttachments: _withAttachments,
|
||||
);
|
||||
}
|
||||
|
||||
// No remote messages, return local (if any)
|
||||
return localMessages;
|
||||
} else {
|
||||
return []; // If searching, and no local messages, don't fetch from network
|
||||
// For search queries, return local only
|
||||
return localMessages;
|
||||
}
|
||||
} catch (e) {
|
||||
final localMessages = await _getCachedMessages(
|
||||
@@ -388,6 +479,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
Future<void> loadInitial() async {
|
||||
talker.log('Loading initial messages');
|
||||
_allRemoteMessagesFetched = false;
|
||||
if (_searchQuery == null || _searchQuery!.isEmpty) {
|
||||
syncMessages();
|
||||
}
|
||||
@@ -409,6 +501,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
if (!_hasMore || state is AsyncLoading) return;
|
||||
talker.log('Loading more messages');
|
||||
|
||||
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
|
||||
try {
|
||||
final currentMessages = state.value ?? [];
|
||||
final offset = currentMessages.length;
|
||||
@@ -430,6 +523,10 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
Future.microtask(
|
||||
() => ref.read(isSyncingProvider.notifier).state = false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +534,8 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
WidgetRef ref,
|
||||
String content,
|
||||
List<UniversalFile> attachments, {
|
||||
SnPoll? poll,
|
||||
SnWalletFund? fund,
|
||||
SnChatMessage? editingTo,
|
||||
SnChatMessage? forwardingTo,
|
||||
SnChatMessage? replyingTo,
|
||||
@@ -463,7 +562,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
_pendingMessages[localMessage.id] = localMessage;
|
||||
_fileUploadProgress[localMessage.id] = {};
|
||||
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
||||
await _database.saveMessageWithSender(localMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
state = AsyncValue.data([localMessage, ...currentMessages]);
|
||||
@@ -498,6 +597,8 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
'attachments_id': cloudAttachments.map((e) => e.id).toList(),
|
||||
'replied_message_id': replyingTo?.id,
|
||||
'forwarded_message_id': forwardingTo?.id,
|
||||
'poll_id': poll?.id,
|
||||
'fund_id': fund?.id,
|
||||
'meta': {},
|
||||
'nonce': nonce,
|
||||
},
|
||||
@@ -512,7 +613,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
_pendingMessages.remove(localMessage.id);
|
||||
await _database.deleteMessage(localMessage.id);
|
||||
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
if (editingTo != null) {
|
||||
@@ -594,7 +695,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
_pendingMessages.remove(pendingMessageId);
|
||||
await _database.deleteMessage(pendingMessageId);
|
||||
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
final newMessages =
|
||||
(state.value ?? []).map((m) {
|
||||
@@ -651,7 +752,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
||||
await _database.saveMessageWithSender(localMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final existingIndex = currentMessages.indexWhere(
|
||||
@@ -748,7 +849,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
messageToUpdate.status,
|
||||
);
|
||||
|
||||
await _database.saveMessage(_database.messageToCompanion(deletedMessage));
|
||||
await _database.saveMessageWithSender(deletedMessage);
|
||||
|
||||
if (messageIndex != -1) {
|
||||
final newList = [...currentMessages];
|
||||
@@ -872,6 +973,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_searchQuery = null;
|
||||
_withLinks = null;
|
||||
_withAttachments = null;
|
||||
_allRemoteMessagesFetched = false;
|
||||
loadInitial();
|
||||
}
|
||||
|
||||
@@ -882,7 +984,10 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
await (_database.select(_database.chatMessages)
|
||||
..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull();
|
||||
if (localMessage != null) {
|
||||
return _database.companionToMessage(localMessage);
|
||||
return _database.companionToMessage(
|
||||
localMessage,
|
||||
fetchAccount: _fetchAccount,
|
||||
);
|
||||
}
|
||||
|
||||
final response = await _apiClient.get(
|
||||
@@ -894,7 +999,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
MessageStatus.sent,
|
||||
);
|
||||
|
||||
await _database.saveMessage(_database.messageToCompanion(message));
|
||||
await _database.saveMessageWithSender(message);
|
||||
return message;
|
||||
} catch (e) {
|
||||
if (e is DioException) return null;
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$messagesNotifierHash() => r'c009eb8598e8b5fbcece2d0b5213b2e434edb3b2';
|
||||
String _$messagesNotifierHash() => r'27ce32c54e317a04e1d554ed4a70a24e4503fdd1';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
||||
@@ -11,12 +11,36 @@ part 'file_list.g.dart';
|
||||
class CloudFileListNotifier extends _$CloudFileListNotifier
|
||||
with CursorPagingNotifierMixin<FileListItem> {
|
||||
String _currentPath = '/';
|
||||
String? _poolId;
|
||||
String? _query;
|
||||
String? _order;
|
||||
bool _orderDesc = false;
|
||||
|
||||
void setPath(String path) {
|
||||
_currentPath = path;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
void setPool(String? poolId) {
|
||||
_poolId = poolId;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
void setQuery(String? query) {
|
||||
_query = query;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
void setOrder(String? order) {
|
||||
_order = order;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
void setOrderDesc(bool orderDesc) {
|
||||
_orderDesc = orderDesc;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
|
||||
|
||||
@@ -26,9 +50,25 @@ class CloudFileListNotifier extends _$CloudFileListNotifier
|
||||
}) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
final queryParameters = <String, String>{'path': _currentPath};
|
||||
|
||||
if (_poolId != null) {
|
||||
queryParameters['pool'] = _poolId!;
|
||||
}
|
||||
|
||||
if (_query != null) {
|
||||
queryParameters['query'] = _query!;
|
||||
}
|
||||
|
||||
if (_order != null) {
|
||||
queryParameters['order'] = _order!;
|
||||
}
|
||||
|
||||
queryParameters['orderDesc'] = _orderDesc.toString();
|
||||
|
||||
final response = await client.get(
|
||||
'/drive/index/browse',
|
||||
queryParameters: {'path': _currentPath},
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
|
||||
final List<String> folders =
|
||||
@@ -58,6 +98,37 @@ Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
|
||||
@riverpod
|
||||
class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
|
||||
with CursorPagingNotifierMixin<FileListItem> {
|
||||
String? _poolId;
|
||||
bool _recycled = false;
|
||||
String? _query;
|
||||
String? _order;
|
||||
bool _orderDesc = false;
|
||||
|
||||
void setPool(String? poolId) {
|
||||
_poolId = poolId;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
void setRecycled(bool recycled) {
|
||||
_recycled = recycled;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
void setQuery(String? query) {
|
||||
_query = query;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
void setOrder(String? order) {
|
||||
_order = order;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
void setOrderDesc(bool orderDesc) {
|
||||
_orderDesc = orderDesc;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
|
||||
|
||||
@@ -70,9 +141,32 @@ class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
|
||||
final offset = cursor != null ? int.tryParse(cursor) ?? 0 : 0;
|
||||
const take = 50; // Default page size
|
||||
|
||||
final queryParameters = <String, String>{
|
||||
'take': take.toString(),
|
||||
'offset': offset.toString(),
|
||||
};
|
||||
|
||||
if (_poolId != null) {
|
||||
queryParameters['pool'] = _poolId!;
|
||||
}
|
||||
|
||||
if (_recycled) {
|
||||
queryParameters['recycled'] = _recycled.toString();
|
||||
}
|
||||
|
||||
if (_query != null) {
|
||||
queryParameters['query'] = _query!;
|
||||
}
|
||||
|
||||
if (_order != null) {
|
||||
queryParameters['order'] = _order!;
|
||||
}
|
||||
|
||||
queryParameters['orderDesc'] = _orderDesc.toString();
|
||||
|
||||
final response = await client.get(
|
||||
'/drive/index/unindexed',
|
||||
queryParameters: {'take': take.toString(), 'offset': offset.toString()},
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
|
||||
final total = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0;
|
||||
|
||||
@@ -45,7 +45,7 @@ final billingQuotaProvider =
|
||||
// ignore: unused_element
|
||||
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
|
||||
String _$cloudFileListNotifierHash() =>
|
||||
r'5f2f80357cb31ac6473df5ac2101f9a462004f81';
|
||||
r'533dfa86f920b60cf7491fb4aeb95ece19e428af';
|
||||
|
||||
/// See also [CloudFileListNotifier].
|
||||
@ProviderFor(CloudFileListNotifier)
|
||||
@@ -66,7 +66,7 @@ final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
|
||||
typedef _$CloudFileListNotifier =
|
||||
AutoDisposeAsyncNotifier<CursorPagingData<FileListItem>>;
|
||||
String _$unindexedFileListNotifierHash() =>
|
||||
r'48fc92432a50a562190da5fe8ed0920d171b07b6';
|
||||
r'afa487d7b956b71b21ca1b073a01364a34ede1d5';
|
||||
|
||||
/// See also [UnindexedFileListNotifier].
|
||||
@ProviderFor(UnindexedFileListNotifier)
|
||||
|
||||
16
lib/pods/file_references.dart
Normal file
16
lib/pods/file_references.dart
Normal 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();
|
||||
}
|
||||
153
lib/pods/file_references.g.dart
Normal file
153
lib/pods/file_references.g.dart
Normal 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
159
lib/pods/site_files.dart
Normal 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
451
lib/pods/site_files.g.dart
Normal 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
116
lib/pods/site_pages.dart
Normal 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
280
lib/pods/site_pages.g.dart
Normal 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
88
lib/pods/sites.dart
Normal 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);
|
||||
@@ -258,6 +258,24 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void updateDownloadProgress(
|
||||
String taskId,
|
||||
int downloadedBytes,
|
||||
int totalBytes,
|
||||
) {
|
||||
state =
|
||||
state.map((task) {
|
||||
if (task.taskId == taskId) {
|
||||
return task.copyWith(
|
||||
fileSize: totalBytes,
|
||||
uploadedBytes: downloadedBytes,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
return task;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void removeTask(String taskId) {
|
||||
state = state.where((task) => task.taskId != taskId).toList();
|
||||
}
|
||||
@@ -275,6 +293,10 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
|
||||
.toList();
|
||||
}
|
||||
|
||||
void clearAllTasks() {
|
||||
state = [];
|
||||
}
|
||||
|
||||
DriveTask? getTask(String taskId) {
|
||||
return state.where((task) => task.taskId == taskId).firstOrNull;
|
||||
}
|
||||
@@ -291,6 +313,27 @@ class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
|
||||
.toList();
|
||||
}
|
||||
|
||||
String addLocalDownloadTask(SnCloudFile item) {
|
||||
final taskId =
|
||||
'download-${item.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final task = DriveTask(
|
||||
id: taskId,
|
||||
taskId: taskId,
|
||||
fileName: item.name,
|
||||
contentType: item.mimeType ?? '',
|
||||
fileSize: 0,
|
||||
uploadedBytes: 0,
|
||||
totalChunks: 1,
|
||||
uploadedChunks: 0,
|
||||
status: DriveTaskStatus.inProgress,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
type: 'FileDownload',
|
||||
);
|
||||
state = [...state, task];
|
||||
return taskId;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_websocketSubscription?.cancel();
|
||||
|
||||
@@ -5,7 +5,8 @@ import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_platform_alert/flutter_platform_alert.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
@@ -36,41 +37,65 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
|
||||
} catch (error, stackTrace) {
|
||||
if (!kIsWeb) {
|
||||
if (error is DioException) {
|
||||
FlutterPlatformAlert.showCustomAlert(
|
||||
windowTitle: 'failedToLoadUserInfo'.tr(),
|
||||
text: [
|
||||
(error.response?.statusCode == 401
|
||||
? 'failedToLoadUserInfoUnauthorized'
|
||||
: 'failedToLoadUserInfoNetwork')
|
||||
.tr()
|
||||
.trim(),
|
||||
'',
|
||||
'${error.response?.statusCode ?? 'Network Error'}',
|
||||
if (error.response?.headers != null) error.response?.headers,
|
||||
if (error.response?.data != null)
|
||||
jsonEncode(error.response?.data),
|
||||
].join('\n'),
|
||||
iconStyle: IconStyle.error,
|
||||
neutralButtonTitle: 'retry'.tr(),
|
||||
negativeButtonTitle: 'okay'.tr(),
|
||||
showOverlayDialog<bool>(
|
||||
builder:
|
||||
(context, close) => AlertDialog(
|
||||
title: Text('failedToLoadUserInfo'.tr()),
|
||||
content: Text(
|
||||
[
|
||||
(error.response?.statusCode == 401
|
||||
? 'failedToLoadUserInfoUnauthorized'
|
||||
: 'failedToLoadUserInfoNetwork')
|
||||
.tr()
|
||||
.trim(),
|
||||
'',
|
||||
'${error.response?.statusCode ?? 'Network Error'}',
|
||||
if (error.response?.headers != null)
|
||||
error.response?.headers,
|
||||
if (error.response?.data != null)
|
||||
jsonEncode(error.response?.data),
|
||||
].join('\n'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(false),
|
||||
child: Text('okay'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => close(true),
|
||||
child: Text('retry'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
).then((value) {
|
||||
if (value == CustomButton.neutralButton) {
|
||||
if (value == true) {
|
||||
fetchUser();
|
||||
}
|
||||
});
|
||||
}
|
||||
FlutterPlatformAlert.showCustomAlert(
|
||||
windowTitle: 'failedToLoadUserInfo'.tr(),
|
||||
text:
|
||||
[
|
||||
'failedToLoadUserInfoNetwork'.tr(),
|
||||
error.toString(),
|
||||
].join('\n\n').trim(),
|
||||
iconStyle: IconStyle.error,
|
||||
neutralButtonTitle: 'retry'.tr(),
|
||||
negativeButtonTitle: 'okay'.tr(),
|
||||
showOverlayDialog<bool>(
|
||||
builder:
|
||||
(context, close) => AlertDialog(
|
||||
title: Text('failedToLoadUserInfo'.tr()),
|
||||
content: Text(
|
||||
[
|
||||
'failedToLoadUserInfoNetwork'.tr(),
|
||||
error.toString(),
|
||||
].join('\n\n').trim(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(false),
|
||||
child: Text('okay'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => close(true),
|
||||
child: Text('retry'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
).then((value) {
|
||||
if (value == CustomButton.neutralButton) {
|
||||
if (value == true) {
|
||||
fetchUser();
|
||||
}
|
||||
});
|
||||
|
||||
107
lib/route.dart
107
lib/route.dart
@@ -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/me/account_settings.dart';
|
||||
import 'package:island/screens/chat/chat.dart';
|
||||
import 'package:island/screens/chat/chat_form.dart';
|
||||
import 'package:island/screens/chat/room.dart';
|
||||
import 'package:island/screens/chat/room_detail.dart';
|
||||
import 'package:island/screens/chat/call.dart';
|
||||
import 'package:island/screens/chat/search_messages.dart';
|
||||
import 'package:island/screens/thought/think.dart';
|
||||
import 'package:island/screens/creators/hub.dart';
|
||||
@@ -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_detail.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/poll/poll_editor.dart';
|
||||
import 'package:island/screens/posts/compose.dart';
|
||||
import 'package:island/screens/posts/compose_article.dart';
|
||||
import 'package:island/screens/posts/post_detail.dart';
|
||||
@@ -119,19 +118,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
return ArticleEditScreen(id: id);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'chatCall',
|
||||
path: '/chat/:id/call',
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return CallScreen(roomId: id);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'thought',
|
||||
path: '/thought',
|
||||
builder: (context, state) => const ThoughtScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'logs',
|
||||
path: '/logs',
|
||||
@@ -177,6 +163,22 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (context, state) => const AboutScreen(),
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
name: 'fileDetail',
|
||||
path: '/files/:id',
|
||||
builder: (context, state) {
|
||||
// For now, we'll need to pass the file object through extra
|
||||
// This will be updated when we modify the file list navigation
|
||||
final file = state.extra as SnCloudFile?;
|
||||
if (file != null) {
|
||||
return FileDetailScreen(item: file);
|
||||
}
|
||||
// Fallback - this shouldn't happen in normal flow
|
||||
Navigator.of(context).pop();
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
// Main tabs with TabsScreen shell
|
||||
ShellRoute(
|
||||
navigatorKey: _tabsShellKey,
|
||||
@@ -270,11 +272,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: '/chat',
|
||||
builder: (context, state) => const ChatListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'chatNew',
|
||||
path: '/chat/new',
|
||||
builder: (context, state) => const NewChatScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'chatRoom',
|
||||
path: '/chat/:id',
|
||||
@@ -283,14 +280,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
return ChatRoomScreen(id: id);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'chatEdit',
|
||||
path: '/chat/:id/edit',
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return EditChatScreen(id: id);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'chatDetail',
|
||||
path: '/chat/:id/detail',
|
||||
@@ -447,23 +436,13 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
name: 'files',
|
||||
path: '/files',
|
||||
builder: (context, state) => const FileListScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: 'fileDetail',
|
||||
path: ':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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// SN-chan tab
|
||||
GoRoute(
|
||||
name: 'thought',
|
||||
path: '/thought',
|
||||
builder: (context, state) => const ThoughtScreen(),
|
||||
),
|
||||
|
||||
// Creator hub tab
|
||||
@@ -498,28 +477,30 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
return CreatorPollListScreen(pubName: name);
|
||||
},
|
||||
),
|
||||
// Poll routes
|
||||
// Site list route
|
||||
GoRoute(
|
||||
name: 'creatorPollNew',
|
||||
path: ':name/polls/new',
|
||||
name: 'creatorSites',
|
||||
path: ':name/sites',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
// initialPollId left null for create; initialPublisher prefilled
|
||||
return PollEditorScreen(initialPublisher: name);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorPollEdit',
|
||||
path: ':name/polls/:id/edit',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
final id = state.pathParameters['id']!;
|
||||
return PollEditorScreen(
|
||||
initialPollId: id,
|
||||
initialPublisher: name,
|
||||
);
|
||||
return CreatorSiteListScreen(pubName: name);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: 'creatorSiteDetail',
|
||||
path: ':siteSlug',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
final siteSlug = state.pathParameters['siteSlug']!;
|
||||
return PublicationSiteDetailScreen(
|
||||
siteSlug: siteSlug,
|
||||
pubName: name,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
name: 'creatorStickers',
|
||||
path: ':name/stickers',
|
||||
|
||||
@@ -384,9 +384,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
icon: const Icon(Symbols.content_copy, size: 16),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: value));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('copiedToClipboard'.tr())),
|
||||
);
|
||||
showSnackBar('copiedToClipboard'.tr());
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
|
||||
@@ -375,16 +375,17 @@ class AccountScreen extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.files),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('files').tr(),
|
||||
onTap: () {
|
||||
context.goNamed('files');
|
||||
},
|
||||
),
|
||||
if (!isWideScreen(context))
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.files),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('files').tr(),
|
||||
onTap: () {
|
||||
context.goNamed('files');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.wallet),
|
||||
|
||||
@@ -62,6 +62,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'accountDeletionHint'.tr(),
|
||||
'accountDeletion'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
|
||||
@@ -26,6 +26,7 @@ class AuthFactorSheet extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'authFactorDeleteHint'.tr(),
|
||||
'authFactorDelete'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
|
||||
@@ -82,6 +82,7 @@ class AccountConnectionSheet extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'accountConnectionDeleteHint'.tr(),
|
||||
'accountConnectionDelete'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
@@ -332,6 +333,7 @@ class AccountConnectionsSheet extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'accountConnectionDeleteHint'.tr(),
|
||||
'accountConnectionDelete'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (confirm && context.mounted) {
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@ class ContactMethodSheet extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'contactMethodDeleteHint'.tr(),
|
||||
'contactMethodDelete'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
|
||||
@@ -6,10 +6,12 @@ import 'package:island/widgets/content/sheet.dart';
|
||||
|
||||
class CaptchaScreen extends ConsumerWidget {
|
||||
static Future<String?> show(BuildContext context) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
return Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// ignore_for_file: invalid_runtime_check_with_js_interop_types
|
||||
|
||||
import 'dart:ui_web' as ui;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
@@ -10,10 +8,12 @@ import 'package:flutter/material.dart';
|
||||
|
||||
class CaptchaScreen extends ConsumerStatefulWidget {
|
||||
static Future<String?> show(BuildContext context) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
return Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
|
||||
|
||||
void _setupWebListener(String serverUrl) async {
|
||||
web.window.onMessage.listen((event) {
|
||||
// ignore: invalid_runtime_check_with_js_interop_types
|
||||
if (event.data != null && event.data is String) {
|
||||
// ignore: invalid_runtime_check_with_js_interop_types
|
||||
final message = event.data as String;
|
||||
if (message.startsWith("captcha_tk=")) {
|
||||
String token = message.replaceFirst("captcha_tk=", "");
|
||||
|
||||
@@ -1,315 +1,22 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:email_validator/email_validator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/screens/account/me/profile_update.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import 'captcha.dart';
|
||||
import 'create_account_content.dart';
|
||||
|
||||
class CreateAccountScreen extends HookConsumerWidget {
|
||||
const CreateAccountScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
|
||||
|
||||
final emailController = useTextEditingController();
|
||||
final usernameController = useTextEditingController();
|
||||
final nicknameController = useTextEditingController();
|
||||
final passwordController = useTextEditingController();
|
||||
|
||||
void showPostCreateModal() {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => _PostCreateModal(),
|
||||
);
|
||||
}
|
||||
|
||||
void performAction() async {
|
||||
if (!formKey.currentState!.validate()) return;
|
||||
|
||||
final captchaTk = await 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(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('createAccount').tr(),
|
||||
),
|
||||
body:
|
||||
StyledWidget(
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.person_add, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'createAccount',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
).tr().padding(left: 4, bottom: 16),
|
||||
Form(
|
||||
key: formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: usernameController,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'fieldCannotBeEmpty'.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'username'.tr(),
|
||||
helperText: 'usernameCannotChangeHint'.tr(),
|
||||
),
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
TextFormField(
|
||||
controller: nicknameController,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'fieldCannotBeEmpty'.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
autocorrect: false,
|
||||
autofillHints: const [AutofillHints.nickname],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'nickname'.tr(),
|
||||
),
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
TextFormField(
|
||||
controller: emailController,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'fieldCannotBeEmpty'.tr();
|
||||
}
|
||||
if (!EmailValidator.validate(value)) {
|
||||
return 'fieldEmailAddressMustBeValid'.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'email'.tr(),
|
||||
),
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
TextFormField(
|
||||
controller: passwordController,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'fieldCannotBeEmpty'.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'password'.tr(),
|
||||
),
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 7),
|
||||
),
|
||||
const Gap(16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: StyledWidget(
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 290),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'termAcceptNextWithAgree'.tr(),
|
||||
textAlign: TextAlign.end,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface
|
||||
.withAlpha((255 * 0.75).round()),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('termAcceptLink').tr(),
|
||||
const Gap(4),
|
||||
const Icon(Symbols.launch, size: 14),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://solsynth.dev/terms',
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).padding(horizontal: 16),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
performAction();
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("next").tr(),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
).padding(all: 24).center(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostCreateModal extends HookConsumerWidget {
|
||||
const _PostCreateModal();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('🎉').fontSize(32),
|
||||
Text(
|
||||
'postCreateAccountTitle'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
).fontSize(17),
|
||||
const Gap(18),
|
||||
Text('postCreateAccountNext').tr().fontSize(19).bold(),
|
||||
const Gap(4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text('\u2022'),
|
||||
Expanded(child: Text('postCreateAccountNext1').tr()),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text('\u2022'),
|
||||
Expanded(child: Text('postCreateAccountNext2').tr()),
|
||||
],
|
||||
),
|
||||
const Gap(6),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
context.pushReplacementNamed('login');
|
||||
},
|
||||
child: Text('login'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: CreateAccountContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
486
lib/screens/auth/create_account_content.dart
Normal file
486
lib/screens/auth/create_account_content.dart
Normal 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()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/screens/auth/create_account_modal.dart
Normal file
19
lib/screens/auth/create_account_modal.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/models/auth.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/screens/account/me/settings_connections.dart';
|
||||
import 'package:island/screens/auth/oidc.dart';
|
||||
import 'package:island/services/notify.dart';
|
||||
import 'package:island/services/udid.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import 'captcha.dart';
|
||||
import 'login_content.dart';
|
||||
|
||||
final Map<int, (String, String, IconData)> kFactorTypes = {
|
||||
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
||||
@@ -44,743 +23,13 @@ class LoginScreen extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
|
||||
final period = useState(0);
|
||||
final currentTicket = useState<SnAuthChallenge?>(null);
|
||||
final factors = useState<List<SnAuthFactor>>([]);
|
||||
final factorPicked = useState<SnAuthFactor?>(null);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('login').tr(),
|
||||
),
|
||||
body: Theme(
|
||||
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
||||
child: Column(
|
||||
children: [
|
||||
if (isBusy.value)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 4,
|
||||
borderRadius: BorderRadius.zero,
|
||||
trackGap: 0,
|
||||
stopIndicatorRadius: 0,
|
||||
)
|
||||
else if (currentTicket.value != null)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 4,
|
||||
borderRadius: BorderRadius.zero,
|
||||
trackGap: 0,
|
||||
stopIndicatorRadius: 0,
|
||||
value:
|
||||
1 -
|
||||
(currentTicket.value!.stepRemain /
|
||||
currentTicket.value!.stepTotal),
|
||||
)
|
||||
else
|
||||
const Gap(4),
|
||||
Expanded(
|
||||
child:
|
||||
SingleChildScrollView(
|
||||
child: PageTransitionSwitcher(
|
||||
transitionBuilder: (
|
||||
Widget child,
|
||||
Animation<double> primaryAnimation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return SharedAxisTransition(
|
||||
animation: primaryAnimation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 380),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: switch (period.value % 3) {
|
||||
1 => _LoginPickerScreen(
|
||||
key: const ValueKey(1),
|
||||
challenge: currentTicket.value,
|
||||
factors: factors.value,
|
||||
onChallenge:
|
||||
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
||||
onPickFactor:
|
||||
(SnAuthFactor p0) => factorPicked.value = p0,
|
||||
onNext: () => period.value++,
|
||||
onBusy: (value) => isBusy.value = value,
|
||||
),
|
||||
2 => _LoginCheckScreen(
|
||||
key: const ValueKey(2),
|
||||
challenge: currentTicket.value,
|
||||
factor: factorPicked.value,
|
||||
onChallenge:
|
||||
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
||||
onNext: () => period.value = 1,
|
||||
onBusy: (value) => isBusy.value = value,
|
||||
),
|
||||
_ => _LoginLookupScreen(
|
||||
key: const ValueKey(0),
|
||||
ticket: currentTicket.value,
|
||||
onChallenge:
|
||||
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
||||
onFactor:
|
||||
(List<SnAuthFactor>? p0) =>
|
||||
factors.value = p0 ?? [],
|
||||
onNext: () => period.value++,
|
||||
onBusy: (value) => isBusy.value = value,
|
||||
),
|
||||
},
|
||||
).padding(all: 24),
|
||||
).center(),
|
||||
),
|
||||
|
||||
const Gap(4),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginCheckScreen extends HookConsumerWidget {
|
||||
final SnAuthChallenge? challenge;
|
||||
final SnAuthFactor? factor;
|
||||
final Function(SnAuthChallenge?) onChallenge;
|
||||
final VoidCallback onNext;
|
||||
final Function(bool) onBusy;
|
||||
|
||||
const _LoginCheckScreen({
|
||||
super.key,
|
||||
required this.challenge,
|
||||
required this.factor,
|
||||
required this.onChallenge,
|
||||
required this.onNext,
|
||||
required this.onBusy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
final passwordController = useTextEditingController();
|
||||
|
||||
useEffect(() {
|
||||
onBusy.call(isBusy.value);
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
Future<void> getToken({String? code}) async {
|
||||
// Get token if challenge is completed
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final tokenResp = await client.post(
|
||||
'/pass/auth/token',
|
||||
data: {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code ?? challenge!.id,
|
||||
},
|
||||
);
|
||||
final token = tokenResp.data['token'];
|
||||
setToken(ref.watch(sharedPreferencesProvider), token);
|
||||
ref.invalidate(tokenProvider);
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Do post login tasks
|
||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||
userNotifier.fetchUser().then((_) {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
subscribePushNotification(apiClient);
|
||||
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
||||
wsNotifier.connect();
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
if (challenge != null && challenge?.stepRemain == 0) {
|
||||
Future(() {
|
||||
if (isBusy.value) return;
|
||||
isBusy.value = true;
|
||||
getToken().catchError((err) {
|
||||
showErrorAlert(err);
|
||||
isBusy.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [challenge]);
|
||||
|
||||
if (factor == null) {
|
||||
// Logging in by third parties
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.asterisk, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'loginInProgress'.tr(),
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).padding(left: 4, bottom: 16),
|
||||
const Gap(16),
|
||||
CircularProgressIndicator().alignment(Alignment.centerLeft),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performCheckTicket() async {
|
||||
final pwd = passwordController.value.text;
|
||||
if (pwd.isEmpty) return;
|
||||
isBusy.value = true;
|
||||
try {
|
||||
// Pass challenge
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final resp = await client.patch(
|
||||
'/pass/auth/challenge/${challenge!.id}',
|
||||
data: {'factor_id': factor!.id, 'password': pwd},
|
||||
);
|
||||
final result = SnAuthChallenge.fromJson(resp.data);
|
||||
onChallenge(result);
|
||||
if (result.stepRemain > 0) {
|
||||
onNext();
|
||||
return;
|
||||
}
|
||||
|
||||
await getToken(code: result.id);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
return;
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
final width = math.min(380, MediaQuery.of(context).size.width);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.asterisk, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'loginEnterPassword'.tr(),
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).padding(left: 4, bottom: 16),
|
||||
if ([0].contains(factor!.type))
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
autofillHints: [
|
||||
factor!.type == 0
|
||||
? AutofillHints.password
|
||||
: AutofillHints.oneTimeCode,
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
labelText: 'password'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
|
||||
).padding(horizontal: 7)
|
||||
else
|
||||
OtpTextField(
|
||||
showCursor: false,
|
||||
numberOfFields: 6,
|
||||
obscureText: false,
|
||||
showFieldAsBox: true,
|
||||
focusedBorderColor: Theme.of(context).colorScheme.primary,
|
||||
fieldWidth: (width / 6) - 10,
|
||||
onSubmit: (value) {
|
||||
passwordController.text = value;
|
||||
performCheckTicket();
|
||||
},
|
||||
textStyle: Theme.of(context).textTheme.titleLarge!,
|
||||
),
|
||||
const Gap(12),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
|
||||
),
|
||||
title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
|
||||
subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
|
||||
),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => performCheckTicket(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginPickerScreen extends HookConsumerWidget {
|
||||
final SnAuthChallenge? challenge;
|
||||
final List<SnAuthFactor>? factors;
|
||||
final Function(SnAuthChallenge?) onChallenge;
|
||||
final Function(SnAuthFactor) onPickFactor;
|
||||
final VoidCallback onNext;
|
||||
final Function(bool) onBusy;
|
||||
|
||||
const _LoginPickerScreen({
|
||||
super.key,
|
||||
required this.challenge,
|
||||
required this.factors,
|
||||
required this.onChallenge,
|
||||
required this.onPickFactor,
|
||||
required this.onNext,
|
||||
required this.onBusy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
final factorPicked = useState<SnAuthFactor?>(null);
|
||||
|
||||
useEffect(() {
|
||||
onBusy.call(isBusy.value);
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
useEffect(() {
|
||||
if (challenge != null && challenge?.stepRemain == 0) {
|
||||
Future(() {
|
||||
onNext();
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [challenge]);
|
||||
|
||||
final unfocusColor = Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||
|
||||
final hintController = useTextEditingController();
|
||||
|
||||
void performGetFactorCode() async {
|
||||
if (factorPicked.value == null) return;
|
||||
|
||||
isBusy.value = true;
|
||||
final client = ref.watch(apiClientProvider);
|
||||
|
||||
try {
|
||||
await client.post(
|
||||
'/pass/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}',
|
||||
data:
|
||||
hintController.text.isNotEmpty
|
||||
? jsonEncode(hintController.text)
|
||||
: null,
|
||||
);
|
||||
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
||||
onNext();
|
||||
} catch (err) {
|
||||
if (err is DioException && err.response?.statusCode == 400) {
|
||||
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
||||
onNext();
|
||||
if (context.mounted) {
|
||||
showSnackBar(err.response!.data.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
showErrorAlert(err);
|
||||
return;
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
key: const ValueKey<int>(1),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.lock, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'loginPickFactor',
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).tr().padding(left: 4),
|
||||
const Gap(8),
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
children:
|
||||
factors
|
||||
?.map(
|
||||
(x) => CheckboxListTile(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
secondary: Icon(
|
||||
kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
|
||||
),
|
||||
title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
|
||||
enabled: !challenge!.blacklistFactors.contains(x.id),
|
||||
value: factorPicked.value == x,
|
||||
onChanged: (value) {
|
||||
if (value == true) {
|
||||
factorPicked.value = x;
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
List.empty(),
|
||||
),
|
||||
),
|
||||
if ([1].contains(factorPicked.value?.type))
|
||||
TextField(
|
||||
controller: hintController,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'authFactorHint'.tr(),
|
||||
helperText: 'authFactorHintHelper'.tr(),
|
||||
),
|
||||
).padding(top: 12, bottom: 4, horizontal: 4),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'loginMultiFactor'.plural(challenge!.stepRemain),
|
||||
style: TextStyle(color: unfocusColor, fontSize: 13),
|
||||
).padding(horizontal: 16),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => performGetFactorCode(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next'.tr()),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginLookupScreen extends HookConsumerWidget {
|
||||
final SnAuthChallenge? ticket;
|
||||
final Function(SnAuthChallenge?) onChallenge;
|
||||
final Function(List<SnAuthFactor>?) onFactor;
|
||||
final VoidCallback onNext;
|
||||
final Function(bool) onBusy;
|
||||
|
||||
const _LoginLookupScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
required this.onChallenge,
|
||||
required this.onFactor,
|
||||
required this.onNext,
|
||||
required this.onBusy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
final usernameController = useTextEditingController();
|
||||
|
||||
useEffect(() {
|
||||
onBusy.call(isBusy.value);
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
Future<void> requestResetPassword() async {
|
||||
final uname = usernameController.value.text;
|
||||
if (uname.isEmpty) {
|
||||
showErrorAlert('loginResetPasswordHint'.tr());
|
||||
return;
|
||||
}
|
||||
final captchaTk = await 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),
|
||||
),
|
||||
],
|
||||
body: LoginContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
814
lib/screens/auth/login_content.dart
Normal file
814
lib/screens/auth/login_content.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/screens/auth/login_modal.dart
Normal file
19
lib/screens/auth/login_modal.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,30 +3,31 @@ import 'package:flutter/material.dart' hide ConnectionState;
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/chat/call.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/chat/call_button.dart';
|
||||
import 'package:island/widgets/chat/call_content.dart';
|
||||
import 'package:island/widgets/chat/call_overlay.dart';
|
||||
import 'package:island/widgets/chat/call_participant_tile.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class CallScreen extends HookConsumerWidget {
|
||||
final String roomId;
|
||||
const CallScreen({super.key, required this.roomId});
|
||||
final SnChatRoom room;
|
||||
const CallScreen({super.key, required this.room});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final ongoingCall = ref.watch(ongoingCallProvider(roomId));
|
||||
final ongoingCall = ref.watch(ongoingCallProvider(room.id));
|
||||
final callState = ref.watch(callNotifierProvider);
|
||||
final callNotifier = ref.watch(callNotifierProvider.notifier);
|
||||
|
||||
useEffect(() {
|
||||
talker.info('[Call] Joining the call...');
|
||||
callNotifier.joinRoom(roomId).catchError((_) {
|
||||
callNotifier.joinRoom(room).catchError((_) {
|
||||
showConfirmAlert(
|
||||
'Seems there already has a call connected, do you want override it?',
|
||||
'Call already connected',
|
||||
@@ -35,7 +36,7 @@ class CallScreen extends HookConsumerWidget {
|
||||
talker.info('[Call] Joining the call... with overrides');
|
||||
callNotifier.disconnect();
|
||||
callNotifier.dispose();
|
||||
callNotifier.joinRoom(roomId);
|
||||
callNotifier.joinRoom(room);
|
||||
});
|
||||
});
|
||||
return null;
|
||||
@@ -110,7 +111,7 @@ class CallScreen extends HookConsumerWidget {
|
||||
onPressed: () {
|
||||
callNotifier.disconnect();
|
||||
callNotifier.dispose();
|
||||
callNotifier.joinRoom(roomId);
|
||||
callNotifier.joinRoom(room);
|
||||
},
|
||||
child: Text('retry').tr(),
|
||||
),
|
||||
@@ -120,72 +121,7 @@ class CallScreen extends HookConsumerWidget {
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (!callState.isConnected) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
if (callNotifier.participants.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No participants in call'),
|
||||
);
|
||||
}
|
||||
|
||||
final participants = callNotifier.participants;
|
||||
if (allAudioOnly) {
|
||||
// Audio-only: show avatars in a compact row
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final live in participants)
|
||||
SpeakingRippleAvatar(
|
||||
live: live,
|
||||
size: 72,
|
||||
).padding(horizontal: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Stage view: show main speaker(s) large, others in row
|
||||
final mainSpeakers =
|
||||
participants
|
||||
.where(
|
||||
(p) => p
|
||||
.remoteParticipant
|
||||
.trackPublications
|
||||
.values
|
||||
.any(
|
||||
(pub) =>
|
||||
pub.track != null &&
|
||||
pub.kind == TrackType.VIDEO,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
if (mainSpeakers.isEmpty && participants.isNotEmpty) {
|
||||
mainSpeakers.add(participants.first);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final speaker in mainSpeakers)
|
||||
Expanded(
|
||||
child: CallParticipantTile(live: speaker),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(child: CallContent()),
|
||||
CallControlsBar(),
|
||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -6,9 +8,12 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/chat/call.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/database.dart';
|
||||
import 'package:island/pods/chat/chat_summary.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/screens/realm/realms.dart';
|
||||
import 'package:island/services/event_bus.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
@@ -22,6 +27,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
part 'chat.g.dart';
|
||||
|
||||
@@ -47,6 +53,17 @@ class ChatRoomListTile extends HookConsumerWidget {
|
||||
.watch(chatSummaryProvider)
|
||||
.whenData((summaries) => summaries[room.id]);
|
||||
|
||||
var validMembers = room.members ?? [];
|
||||
if (validMembers.isNotEmpty) {
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
if (userInfo.value != null) {
|
||||
validMembers =
|
||||
validMembers
|
||||
.where((e) => e.accountId != userInfo.value!.id)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildSubtitle() {
|
||||
if (subtitle != null) return subtitle!;
|
||||
|
||||
@@ -55,7 +72,7 @@ class ChatRoomListTile extends HookConsumerWidget {
|
||||
if (data == null) {
|
||||
return isDirect && room.description == null
|
||||
? Text(
|
||||
room.members!.map((e) => '@${e.account.name}').join(', '),
|
||||
validMembers.map((e) => '@${e.account.name}').join(', '),
|
||||
maxLines: 1,
|
||||
)
|
||||
: Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1);
|
||||
@@ -111,7 +128,7 @@ class ChatRoomListTile extends HookConsumerWidget {
|
||||
(_, _) =>
|
||||
isDirect && room.description == null
|
||||
? Text(
|
||||
room.members!.map((e) => '@${e.account.name}').join(', '),
|
||||
validMembers.map((e) => '@${e.account.name}').join(', '),
|
||||
maxLines: 1,
|
||||
)
|
||||
: Text(
|
||||
@@ -121,6 +138,17 @@ class ChatRoomListTile extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String titleText;
|
||||
if (isDirect && room.name == null) {
|
||||
if (room.members?.isNotEmpty ?? false) {
|
||||
titleText = validMembers.map((e) => e.account.nick).join(', ');
|
||||
} else {
|
||||
titleText = 'Direct Message';
|
||||
}
|
||||
} else {
|
||||
titleText = room.name ?? '';
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: Badge(
|
||||
isLabelVisible: summary.when(
|
||||
@@ -132,7 +160,7 @@ class ChatRoomListTile extends HookConsumerWidget {
|
||||
(isDirect && room.picture?.id == null)
|
||||
? SplitAvatarWidget(
|
||||
filesId:
|
||||
room.members!
|
||||
validMembers
|
||||
.map((e) => e.account.profile.picture?.id)
|
||||
.toList(),
|
||||
)
|
||||
@@ -140,11 +168,7 @@ class ChatRoomListTile extends HookConsumerWidget {
|
||||
? CircleAvatar(child: Text(room.name![0].toUpperCase()))
|
||||
: ProfilePictureWidget(fileId: room.picture?.id),
|
||||
),
|
||||
title: Text(
|
||||
(isDirect && room.name == null)
|
||||
? room.members!.map((e) => e.account.nick).join(', ')
|
||||
: room.name ?? '',
|
||||
),
|
||||
title: Text(titleText),
|
||||
subtitle: buildSubtitle(),
|
||||
trailing: trailing, // Add this line
|
||||
onTap: () async {
|
||||
@@ -162,12 +186,92 @@ class ChatRoomListTile extends HookConsumerWidget {
|
||||
|
||||
@riverpod
|
||||
Future<List<SnChatRoom>> chatroomsJoined(Ref ref) async {
|
||||
final db = ref.watch(databaseProvider);
|
||||
|
||||
try {
|
||||
final localRoomsData = await db.select(db.chatRooms).get();
|
||||
if (localRoomsData.isNotEmpty) {
|
||||
final localRooms = await Future.wait(
|
||||
localRoomsData.map((row) async {
|
||||
final membersRows =
|
||||
await (db.select(db.chatMembers)
|
||||
..where((m) => m.chatRoomId.equals(row.id))).get();
|
||||
final members =
|
||||
membersRows.map((mRow) {
|
||||
final account = SnAccount.fromJson(mRow.account);
|
||||
SnAccountStatus? status;
|
||||
if (mRow.status != null) {
|
||||
status = SnAccountStatus.fromJson(jsonDecode(mRow.status!));
|
||||
}
|
||||
return SnChatMember(
|
||||
id: mRow.id,
|
||||
chatRoomId: mRow.chatRoomId,
|
||||
accountId: mRow.accountId,
|
||||
account: account,
|
||||
nick: mRow.nick,
|
||||
role: mRow.role,
|
||||
notify: mRow.notify,
|
||||
joinedAt: mRow.joinedAt,
|
||||
breakUntil: mRow.breakUntil,
|
||||
timeoutUntil: mRow.timeoutUntil,
|
||||
isBot: mRow.isBot,
|
||||
status: status,
|
||||
lastTyped: mRow.lastTyped,
|
||||
createdAt: mRow.createdAt,
|
||||
updatedAt: mRow.updatedAt,
|
||||
deletedAt: mRow.deletedAt,
|
||||
chatRoom: null,
|
||||
);
|
||||
}).toList();
|
||||
return SnChatRoom(
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
type: row.type,
|
||||
isPublic: row.isPublic!,
|
||||
isCommunity: row.isCommunity!,
|
||||
picture:
|
||||
row.picture != null ? SnCloudFile.fromJson(row.picture!) : null,
|
||||
background:
|
||||
row.background != null
|
||||
? SnCloudFile.fromJson(row.background!)
|
||||
: null,
|
||||
realmId: row.realmId,
|
||||
realm: null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
members: members,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// Background sync
|
||||
Future(() async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final resp = await client.get('/sphere/chat');
|
||||
final remoteRooms =
|
||||
resp.data
|
||||
.map((e) => SnChatRoom.fromJson(e))
|
||||
.cast<SnChatRoom>()
|
||||
.toList();
|
||||
await db.saveChatRooms(remoteRooms);
|
||||
ref.invalidateSelf();
|
||||
} catch (_) {}
|
||||
}).ignore();
|
||||
|
||||
return localRooms;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Fallback to API
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final resp = await client.get('/sphere/chat');
|
||||
return resp.data
|
||||
.map((e) => SnChatRoom.fromJson(e))
|
||||
.cast<SnChatRoom>()
|
||||
.toList();
|
||||
final rooms =
|
||||
resp.data.map((e) => SnChatRoom.fromJson(e)).cast<SnChatRoom>().toList();
|
||||
await db.saveChatRooms(rooms);
|
||||
return rooms;
|
||||
}
|
||||
|
||||
class ChatListBodyWidget extends HookConsumerWidget {
|
||||
@@ -185,7 +289,6 @@ class ChatListBodyWidget extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chats = ref.watch(chatroomsJoinedProvider);
|
||||
final callState = ref.watch(callNotifierProvider);
|
||||
|
||||
Widget bodyWidget = Column(
|
||||
children: [
|
||||
@@ -210,10 +313,8 @@ class ChatListBodyWidget extends HookConsumerWidget {
|
||||
() => Future.sync(() {
|
||||
ref.invalidate(chatroomsJoinedProvider);
|
||||
}),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: callState.isConnected ? 96 : 0,
|
||||
),
|
||||
child: SuperListView.builder(
|
||||
padding: EdgeInsets.only(bottom: 96),
|
||||
itemCount:
|
||||
items
|
||||
.where(
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'chat.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatroomsJoinedHash() => r'3bb6389af07e81007680484d04bf5fe6f6c10571';
|
||||
String _$chatroomsJoinedHash() => r'9523efecd1869e7dd26adfc8ec87be48db19ee1c';
|
||||
|
||||
/// See also [chatroomsJoined].
|
||||
@ProviderFor(chatroomsJoined)
|
||||
|
||||
@@ -16,10 +16,10 @@ import 'package:island/screens/realm/realms.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
|
||||
class NewChatScreen extends StatelessWidget {
|
||||
const NewChatScreen({super.key});
|
||||
@@ -151,12 +151,10 @@ class EditChatScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(id == null ? 'createChatRoom' : 'editChatRoom').tr(),
|
||||
leading: const PageBackButton(),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
return SheetScaffold(
|
||||
titleText: (id == null ? 'createChatRoom' : 'editChatRoom').tr(),
|
||||
onClose: () => context.pop(),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
AspectRatio(
|
||||
@@ -204,16 +202,24 @@ class EditChatScreen extends HookConsumerWidget {
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Name',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Description',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
minLines: 3,
|
||||
maxLines: null,
|
||||
@@ -223,7 +229,12 @@ class EditChatScreen extends HookConsumerWidget {
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<SnRealm>(
|
||||
value: currentRealm.value,
|
||||
decoration: InputDecoration(labelText: 'realm'.tr()),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'realm'.tr(),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem<SnRealm>(
|
||||
value: null,
|
||||
|
||||
@@ -11,6 +11,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:island/database/message.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/models/file.dart";
|
||||
import "package:island/models/poll.dart";
|
||||
import "package:island/models/wallet.dart";
|
||||
import "package:island/pods/chat/chat_rooms.dart";
|
||||
import "package:island/pods/chat/chat_subscribe.dart";
|
||||
import "package:island/pods/chat/messages_notifier.dart";
|
||||
@@ -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/public_room_preview.dart";
|
||||
import "package:island/screens/thought/think_sheet.dart";
|
||||
import "package:island/screens/chat/widgets/message_item_wrapper.dart";
|
||||
|
||||
class ChatRoomScreen extends HookConsumerWidget {
|
||||
final String id;
|
||||
@@ -142,12 +145,33 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
final messageController = useTextEditingController();
|
||||
final scrollController = useScrollController();
|
||||
|
||||
// Input height measurement for dynamic padding
|
||||
final inputKey = useMemoized(() => GlobalKey());
|
||||
final inputHeight = useState<double>(80.0);
|
||||
|
||||
// Periodic height measurement for dynamic sizing
|
||||
useEffect(() {
|
||||
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
|
||||
final renderBox =
|
||||
inputKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final newHeight = renderBox.size.height;
|
||||
if (newHeight != inputHeight.value) {
|
||||
inputHeight.value = newHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
return timer.cancel;
|
||||
}, []);
|
||||
|
||||
// Scroll animation notifiers
|
||||
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
|
||||
|
||||
final messageReplyingTo = useState<SnChatMessage?>(null);
|
||||
final messageForwardingTo = useState<SnChatMessage?>(null);
|
||||
final messageEditingTo = useState<SnChatMessage?>(null);
|
||||
final selectedPoll = useState<SnPoll?>(null);
|
||||
final selectedFund = useState<SnWalletFund?>(null);
|
||||
final attachments = useState<List<UniversalFile>>([]);
|
||||
final attachmentProgress = useState<Map<String, Map<int, double?>>>({});
|
||||
|
||||
@@ -155,6 +179,38 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
final isSelectionMode = useState<bool>(false);
|
||||
final selectedMessages = useState<Set<String>>({});
|
||||
|
||||
final roomOpenTime = useMemoized(() => DateTime.now());
|
||||
|
||||
final onMessageAction = useCallback(
|
||||
(String action, LocalChatMessage message) {
|
||||
switch (action) {
|
||||
case MessageItemAction.delete:
|
||||
messagesNotifier.deleteMessage(message.id);
|
||||
case MessageItemAction.edit:
|
||||
messageEditingTo.value = message.toRemoteMessage();
|
||||
messageController.text = messageEditingTo.value?.content ?? '';
|
||||
attachments.value =
|
||||
messageEditingTo.value!.attachments
|
||||
.map((e) => UniversalFile.fromAttachment(e))
|
||||
.toList();
|
||||
case MessageItemAction.forward:
|
||||
messageForwardingTo.value = message.toRemoteMessage();
|
||||
case MessageItemAction.reply:
|
||||
messageReplyingTo.value = message.toRemoteMessage();
|
||||
case MessageItemAction.resend:
|
||||
messagesNotifier.retryMessage(message.id);
|
||||
}
|
||||
},
|
||||
[
|
||||
messagesNotifier,
|
||||
messageEditingTo,
|
||||
messageController,
|
||||
attachments,
|
||||
messageForwardingTo,
|
||||
messageReplyingTo,
|
||||
],
|
||||
);
|
||||
|
||||
var isLoading = false;
|
||||
var isScrollingToMessage = false; // Flag to prevent scroll conflicts
|
||||
|
||||
@@ -263,11 +319,15 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
|
||||
void sendMessage() {
|
||||
if (messageController.text.trim().isNotEmpty ||
|
||||
attachments.value.isNotEmpty) {
|
||||
attachments.value.isNotEmpty ||
|
||||
selectedPoll.value != null ||
|
||||
selectedFund.value != null) {
|
||||
messagesNotifier.sendMessage(
|
||||
ref,
|
||||
messageController.text.trim(),
|
||||
attachments.value,
|
||||
poll: selectedPoll.value,
|
||||
fund: selectedFund.value,
|
||||
editingTo: messageEditingTo.value,
|
||||
forwardingTo: messageForwardingTo.value,
|
||||
replyingTo: messageReplyingTo.value,
|
||||
@@ -282,6 +342,8 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
messageEditingTo.value = null;
|
||||
messageReplyingTo.value = null;
|
||||
messageForwardingTo.value = null;
|
||||
selectedPoll.value = null;
|
||||
selectedFund.value = null;
|
||||
attachments.value = [];
|
||||
}
|
||||
}
|
||||
@@ -594,180 +656,67 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
|
||||
Widget chatMessageListWidget(
|
||||
List<LocalChatMessage> messageList,
|
||||
) => SuperListView.builder(
|
||||
listController: listController,
|
||||
) => AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
padding: EdgeInsets.only(
|
||||
top: 16,
|
||||
bottom:
|
||||
MediaQuery.of(context).padding.bottom +
|
||||
80, // Leave space for chat input
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8 + inputHeight.value,
|
||||
),
|
||||
controller: scrollController,
|
||||
reverse: true, // Show newest messages at the bottom
|
||||
itemCount: messageList.length,
|
||||
findChildIndexCallback: (key) {
|
||||
if (key is! ValueKey<String>) return null;
|
||||
final messageId = key.value.substring(messageKeyPrefix.length);
|
||||
final index = messageList.indexWhere(
|
||||
(m) => (m.nonce ?? m.id) == messageId,
|
||||
);
|
||||
// Return null for invalid indices to let SuperListView handle it properly
|
||||
return index >= 0 ? index : null;
|
||||
},
|
||||
extentEstimation: (_, _) => 40,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
final nextMessage =
|
||||
index < messageList.length - 1 ? messageList[index + 1] : null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
child: SuperListView.builder(
|
||||
listController: listController,
|
||||
controller: scrollController,
|
||||
reverse: true, // Show newest messages at the bottom
|
||||
itemCount: messageList.length,
|
||||
findChildIndexCallback: (key) {
|
||||
if (key is! ValueKey<String>) return null;
|
||||
final messageId = key.value.substring(messageKeyPrefix.length);
|
||||
final index = messageList.indexWhere(
|
||||
(m) => (m.nonce ?? m.id) == messageId,
|
||||
);
|
||||
return index >= 0 ? index : null;
|
||||
},
|
||||
extentEstimation: (_, _) => 40,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
final nextMessage =
|
||||
index < messageList.length - 1 ? messageList[index + 1] : null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
|
||||
// Use a stable animation key that doesn't change during message lifecycle
|
||||
final key = Key('$messageKeyPrefix${message.nonce ?? message.id}');
|
||||
final key = Key('$messageKeyPrefix${message.nonce ?? message.id}');
|
||||
|
||||
final messageWidget = chatIdentity.when(
|
||||
skipError: true,
|
||||
data:
|
||||
(identity) => GestureDetector(
|
||||
onLongPress: () {
|
||||
if (!isSelectionMode.value) {
|
||||
toggleSelectionMode();
|
||||
toggleMessageSelection(message.id);
|
||||
}
|
||||
},
|
||||
onTap: () {
|
||||
if (isSelectionMode.value) {
|
||||
toggleMessageSelection(message.id);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
color:
|
||||
selectedMessages.value.contains(message.id)
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withOpacity(0.3)
|
||||
: null,
|
||||
child: Stack(
|
||||
children: [
|
||||
MessageItem(
|
||||
key: settings.disableAnimation ? key : null,
|
||||
message: message,
|
||||
isCurrentUser: identity?.id == message.senderId,
|
||||
onAction:
|
||||
isSelectionMode.value
|
||||
? null
|
||||
: (action) {
|
||||
switch (action) {
|
||||
case MessageItemAction.delete:
|
||||
messagesNotifier.deleteMessage(
|
||||
message.id,
|
||||
);
|
||||
case MessageItemAction.edit:
|
||||
messageEditingTo.value =
|
||||
message.toRemoteMessage();
|
||||
messageController.text =
|
||||
messageEditingTo.value?.content ?? '';
|
||||
attachments.value =
|
||||
messageEditingTo.value!.attachments
|
||||
.map(
|
||||
(e) =>
|
||||
UniversalFile.fromAttachment(
|
||||
e,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
case MessageItemAction.forward:
|
||||
messageForwardingTo.value =
|
||||
message.toRemoteMessage();
|
||||
case MessageItemAction.reply:
|
||||
messageReplyingTo.value =
|
||||
message.toRemoteMessage();
|
||||
case MessageItemAction.resend:
|
||||
messagesNotifier.retryMessage(message.id);
|
||||
}
|
||||
},
|
||||
onJump: (messageId) {
|
||||
scrollToMessage(
|
||||
messageId: messageId,
|
||||
messageList: messageList,
|
||||
messagesNotifier: messagesNotifier,
|
||||
listController: listController,
|
||||
scrollController: scrollController,
|
||||
ref: ref,
|
||||
);
|
||||
},
|
||||
progress: attachmentProgress.value[message.id],
|
||||
showAvatar: isLastInGroup,
|
||||
isSelectionMode: isSelectionMode.value,
|
||||
isSelected: selectedMessages.value.contains(message.id),
|
||||
onToggleSelection: toggleMessageSelection,
|
||||
onEnterSelectionMode: () {
|
||||
if (!isSelectionMode.value) {
|
||||
toggleSelectionMode();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (selectedMessages.value.contains(message.id))
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return MessageItemWrapper(
|
||||
key: key,
|
||||
message: message,
|
||||
index: index,
|
||||
isLastInGroup: isLastInGroup,
|
||||
isSelectionMode: isSelectionMode.value,
|
||||
selectedMessages: selectedMessages.value,
|
||||
chatIdentity: chatIdentity,
|
||||
toggleSelectionMode: toggleSelectionMode,
|
||||
toggleMessageSelection: toggleMessageSelection,
|
||||
onMessageAction: onMessageAction,
|
||||
onJump:
|
||||
(messageId) => scrollToMessage(
|
||||
messageId: messageId,
|
||||
messageList: messageList,
|
||||
messagesNotifier: messagesNotifier,
|
||||
listController: listController,
|
||||
scrollController: scrollController,
|
||||
ref: ref,
|
||||
),
|
||||
),
|
||||
loading:
|
||||
() => MessageItem(
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
onAction: null,
|
||||
progress: null,
|
||||
showAvatar: false,
|
||||
onJump: (_) {},
|
||||
),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
|
||||
return settings.disableAnimation
|
||||
? messageWidget
|
||||
: TweenAnimationBuilder<double>(
|
||||
key: key,
|
||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||
duration: Duration(
|
||||
milliseconds: 400 + (index % 5) * 50,
|
||||
), // Staggered delay
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, animationValue, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(
|
||||
0,
|
||||
20 * (1 - animationValue),
|
||||
), // Slide up from bottom
|
||||
child: Opacity(opacity: animationValue, child: child),
|
||||
);
|
||||
},
|
||||
child: messageWidget,
|
||||
);
|
||||
},
|
||||
attachmentProgress: attachmentProgress.value,
|
||||
disableAnimation: settings.disableAnimation,
|
||||
roomOpenTime: roomOpenTime,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
@@ -789,7 +738,11 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
AudioCallButton(roomId: id),
|
||||
chatRoom.when(
|
||||
data: (data) => AudioCallButton(room: data!),
|
||||
error: (err, _) => const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () async {
|
||||
@@ -890,7 +843,14 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: CallOverlayBar().padding(horizontal: 8, top: 12),
|
||||
child: chatRoom.when(
|
||||
data:
|
||||
(data) => CallOverlayBar(
|
||||
room: data!,
|
||||
).padding(horizontal: 8, top: 12),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (isSyncing)
|
||||
Positioned(
|
||||
@@ -964,6 +924,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
child: chatRoom.when(
|
||||
data:
|
||||
(room) => Column(
|
||||
key: inputKey,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ChatInput(
|
||||
@@ -978,10 +939,16 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
messageEditingTo.value = null;
|
||||
messageReplyingTo.value = null;
|
||||
messageForwardingTo.value = null;
|
||||
selectedPoll.value = null;
|
||||
selectedFund.value = null;
|
||||
},
|
||||
messageEditingTo: messageEditingTo.value,
|
||||
messageReplyingTo: messageReplyingTo.value,
|
||||
messageForwardingTo: messageForwardingTo.value,
|
||||
selectedPoll: selectedPoll.value,
|
||||
onPollSelected: (poll) => selectedPoll.value = poll,
|
||||
selectedFund: selectedFund.value,
|
||||
onFundSelected: (fund) => selectedFund.value = fund,
|
||||
onPickFile: (bool isPhoto) {
|
||||
if (isPhoto) {
|
||||
pickPhotoMedia();
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/screens/chat/chat_form.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||
@@ -447,10 +448,17 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
|
||||
if ((chatIdentity.value?.role ?? 0) >= 50)
|
||||
PopupMenuItem(
|
||||
onTap: () {
|
||||
context.pushReplacementNamed(
|
||||
'chatEdit',
|
||||
pathParameters: {'id': id},
|
||||
);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => EditChatScreen(id: id),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
// Invalidate to refresh room data after edit
|
||||
ref.invalidate(chatroomProvider(id));
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -479,6 +487,7 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteChatRoomHint'.tr(),
|
||||
'deleteChatRoom'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) async {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
|
||||
169
lib/screens/chat/widgets/message_item_wrapper.dart
Normal file
169
lib/screens/chat/widgets/message_item_wrapper.dart
Normal 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: (_) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -304,16 +304,18 @@ class CreatorHubScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void deletePublisher() {
|
||||
showConfirmAlert('deletePublisherHint'.tr(), 'deletePublisher'.tr()).then(
|
||||
(confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client.delete('/sphere/publishers/${currentPublisher.value!.name}');
|
||||
ref.invalidate(publishersManagedProvider);
|
||||
currentPublisher.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
showConfirmAlert(
|
||||
'deletePublisherHint'.tr(),
|
||||
'deletePublisher'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client.delete('/sphere/publishers/${currentPublisher.value!.name}');
|
||||
ref.invalidate(publishersManagedProvider);
|
||||
currentPublisher.value = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final List<DropdownMenuItem<SnPublisher>> publishersMenu = publishers.when(
|
||||
@@ -403,6 +405,21 @@ class CreatorHubScreen extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
minTileHeight: 48,
|
||||
title: Text('publicationSites').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.web),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'creatorSites',
|
||||
pathParameters: {'name': currentPublisher.value!.name},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
@@ -585,7 +602,7 @@ class CreatorHubScreen extends HookConsumerWidget {
|
||||
).padding(horizontal: 12),
|
||||
buildNavigationWidget(true),
|
||||
],
|
||||
)
|
||||
).padding(vertical: 24)
|
||||
: Column(
|
||||
spacing: 12,
|
||||
children: [
|
||||
@@ -831,7 +848,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
|
||||
try {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.post(
|
||||
'/publishers/$publisherUname/invites',
|
||||
'/sphere/publishers/invites/$publisherUname',
|
||||
data: {'related_user_id': result.id, 'role': 0},
|
||||
);
|
||||
// Refresh both providers
|
||||
@@ -962,7 +979,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
|
||||
apiClientProvider,
|
||||
);
|
||||
await apiClient.delete(
|
||||
'/publishers/$publisherUname/members/${member.accountId}',
|
||||
'/sphere/publishers/$publisherUname/members/${member.accountId}',
|
||||
);
|
||||
// Refresh both providers
|
||||
memberNotifier.reset();
|
||||
@@ -1087,7 +1104,7 @@ class _PublisherMemberRoleSheet extends HookConsumerWidget {
|
||||
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
await apiClient.patch(
|
||||
'/publishers/$publisherUname/members/${member.accountId}/role',
|
||||
'/sphere/publishers/$publisherUname/members/${member.accountId}/role',
|
||||
data: newRole,
|
||||
);
|
||||
|
||||
@@ -1119,7 +1136,7 @@ class _PublisherInviteSheet extends HookConsumerWidget {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post(
|
||||
'/publishers/invites/${invite.publisher!.name}/accept',
|
||||
'/sphere/publishers/invites/${invite.publisher!.name}/accept',
|
||||
);
|
||||
ref.invalidate(publisherInvitesProvider);
|
||||
ref.invalidate(publishersManagedProvider);
|
||||
@@ -1132,7 +1149,7 @@ class _PublisherInviteSheet extends HookConsumerWidget {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post(
|
||||
'/publishers/invites/${invite.publisher!.name}/decline',
|
||||
'/sphere/publishers/invites/${invite.publisher!.name}/decline',
|
||||
);
|
||||
ref.invalidate(publisherInvitesProvider);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/poll.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/screens/poll/poll_editor.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/poll/poll_feedback.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -73,10 +74,14 @@ class CreatorPollListScreen extends HookConsumerWidget {
|
||||
final String pubName;
|
||||
|
||||
Future<void> _createPoll(BuildContext context) async {
|
||||
final result = await GoRouter.of(
|
||||
context,
|
||||
).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
|
||||
if (result is SnPollWithStats && context.mounted) {
|
||||
final result = await showModalBottomSheet<SnPollWithStats>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
builder: (context) => PollEditorScreen(initialPublisher: pubName),
|
||||
);
|
||||
if (result != null && context.mounted) {
|
||||
Navigator.of(context).maybePop(result);
|
||||
}
|
||||
}
|
||||
@@ -176,11 +181,20 @@ class _CreatorPollItem extends HookConsumerWidget {
|
||||
Text('edit').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'creatorPollEdit',
|
||||
pathParameters: {'name': pubName, 'id': pollWithStats.id},
|
||||
onTap: () async {
|
||||
final result = await showModalBottomSheet<SnPoll>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
builder:
|
||||
(context) => PollEditorScreen(
|
||||
initialPublisher: pubName,
|
||||
initialPollId: pollWithStats.id,
|
||||
),
|
||||
);
|
||||
if (result != null && context.mounted) {
|
||||
ref.invalidate(pollListNotifierProvider(pubName));
|
||||
}
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
@@ -221,19 +235,9 @@ class _CreatorPollItem extends HookConsumerWidget {
|
||||
'/sphere/polls/${pollWithStats.id}',
|
||||
);
|
||||
ref.invalidate(pollListNotifierProvider(pubName));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Poll deleted successfully'),
|
||||
),
|
||||
);
|
||||
}
|
||||
showSnackBar('Poll deleted successfully');
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to delete poll')),
|
||||
);
|
||||
}
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
161
lib/screens/creators/sites/site_detail.dart
Normal file
161
lib/screens/creators/sites/site_detail.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
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/sites/site_info_card.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/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/widgets/extended_refresh_indicator.dart';
|
||||
import 'package:styled_widget/styled_widget.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) {
|
||||
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: [
|
||||
SiteInfoCard(site: site),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
173
lib/screens/creators/sites/site_detail.g.dart
Normal file
173
lib/screens/creators/sites/site_detail.g.dart
Normal 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
|
||||
385
lib/screens/creators/sites/site_edit.dart
Normal file
385
lib/screens/creators/sites/site_edit.dart
Normal file
@@ -0,0 +1,385 @@
|
||||
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(),
|
||||
isDanger: true,
|
||||
);
|
||||
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,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
243
lib/screens/creators/sites/site_list.dart
Normal file
243
lib/screens/creators/sites/site_list.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
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/$pubName/${site.slug}',
|
||||
);
|
||||
ref.invalidate(siteListNotifierProvider(pubName));
|
||||
showSnackBar('siteDeletedSuccess'.tr());
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
183
lib/screens/creators/sites/site_list.g.dart
Normal file
183
lib/screens/creators/sites/site_list.g.dart
Normal 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
|
||||
@@ -288,6 +288,7 @@ class StickerPackActionMenu extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteStickerPackHint'.tr(),
|
||||
'deleteStickerPack'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
|
||||
@@ -70,6 +70,7 @@ class WebfeedForm extends HookConsumerWidget {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'Are you sure you want to delete this web feed? This action cannot be undone.',
|
||||
'Delete Web Feed',
|
||||
isDanger: true,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ class AppSecretsScreen extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteSecretHint'.tr(),
|
||||
'deleteSecret'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
@@ -231,6 +231,7 @@ class CustomAppsScreen extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteCustomAppHint'.tr(),
|
||||
'deleteCustomApp'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.read(
|
||||
|
||||
@@ -159,9 +159,11 @@ class BotKeysScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void revokeKey(String keyId) {
|
||||
showConfirmAlert('revokeBotKeyHint'.tr(), 'revokeBotKey'.tr()).then((
|
||||
confirm,
|
||||
) {
|
||||
showConfirmAlert(
|
||||
'revokeBotKeyHint'.tr(),
|
||||
'revokeBotKey'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.read(apiClientProvider);
|
||||
client
|
||||
|
||||
@@ -172,6 +172,7 @@ class BotsScreen extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteBotHint'.tr(),
|
||||
'deleteBot'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
@@ -631,6 +631,7 @@ class _ProjectListTile extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteProjectHint'.tr(),
|
||||
'deleteProject'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -11,8 +12,10 @@ import 'package:island/models/realm.dart';
|
||||
import 'package:island/models/webfeed.dart';
|
||||
import 'package:island/pods/event_calendar.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/screens/auth/login_modal.dart';
|
||||
import 'package:island/screens/notification.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/account/friends_overview.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/widgets/check_in.dart';
|
||||
@@ -28,7 +31,9 @@ import 'package:island/widgets/publisher/publisher_card.dart';
|
||||
import 'package:island/widgets/web_article_card.dart';
|
||||
import 'package:island/widgets/extended_refresh_indicator.dart';
|
||||
import 'package:island/services/event_bus.dart';
|
||||
import 'package:island/widgets/share/share_sheet.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
part 'explore.g.dart';
|
||||
|
||||
@@ -237,23 +242,74 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
|
||||
final appBar = isWide ? null : _buildAppBar(tabController, context);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: appBar,
|
||||
body:
|
||||
isWide
|
||||
? _buildWideBody(
|
||||
context,
|
||||
ref,
|
||||
filterBar,
|
||||
user,
|
||||
notificationCount,
|
||||
query,
|
||||
events,
|
||||
selectedDay,
|
||||
currentFilter.value,
|
||||
)
|
||||
: _buildNarrowBody(context, ref, currentFilter.value),
|
||||
final dragging = useState(false);
|
||||
|
||||
return DropTarget(
|
||||
onDragDone: (detail) {
|
||||
dragging.value = false;
|
||||
if (detail.files.isNotEmpty) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => ShareSheet.files(files: detail.files),
|
||||
);
|
||||
}
|
||||
},
|
||||
onDragEntered: (_) => dragging.value = true,
|
||||
onDragExited: (_) => dragging.value = false,
|
||||
child: Stack(
|
||||
children: [
|
||||
AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: appBar,
|
||||
body:
|
||||
isWide
|
||||
? _buildWideBody(
|
||||
context,
|
||||
ref,
|
||||
filterBar,
|
||||
user,
|
||||
notificationCount,
|
||||
query,
|
||||
events,
|
||||
selectedDay,
|
||||
currentFilter.value,
|
||||
)
|
||||
: _buildNarrowBody(context, ref, currentFilter.value),
|
||||
),
|
||||
if (dragging.value)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withOpacity(0.9),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.upload_file,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'dropToShare'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -340,6 +396,7 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
PostFeaturedList(),
|
||||
FriendsOverviewWidget(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -348,21 +405,39 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
else
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Welcome to\nthe Solar Network',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
).bold(),
|
||||
const Gap(2),
|
||||
Text(
|
||||
'Login to explore more!',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 36, vertical: 16),
|
||||
child:
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.emoji_people_rounded, size: 40),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'Welcome to\nthe Solar Network',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
).bold(),
|
||||
const Gap(2),
|
||||
Text(
|
||||
'Login to explore more!',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const Gap(4),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => LoginModal(),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.login),
|
||||
label: Text('login').tr(),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 36, vertical: 16).center(),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12);
|
||||
@@ -521,6 +596,12 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
child: PostFeaturedList(),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: FriendsOverviewWidget(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
hideWhenEmpty: true,
|
||||
),
|
||||
),
|
||||
if (notificationCount.value != null &&
|
||||
notificationCount.value! > 0)
|
||||
SliverToBoxAdapter(
|
||||
@@ -555,7 +636,7 @@ class _DiscoveryActivityItem extends StatelessWidget {
|
||||
final height = type == 'post' ? 280.0 : 180.0;
|
||||
|
||||
final contentWidget = switch (type) {
|
||||
'post' => ListView.separated(
|
||||
'post' => SuperListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (context, index) => const Gap(12),
|
||||
|
||||
@@ -6,17 +6,24 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gal/gal.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/file_references.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/upload_tasks.dart';
|
||||
import 'package:island/models/drive_task.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||
import 'package:island/widgets/content/file_viewer_contents.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:path/path.dart' show extension;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class FileDetailScreen extends HookConsumerWidget {
|
||||
final SnCloudFile item;
|
||||
@@ -76,7 +83,7 @@ class FileDetailScreen extends HookConsumerWidget {
|
||||
}, [animationController]);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: true,
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
@@ -86,26 +93,47 @@ class FileDetailScreen extends HookConsumerWidget {
|
||||
title: Text(item.name.isEmpty ? 'File Details' : item.name),
|
||||
actions: _buildAppBarActions(context, ref, showInfoSheet),
|
||||
),
|
||||
body: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
return Row(
|
||||
children: [
|
||||
// Main content area
|
||||
Expanded(child: _buildContent(context, ref, serverUrl)),
|
||||
// Animated drawer panel
|
||||
if (isWide)
|
||||
SizedBox(
|
||||
height: double.infinity,
|
||||
width: animation.value * 400, // Max width of 400px
|
||||
child: Container(
|
||||
child:
|
||||
animation.value > 0.1
|
||||
? FileInfoSheet(item: item, onClose: showInfoSheet)
|
||||
: const SizedBox.shrink(),
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Main content area - resizes with animation
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: constraints.maxWidth - animation.value * 400,
|
||||
child: _buildContent(context, ref, serverUrl),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Animated drawer panel - overlays
|
||||
if (isWide)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 400,
|
||||
child: Transform.translate(
|
||||
offset: Offset((1 - animation.value) * 400, 0),
|
||||
child: SizedBox(
|
||||
width: 400,
|
||||
child: Material(
|
||||
color:
|
||||
Theme.of(context).colorScheme.surfaceContainer,
|
||||
elevation: 8,
|
||||
child: FileInfoSheet(
|
||||
item: item,
|
||||
onClose: showInfoSheet,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -144,6 +172,24 @@ class FileDetailScreen extends HookConsumerWidget {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add references button
|
||||
actions.add(
|
||||
IconButton(
|
||||
icon: Icon(Icons.link),
|
||||
onPressed:
|
||||
() => showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'File References',
|
||||
child: ReferencesList(fileId: item.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Always add info button
|
||||
actions.add(
|
||||
IconButton(icon: Icon(Icons.info_outline), onPressed: showInfoSheet),
|
||||
@@ -187,6 +233,8 @@ class FileDetailScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
Future<void> _downloadFile(WidgetRef ref) async {
|
||||
final taskNotifier = ref.read(uploadTasksProvider.notifier);
|
||||
final taskId = taskNotifier.addLocalDownloadTask(item);
|
||||
try {
|
||||
showSnackBar('Downloading file...');
|
||||
|
||||
@@ -202,14 +250,26 @@ class FileDetailScreen extends HookConsumerWidget {
|
||||
'/drive/files/${item.id}',
|
||||
filePath,
|
||||
queryParameters: {'original': true},
|
||||
onReceiveProgress: (count, total) {
|
||||
if (total > 0) {
|
||||
taskNotifier.updateDownloadProgress(taskId, count, total);
|
||||
taskNotifier.updateTransmissionProgress(taskId, count / total);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await FileSaver.instance.saveFile(
|
||||
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
|
||||
file: File(filePath),
|
||||
);
|
||||
taskNotifier.updateTaskStatus(taskId, DriveTaskStatus.completed);
|
||||
showSnackBar('File saved to downloads');
|
||||
} catch (e) {
|
||||
taskNotifier.updateTaskStatus(
|
||||
taskId,
|
||||
DriveTaskStatus.failed,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
@@ -229,3 +289,54 @@ class FileDetailScreen extends HookConsumerWidget {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ReferencesList extends ConsumerWidget {
|
||||
const ReferencesList({super.key, required this.fileId});
|
||||
|
||||
final String fileId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncReferences = ref.watch(fileReferencesProvider(fileId));
|
||||
|
||||
return asyncReferences.when(
|
||||
data:
|
||||
(references) => ListView.builder(
|
||||
itemCount: references.length,
|
||||
itemBuilder: (context, index) {
|
||||
final reference = references[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.link),
|
||||
title: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text(
|
||||
reference.usage,
|
||||
style: GoogleFonts.robotoMono(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
reference.id,
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(reference.createdAt.formatRelative(context)),
|
||||
const VerticalDivider(width: 1, thickness: 1).height(12),
|
||||
Text(reference.createdAt.formatSystem()),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, _) => Center(child: Text('Error loading references: $error')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/file_pool.dart';
|
||||
import 'package:island/pods/file_list.dart';
|
||||
import 'package:island/services/file_uploader.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/usage_overview.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class FileListScreen extends HookConsumerWidget {
|
||||
const FileListScreen({super.key});
|
||||
@@ -22,6 +25,7 @@ class FileListScreen extends HookConsumerWidget {
|
||||
// Path navigation state
|
||||
final currentPath = useState<String>('/');
|
||||
final mode = useState<FileListMode>(FileListMode.normal);
|
||||
final selectedPool = useState<SnFilePool?>(null);
|
||||
|
||||
final usageAsync = ref.watch(billingUsageProvider);
|
||||
final quotaAsync = ref.watch(billingQuotaProvider);
|
||||
@@ -31,8 +35,8 @@ class FileListScreen extends HookConsumerWidget {
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
title: Text('Files'),
|
||||
leading: const PageBackButton(),
|
||||
title: Text('files').tr(),
|
||||
leading: const PageBackButton(backTo: '/account'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.bar_chart),
|
||||
@@ -54,8 +58,13 @@ class FileListScreen extends HookConsumerWidget {
|
||||
usage: usage,
|
||||
quota: quota,
|
||||
currentPath: currentPath,
|
||||
selectedPool: selectedPool,
|
||||
onPickAndUpload:
|
||||
() => _pickAndUploadFile(ref, currentPath.value),
|
||||
() => _pickAndUploadFile(
|
||||
ref,
|
||||
currentPath.value,
|
||||
selectedPool.value?.id,
|
||||
),
|
||||
onShowCreateDirectory: _showCreateDirectoryDialog,
|
||||
mode: mode,
|
||||
viewMode: viewMode,
|
||||
@@ -69,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 {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: true,
|
||||
@@ -91,6 +104,7 @@ class FileListScreen extends HookConsumerWidget {
|
||||
fileData: universalFile,
|
||||
ref: ref,
|
||||
path: currentPath,
|
||||
poolId: poolId,
|
||||
onProgress: (progress, _) {
|
||||
// Progress is handled by the upload tasks system
|
||||
if (progress != null) {
|
||||
@@ -196,7 +210,10 @@ class FileListScreen extends HookConsumerWidget {
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'Usage Overview',
|
||||
child: UsageOverviewWidget(usage: usage, quota: quota),
|
||||
child: UsageOverviewWidget(
|
||||
usage: usage,
|
||||
quota: quota,
|
||||
).padding(horizontal: 8, vertical: 16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -534,7 +534,7 @@ class _LotteryPurchaseSheetState extends State<LotteryPurchaseSheet> {
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'The last selected number will be your special number.',
|
||||
'lotteryLastNumberSpecial'.tr(),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -738,11 +738,11 @@ class _LotteryPurchaseSheetState extends State<LotteryPurchaseSheet> {
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a multiplier';
|
||||
return 'lotteryMultiplierRequired'.tr();
|
||||
}
|
||||
final parsed = int.tryParse(value);
|
||||
if (parsed == null || parsed < 1 || parsed > 10) {
|
||||
return 'Multiplier must be between 1 and 10';
|
||||
return 'lotteryMultiplierRange'.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
@@ -68,6 +69,16 @@ class NotificationUnreadCountNotifier
|
||||
void clear() async {
|
||||
state = AsyncData(0);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/ring/notifications/count');
|
||||
state = AsyncData((response.data as num).toInt());
|
||||
} catch (_) {
|
||||
// Keep the current state if refresh fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
@@ -115,8 +126,36 @@ class NotificationListNotifier extends _$NotificationListNotifier
|
||||
class NotificationSheet extends HookConsumerWidget {
|
||||
const NotificationSheet({super.key});
|
||||
|
||||
IconData _getNotificationIcon(String topic) {
|
||||
switch (topic) {
|
||||
case 'post.replies':
|
||||
return Symbols.reply;
|
||||
case 'wallet.transactions':
|
||||
return Symbols.account_balance_wallet;
|
||||
case 'relationships.friends.request':
|
||||
return Symbols.person_add;
|
||||
case 'invites.chat':
|
||||
return Symbols.chat;
|
||||
case 'invites.realm':
|
||||
return Symbols.domain;
|
||||
case 'auth.login':
|
||||
return Symbols.login;
|
||||
case 'posts.new':
|
||||
return Symbols.post_add;
|
||||
case 'wallet.orders.paid':
|
||||
return Symbols.shopping_bag;
|
||||
case 'posts.reactions.new':
|
||||
return Symbols.add_reaction;
|
||||
default:
|
||||
return Symbols.notifications;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Refresh unread count when sheet opens to sync across devices
|
||||
ref.read(notificationUnreadCountNotifierProvider.notifier).refresh();
|
||||
|
||||
Future<void> markAllRead() async {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
@@ -149,12 +188,30 @@ class NotificationSheet extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final notification = data.items[index];
|
||||
final pfp = notification.meta['pfp'] as String?;
|
||||
final images = notification.meta['images'] as List?;
|
||||
final imageIds = images?.cast<String>() ?? [];
|
||||
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
leading:
|
||||
pfp != null
|
||||
? ProfilePictureWidget(fileId: pfp, radius: 20)
|
||||
: CircleAvatar(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
_getNotificationIcon(notification.topic),
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
title: Text(notification.title),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -187,6 +244,29 @@ class NotificationSheet extends HookConsumerWidget {
|
||||
).colorScheme.onSurface.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
if (imageIds.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children:
|
||||
imageIds.map((imageId) {
|
||||
return SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CloudImageWidget(
|
||||
fileId: imageId,
|
||||
aspectRatio: 1,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing:
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:island/pods/network.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/models/poll.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -393,7 +393,7 @@ class PollEditorScreen extends ConsumerWidget {
|
||||
showSnackBar(isUpdate ? 'pollUpdated'.tr() : 'pollCreated'.tr());
|
||||
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).maybePop(res.data);
|
||||
Navigator.of(context).maybePop(SnPoll.fromJson(res.data));
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
}
|
||||
@@ -415,23 +415,46 @@ class PollEditorScreen extends ConsumerWidget {
|
||||
});
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
title: Text(model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr()),
|
||||
actions: [
|
||||
if (kDebugMode)
|
||||
IconButton(
|
||||
tooltip: 'pollPreviewJsonDebug'.tr(),
|
||||
onPressed: () {
|
||||
_showDebugPreview(context, model);
|
||||
},
|
||||
icon: const Icon(Icons.visibility_outlined),
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
return SheetScaffold(
|
||||
titleText: model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr(),
|
||||
actions: [
|
||||
if (kDebugMode)
|
||||
IconButton(
|
||||
tooltip: 'pollPreviewJsonDebug'.tr(),
|
||||
onPressed: () {
|
||||
_showDebugPreview(context, model);
|
||||
},
|
||||
icon: const Icon(Icons.visibility_outlined),
|
||||
),
|
||||
],
|
||||
heightFactor: 0.9,
|
||||
onClose: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AlertDialog(
|
||||
title: Text('confirm'.tr()),
|
||||
content: Text('pollConfirmDiscard'.tr()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(ctx).colorScheme.error,
|
||||
),
|
||||
child: Text('discard'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
|
||||
@@ -256,21 +256,25 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ComposeFormFields(
|
||||
state: state,
|
||||
showPublisherAvatar: false,
|
||||
onPublisherTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const PublisherModal(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
state.currentPublisher.value = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
).padding(top: 16),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: ComposeFormFields(
|
||||
state: state,
|
||||
showPublisherAvatar: false,
|
||||
onPublisherTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const PublisherModal(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
state.currentPublisher.value = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
).padding(top: 16),
|
||||
),
|
||||
),
|
||||
|
||||
// Attachments preview
|
||||
ValueListenableBuilder<List<UniversalFile>>(
|
||||
|
||||
@@ -145,9 +145,11 @@ class PostActionButtons extends HookConsumerWidget {
|
||||
message: 'delete'.tr(),
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () {
|
||||
showConfirmAlert('deletePostHint'.tr(), 'deletePost'.tr()).then((
|
||||
confirm,
|
||||
) {
|
||||
showConfirmAlert(
|
||||
'deletePostHint'.tr(),
|
||||
'deletePost'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client
|
||||
|
||||
@@ -237,7 +237,9 @@ class PostSearchScreen extends HookConsumerWidget {
|
||||
controller: pubNameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'pubName'.tr(),
|
||||
border: OutlineInputBorder(),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
),
|
||||
onChanged:
|
||||
(value) => onSearchWithFilters(searchController.text),
|
||||
@@ -247,7 +249,9 @@ class PostSearchScreen extends HookConsumerWidget {
|
||||
controller: realmController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'realm'.tr(),
|
||||
border: OutlineInputBorder(),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
),
|
||||
onChanged:
|
||||
(value) => onSearchWithFilters(searchController.text),
|
||||
|
||||
@@ -73,10 +73,33 @@ class _PublisherBasisWidget extends StatelessWidget {
|
||||
Positioned(
|
||||
bottom: -24,
|
||||
left: 16,
|
||||
child: ProfilePictureWidget(
|
||||
file: data.picture,
|
||||
radius: 32,
|
||||
borderRadius: data.type == 0 ? null : 12,
|
||||
child: GestureDetector(
|
||||
child: Badge(
|
||||
isLabelVisible: data.type == 0,
|
||||
padding: EdgeInsets.all(3),
|
||||
label: Icon(
|
||||
Symbols.launch,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
offset: Offset(0, 48),
|
||||
child: ProfilePictureWidget(
|
||||
file: data.picture,
|
||||
radius: 32,
|
||||
borderRadius: data.type == 0 ? null : 12,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (data.account?.name != null) {
|
||||
Navigator.pop(context, true);
|
||||
context.pushNamed(
|
||||
'accountProfile',
|
||||
pathParameters: {'name': data.account!.name},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -427,6 +427,7 @@ class _RealmActionMenu extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteRealmHint'.tr(),
|
||||
'deleteRealm'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:island/widgets/navigation/fab_menu.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/chat/chat_summary.dart';
|
||||
|
||||
final currentRouteProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
@@ -24,6 +25,7 @@ const kTabRoutes = [
|
||||
'/realms',
|
||||
'/account',
|
||||
'/files',
|
||||
'/thought',
|
||||
'/creators',
|
||||
'/developers',
|
||||
];
|
||||
@@ -49,6 +51,8 @@ class TabsScreen extends HookConsumerWidget {
|
||||
notificationUnreadCountNotifierProvider,
|
||||
);
|
||||
|
||||
final chatUnreadCount = ref.watch(chatUnreadCountNotifierProvider);
|
||||
|
||||
final wideScreen = isWideScreen(context);
|
||||
|
||||
final destinations = [
|
||||
@@ -58,7 +62,11 @@ class TabsScreen extends HookConsumerWidget {
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'chat'.tr(),
|
||||
icon: const Icon(Symbols.forum_rounded),
|
||||
icon: Badge.count(
|
||||
count: chatUnreadCount.value ?? 0,
|
||||
isLabelVisible: (chatUnreadCount.value ?? 0) > 0,
|
||||
child: const Icon(Symbols.forum_rounded),
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'realms'.tr(),
|
||||
@@ -90,6 +98,10 @@ class TabsScreen extends HookConsumerWidget {
|
||||
label: 'files'.tr(),
|
||||
icon: const Icon(Symbols.folder_rounded),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'aiThought'.tr(),
|
||||
icon: const Icon(Symbols.bubble_chart),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'creatorHub'.tr(),
|
||||
icon: const Icon(Symbols.design_services_rounded),
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import "dart:convert";
|
||||
import "dart:math" as math;
|
||||
import "package:dio/dio.dart";
|
||||
import "package:easy_localization/easy_localization.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
@@ -9,18 +6,22 @@ import "package:riverpod_annotation/riverpod_annotation.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:island/models/thought.dart";
|
||||
import "package:island/pods/network.dart";
|
||||
import "package:island/pods/userinfo.dart";
|
||||
import "package:island/widgets/alert.dart";
|
||||
import "package:island/widgets/app_scaffold.dart";
|
||||
import "package:island/widgets/response.dart";
|
||||
import "package:island/widgets/thought/thought_sequence_list.dart";
|
||||
import "package:island/widgets/thought/thought_shared.dart";
|
||||
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
import "package:collection/collection.dart";
|
||||
|
||||
part 'think.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<bool> thoughtAvailableStaus(Ref ref) async {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final response = await apiClient.get('/insight/billing/status');
|
||||
return response.data['status'] == 'ok';
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<List<SnThinkingThought>> thoughtSequence(
|
||||
Ref ref,
|
||||
@@ -35,6 +36,13 @@ Future<List<SnThinkingThought>> thoughtSequence(
|
||||
.toList();
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<ThoughtServicesResponse> thoughtServices(Ref ref) async {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final response = await apiClient.get('/insight/thought/services');
|
||||
return ThoughtServicesResponse.fromJson(response.data);
|
||||
}
|
||||
|
||||
class ThoughtScreen extends HookConsumerWidget {
|
||||
const ThoughtScreen({super.key});
|
||||
|
||||
@@ -46,203 +54,32 @@ class ThoughtScreen extends HookConsumerWidget {
|
||||
? ref.watch(thoughtSequenceProvider(selectedSequenceId.value!))
|
||||
: const AsyncValue<List<SnThinkingThought>>.data([]);
|
||||
|
||||
final localThoughts = useState<List<SnThinkingThought>>([]);
|
||||
final currentTopic = useState<String?>('aiThought'.tr());
|
||||
|
||||
final messageController = useTextEditingController();
|
||||
final scrollController = useScrollController();
|
||||
final isStreaming = useState(false);
|
||||
final streamingText = useState<String>('');
|
||||
final functionCalls = useState<List<String>>([]);
|
||||
final reasoningChunks = useState<List<String>>([]);
|
||||
|
||||
final listController = useMemoized(() => ListController(), []);
|
||||
|
||||
// Scroll animation notifiers
|
||||
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
|
||||
|
||||
// Update local thoughts when provider data changes
|
||||
useEffect(() {
|
||||
thoughts.whenData((data) {
|
||||
// Server returns messages in DESC order (newest first), keep as-is for UI
|
||||
localThoughts.value = data;
|
||||
// Update topic from the first thought's sequence
|
||||
if (data.isNotEmpty && data.first.sequence?.topic != null) {
|
||||
currentTopic.value = data.first.sequence!.topic;
|
||||
} else {
|
||||
currentTopic.value = 'aiThought'.tr();
|
||||
// Extract sequence ID from loaded thoughts for the chat interface
|
||||
final sequenceIdFromThoughts = thoughts.maybeWhen(
|
||||
data: (thoughts) {
|
||||
if (thoughts.isNotEmpty && thoughts.first.sequenceId.isNotEmpty) {
|
||||
return thoughts.first.sequenceId;
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}, [thoughts]);
|
||||
return null;
|
||||
},
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
// Scroll to bottom when thoughts change or streaming state changes
|
||||
useEffect(() {
|
||||
if (localThoughts.value.isNotEmpty || isStreaming.value) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [localThoughts.value.length, isStreaming.value]);
|
||||
// Get initial thoughts and topic from provider
|
||||
final initialThoughts = thoughts.valueOrNull;
|
||||
final initialTopic =
|
||||
(initialThoughts?.isNotEmpty ?? false) &&
|
||||
initialThoughts!.first.sequence?.topic != null
|
||||
? initialThoughts.first.sequence!.topic
|
||||
: 'aiThought'.tr();
|
||||
|
||||
// Add scroll listener for gradient animations
|
||||
useEffect(() {
|
||||
void onScroll() {
|
||||
// Update gradient animations
|
||||
final pixels = scrollController.position.pixels;
|
||||
|
||||
// Bottom gradient: appears when not at bottom (pixels > 0)
|
||||
bottomGradientNotifier.value.value = (pixels / 500.0).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
scrollController.addListener(onScroll);
|
||||
return () => scrollController.removeListener(onScroll);
|
||||
}, [scrollController]);
|
||||
|
||||
void sendMessage() async {
|
||||
if (messageController.text.trim().isEmpty) return;
|
||||
|
||||
final userMessage = messageController.text.trim();
|
||||
|
||||
// Add user message to local thoughts
|
||||
final userInfo = ref.read(userInfoProvider);
|
||||
final now = DateTime.now();
|
||||
final userThought = SnThinkingThought(
|
||||
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
|
||||
content: userMessage,
|
||||
files: [],
|
||||
role: ThinkingThoughtRole.user,
|
||||
sequenceId: selectedSequenceId.value ?? '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sequence:
|
||||
selectedSequenceId.value != null
|
||||
? thoughts.value?.firstOrNull?.sequence ??
|
||||
SnThinkingSequence(
|
||||
id: selectedSequenceId.value!,
|
||||
accountId: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
)
|
||||
: SnThinkingSequence(
|
||||
id: '',
|
||||
accountId: userInfo.value!.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
localThoughts.value = [userThought, ...localThoughts.value];
|
||||
|
||||
final request = StreamThinkingRequest(
|
||||
userMessage: userMessage,
|
||||
sequenceId: selectedSequenceId.value,
|
||||
accpetProposals: ['post_create'],
|
||||
attachedMessages: [], // Message datas
|
||||
attachedPosts: [], // ID list for posts
|
||||
);
|
||||
|
||||
try {
|
||||
isStreaming.value = true;
|
||||
streamingText.value = '';
|
||||
functionCalls.value = [];
|
||||
reasoningChunks.value = [];
|
||||
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final response = await apiClient.post(
|
||||
'/insight/thought',
|
||||
data: request.toJson(),
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
sendTimeout: Duration(minutes: 1),
|
||||
receiveTimeout: Duration(minutes: 1),
|
||||
),
|
||||
);
|
||||
|
||||
final stream = response.data.stream;
|
||||
final lineBuffer = StringBuffer();
|
||||
|
||||
stream.listen(
|
||||
(data) {
|
||||
final chunk = utf8.decode(data);
|
||||
lineBuffer.write(chunk);
|
||||
final lines = lineBuffer.toString().split('\n');
|
||||
lineBuffer.clear();
|
||||
lineBuffer.write(lines.last); // keep incomplete line
|
||||
|
||||
for (final line in lines.sublist(0, lines.length - 1)) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
try {
|
||||
if (line.startsWith('data: ')) {
|
||||
final jsonStr = line.substring(6);
|
||||
final event = jsonDecode(jsonStr);
|
||||
final type = event['type'];
|
||||
final eventData = event['data'];
|
||||
if (type == 'text') {
|
||||
streamingText.value += eventData;
|
||||
} else if (type == 'function_call') {
|
||||
functionCalls.value = [
|
||||
...functionCalls.value,
|
||||
JsonEncoder.withIndent(' ').convert(eventData),
|
||||
];
|
||||
} else if (type == 'reasoning') {
|
||||
reasoningChunks.value = [
|
||||
...reasoningChunks.value,
|
||||
eventData,
|
||||
];
|
||||
}
|
||||
} else if (line.startsWith('topic: ')) {
|
||||
final jsonStr = line.substring(7);
|
||||
final event = jsonDecode(jsonStr);
|
||||
currentTopic.value = event['data'];
|
||||
} else if (line.startsWith('thought: ')) {
|
||||
final jsonStr = line.substring(9);
|
||||
final event = jsonDecode(jsonStr);
|
||||
final aiThought = SnThinkingThought.fromJson(event['data']);
|
||||
localThoughts.value = [aiThought, ...localThoughts.value];
|
||||
if (selectedSequenceId.value == null &&
|
||||
aiThought.sequenceId.isNotEmpty) {
|
||||
selectedSequenceId.value = aiThought.sequenceId;
|
||||
}
|
||||
isStreaming.value = false;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parsing errors for individual events
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (isStreaming.value) {
|
||||
isStreaming.value = false;
|
||||
showErrorAlert('thoughtParseError'.tr());
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
isStreaming.value = false;
|
||||
if (error is DioException && error.response?.data is ResponseBody) {
|
||||
showErrorAlert('toughtParseError'.tr());
|
||||
} else {
|
||||
showErrorAlert(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
messageController.clear();
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
} catch (error) {
|
||||
isStreaming.value = false;
|
||||
showErrorAlert(error);
|
||||
}
|
||||
}
|
||||
final statusAsync = ref.watch(thoughtAvailableStausProvider);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
title: Text(currentTopic.value ?? 'aiThought'.tr()),
|
||||
title: Text(initialTopic ?? 'aiThought'.tr()),
|
||||
leading: const PageBackButton(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.history),
|
||||
@@ -259,137 +96,96 @@ class ThoughtScreen extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (localThoughts.value.isNotEmpty &&
|
||||
!isStreaming.value &&
|
||||
localThoughts.value.last.role == ThinkingThoughtRole.assistant)
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.add),
|
||||
tooltip: 'thoughtNewConversation'.tr(),
|
||||
onPressed: () {
|
||||
// Clear current conversation and start new one
|
||||
selectedSequenceId.value = null;
|
||||
localThoughts.value = [];
|
||||
currentTopic.value = 'aiThought'.tr();
|
||||
messageController.clear();
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// Thoughts list
|
||||
Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: Column(
|
||||
body: statusAsync.maybeWhen(
|
||||
data: (status) {
|
||||
final retry = useMemoized(
|
||||
() => () async {
|
||||
showLoadingModal(context);
|
||||
try {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.post('/insight/billing/retry');
|
||||
showSnackBar('Retried billing process');
|
||||
ref.invalidate(thoughtAvailableStausProvider);
|
||||
} catch (e) {
|
||||
showSnackBar('Failed to retry billing');
|
||||
}
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
},
|
||||
[context, ref],
|
||||
);
|
||||
|
||||
final thoughtsBody = thoughts.when(
|
||||
data:
|
||||
(thoughtList) => ThoughtChatInterface(
|
||||
initialThoughts: thoughtList,
|
||||
initialSequenceId: sequenceIdFromThoughts,
|
||||
initialTopic: initialTopic,
|
||||
isDisabled: !status,
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, _) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry:
|
||||
() =>
|
||||
selectedSequenceId.value != null
|
||||
? ref.invalidate(
|
||||
thoughtSequenceProvider(
|
||||
selectedSequenceId.value!,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
return status
|
||||
? thoughtsBody
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: thoughts.when(
|
||||
data:
|
||||
(thoughtList) => SuperListView.builder(
|
||||
listController: listController,
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
top: 16,
|
||||
bottom:
|
||||
MediaQuery.of(context).padding.bottom +
|
||||
80, // Leave space for thought input
|
||||
),
|
||||
reverse: true,
|
||||
itemCount:
|
||||
localThoughts.value.length +
|
||||
(isStreaming.value ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (isStreaming.value && index == 0) {
|
||||
return ThoughtItem(
|
||||
isStreaming: true,
|
||||
streamingText: streamingText.value,
|
||||
reasoningChunks: reasoningChunks.value,
|
||||
streamingFunctionCalls: functionCalls.value,
|
||||
);
|
||||
}
|
||||
final thoughtIndex =
|
||||
isStreaming.value ? index - 1 : index;
|
||||
final thought = localThoughts.value[thoughtIndex];
|
||||
return ThoughtItem(
|
||||
thought: thought,
|
||||
thoughtIndex: thoughtIndex,
|
||||
);
|
||||
},
|
||||
),
|
||||
loading:
|
||||
() =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, _) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry:
|
||||
() =>
|
||||
selectedSequenceId.value != null
|
||||
? ref.invalidate(
|
||||
thoughtSequenceProvider(
|
||||
selectedSequenceId.value!,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
MaterialBanner(
|
||||
leading: const Icon(Symbols.error),
|
||||
content: const Text(
|
||||
'You have unpaid orders. Please settle your payment to continue using the service.',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
retry();
|
||||
},
|
||||
child: Text('retry'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(child: thoughtsBody),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom gradient - appears when scrolling towards newer thoughts (behind thought input)
|
||||
AnimatedBuilder(
|
||||
animation: bottomGradientNotifier.value,
|
||||
builder:
|
||||
(context, child) => Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Opacity(
|
||||
opacity: bottomGradientNotifier.value.value,
|
||||
child: Container(
|
||||
height: math.min(
|
||||
MediaQuery.of(context).size.height * 0.1,
|
||||
128,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer.withOpacity(0.8),
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse:
|
||||
() => thoughts.when(
|
||||
data:
|
||||
(thoughtList) => ThoughtChatInterface(
|
||||
initialThoughts: thoughtList,
|
||||
initialTopic: initialTopic,
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, _) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry:
|
||||
() =>
|
||||
selectedSequenceId.value != null
|
||||
? ref.invalidate(
|
||||
thoughtSequenceProvider(
|
||||
selectedSequenceId.value!,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Thought Input positioned above gradient (higher z-index)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0, // At the very bottom, above gradient
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: ThoughtInput(
|
||||
messageController: messageController,
|
||||
isStreaming: isStreaming.value,
|
||||
onSend: sendMessage,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,25 @@ part of 'think.dart';
|
||||
// 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';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
@@ -152,5 +171,25 @@ class _ThoughtSequenceProviderElement
|
||||
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: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
|
||||
@@ -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:flutter/material.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:island/models/thought.dart";
|
||||
import "package:island/pods/network.dart";
|
||||
import "package:island/pods/userinfo.dart";
|
||||
import "package:island/screens/thought/think.dart";
|
||||
import "package:island/widgets/alert.dart";
|
||||
import "package:island/widgets/content/sheet.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 {
|
||||
final List<Map<String, dynamic>> attachedMessages;
|
||||
@@ -42,275 +38,68 @@ class ThoughtSheet extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sequenceId = useState<String?>(null);
|
||||
final localThoughts = useState<List<SnThinkingThought>>([]);
|
||||
final currentTopic = useState<String?>('aiThought'.tr());
|
||||
final chatState = useThoughtChat(
|
||||
ref,
|
||||
attachedMessages: attachedMessages,
|
||||
attachedPosts: attachedPosts,
|
||||
);
|
||||
|
||||
final messageController = useTextEditingController();
|
||||
final scrollController = useScrollController();
|
||||
final isStreaming = useState(false);
|
||||
final streamingText = useState<String>('');
|
||||
final functionCalls = useState<List<String>>([]);
|
||||
final reasoningChunks = useState<List<String>>([]);
|
||||
|
||||
final listController = useMemoized(() => ListController(), []);
|
||||
|
||||
// Scroll animation notifiers
|
||||
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
final statusAsync = ref.watch(thoughtAvailableStausProvider);
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: currentTopic.value ?? 'aiThought'.tr(),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Thoughts list
|
||||
Center(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: Column(
|
||||
titleText: chatState.currentTopic.value ?? 'aiThought'.tr(),
|
||||
child: statusAsync.maybeWhen(
|
||||
data: (status) {
|
||||
final retry = useMemoized(
|
||||
() => () async {
|
||||
showLoadingModal(context);
|
||||
try {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.post('/insight/billing/retry');
|
||||
showSnackBar('Retried billing process');
|
||||
ref.invalidate(thoughtAvailableStausProvider);
|
||||
} catch (e) {
|
||||
showSnackBar('Failed to retry billing');
|
||||
}
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
},
|
||||
[context, ref],
|
||||
);
|
||||
|
||||
final chatInterface = ThoughtChatInterface(
|
||||
attachedMessages: attachedMessages,
|
||||
attachedPosts: attachedPosts,
|
||||
isDisabled: !status,
|
||||
);
|
||||
return status
|
||||
? chatInterface
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SuperListView.builder(
|
||||
listController: listController,
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.only(
|
||||
top: 16,
|
||||
bottom:
|
||||
MediaQuery.of(context).padding.bottom +
|
||||
80, // Leave space for thought input
|
||||
),
|
||||
reverse: true,
|
||||
itemCount:
|
||||
localThoughts.value.length +
|
||||
(isStreaming.value ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (isStreaming.value && index == 0) {
|
||||
return ThoughtItem(
|
||||
isStreaming: true,
|
||||
streamingText: streamingText.value,
|
||||
reasoningChunks: reasoningChunks.value,
|
||||
streamingFunctionCalls: functionCalls.value,
|
||||
);
|
||||
}
|
||||
final thoughtIndex =
|
||||
isStreaming.value ? index - 1 : index;
|
||||
final thought = localThoughts.value[thoughtIndex];
|
||||
return ThoughtItem(
|
||||
thought: thought,
|
||||
thoughtIndex: thoughtIndex,
|
||||
);
|
||||
},
|
||||
MaterialBanner(
|
||||
leading: const Icon(Symbols.error),
|
||||
content: const Text(
|
||||
'You have unpaid orders. Please settle your payment to continue using the service.',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
retry();
|
||||
},
|
||||
child: Text('retry'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(child: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ class CreateFundSheet extends StatefulWidget {
|
||||
|
||||
class _CreateFundSheetState extends State<CreateFundSheet> {
|
||||
final amountController = TextEditingController();
|
||||
final splitsController = TextEditingController(text: '1');
|
||||
final messageController = TextEditingController();
|
||||
String selectedCurrency = 'golds';
|
||||
int selectedSplitType = 0; // 0: even, 1: random
|
||||
@@ -64,6 +65,7 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
||||
void dispose() {
|
||||
amountController.dispose();
|
||||
messageController.dispose();
|
||||
splitsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -103,17 +105,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
||||
labelText: 'enterAmount'.tr(),
|
||||
hintText: '0.00',
|
||||
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
|
||||
border: OutlineInputBorder(),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -136,17 +130,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
||||
DropdownButtonFormField<String>(
|
||||
value: selectedCurrency,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -173,49 +159,84 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
||||
},
|
||||
),
|
||||
|
||||
// Split Type Section (only show when there are 2+ recipients)
|
||||
if (selectedRecipients.length >= 2) ...[
|
||||
const Gap(16),
|
||||
Text(
|
||||
'splitType'.tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
const Gap(16),
|
||||
|
||||
// Amount of Splits Section
|
||||
Text(
|
||||
'amountOfSplits'.tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
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),
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onChanged: (value) {
|
||||
if (value.isEmpty && selectedRecipients.isNotEmpty) {
|
||||
splitsController.text =
|
||||
selectedRecipients.length.toString();
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
const Gap(16),
|
||||
Text(
|
||||
'splitType'.tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
|
||||
@@ -370,17 +391,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
||||
labelText: 'personalMessage'.tr(),
|
||||
hintText: 'addPersonalMessageForRecipients'.tr(),
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -520,14 +533,15 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
||||
|
||||
Future<void> _createFund() async {
|
||||
final amount = double.tryParse(amountController.text);
|
||||
final splits = int.tryParse(splitsController.text);
|
||||
|
||||
if (amount == null || amount <= 0) {
|
||||
showErrorAlert('invalidAmount'.tr());
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedRecipients.isEmpty) {
|
||||
showErrorAlert('noRecipientsSelected'.tr());
|
||||
if (splits == null || splits <= 0) {
|
||||
showErrorAlert('invalidNumberOfSplits'.tr());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -535,6 +549,7 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
||||
'currency': selectedCurrency,
|
||||
'total_amount': amount,
|
||||
'split_type': selectedSplitType,
|
||||
'amount_of_splits': splits,
|
||||
'recipient_account_ids': selectedRecipients.map((r) => r.id).toList(),
|
||||
'message':
|
||||
messageController.text.trim().isEmpty
|
||||
@@ -610,17 +625,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
|
||||
labelText: 'enterAmount'.tr(),
|
||||
hintText: '0.00',
|
||||
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
|
||||
border: OutlineInputBorder(),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -643,17 +650,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
|
||||
DropdownButtonFormField<String>(
|
||||
value: selectedCurrency,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -817,17 +816,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
|
||||
labelText: 'transferRemark'.tr(),
|
||||
hintText: 'addRemarkForTransfer'.tr(),
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1863,6 +1854,6 @@ class WalletScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
const Map<String, IconData> kCurrencyIconData = {
|
||||
'points': Symbols.toll,
|
||||
'golds': Symbols.attach_money,
|
||||
'points': Symbols.bolt,
|
||||
'golds': Symbols.diamond,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,3 +16,10 @@ class PostCreatedEvent {
|
||||
class ChatRoomsRefreshEvent {
|
||||
const ChatRoomsRefreshEvent();
|
||||
}
|
||||
|
||||
/// Event fired when OIDC auth callback is received
|
||||
class OidcAuthCallbackEvent {
|
||||
final String challengeId;
|
||||
|
||||
const OidcAuthCallbackEvent(this.challengeId);
|
||||
}
|
||||
|
||||
@@ -82,12 +82,32 @@ class _ParsedVersion implements Comparable<_ParsedVersion> {
|
||||
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
|
||||
int compareTo(_ParsedVersion other) {
|
||||
if (major != other.major) return major.compareTo(other.major);
|
||||
if (minor != other.minor) return minor.compareTo(other.minor);
|
||||
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
|
||||
@@ -244,13 +264,14 @@ class UpdateService {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _WindowsUpdateDialog(
|
||||
updateUrl: url,
|
||||
onComplete: () {
|
||||
// Close the update sheet
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
builder:
|
||||
(context) => _WindowsUpdateDialog(
|
||||
updateUrl: url,
|
||||
onComplete: () {
|
||||
// Close the update sheet
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -321,7 +342,9 @@ class _WindowsUpdateDialog extends StatefulWidget {
|
||||
|
||||
class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
|
||||
final ValueNotifier<double?> progressNotifier = ValueNotifier<double?>(null);
|
||||
final ValueNotifier<String> messageNotifier = ValueNotifier<String>('Downloading installer...');
|
||||
final ValueNotifier<String> messageNotifier = ValueNotifier<String>(
|
||||
'Downloading installer...',
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -392,16 +415,17 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
|
||||
Navigator.of(context).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Update Failed'),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Update Failed'),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -458,7 +482,9 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
talker.info('[Update] Windows installer downloaded successfully to: $filePath');
|
||||
talker.info(
|
||||
'[Update] Windows installer downloaded successfully to: $filePath',
|
||||
);
|
||||
return filePath;
|
||||
} else {
|
||||
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;
|
||||
} catch (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');
|
||||
|
||||
final dir = Directory(extractDir);
|
||||
final exeFiles = dir
|
||||
.listSync()
|
||||
.where((f) => f is File && f.path.endsWith('.exe'))
|
||||
.toList();
|
||||
final exeFiles =
|
||||
dir
|
||||
.listSync()
|
||||
.where((f) => f is File && f.path.endsWith('.exe'))
|
||||
.toList();
|
||||
|
||||
if (exeFiles.isEmpty) {
|
||||
talker.info('[Update] No .exe file found in extracted directory');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user