Compare commits

..

68 Commits

Author SHA1 Message Date
5fc8859f3b 🚀 Launch 3.4.0+149 2025-11-25 00:06:45 +08:00
e30e7adbe2 🐛 Trying to fix NSE attachment 2025-11-25 00:05:07 +08:00
68be4db160 Able to upload from share 2025-11-25 00:00:04 +08:00
aa91e376ca 🐛 Fix bugs in the share sheet 2025-11-24 23:49:19 +08:00
caffb85588 Explore screen has a drop to share 2025-11-24 23:36:53 +08:00
521b192205 🐛 Fix edit post got truncated post lead to wrong state of editing, close #195 2025-11-24 23:28:43 +08:00
77ac0428ea 💄 The universal escape now can close the fade made dialog 2025-11-24 23:21:52 +08:00
88c8227c66 ♻️ Dangerous confirm dialog variant 2025-11-24 23:13:35 +08:00
b20d8350a8 💄 Alert max width 2025-11-24 23:01:29 +08:00
98b27bed0e 🐛 Fix list overlap with some UI element on the screen 2025-11-24 22:56:08 +08:00
3a7d8b1a0d 🐛 Fix file dashboard icon sometimes overflow 2025-11-24 22:51:22 +08:00
b4801d6af6 🐛 Fix site unable to delete, close #196 2025-11-24 22:50:24 +08:00
aab5b957af 🐛 Fix mobile site didn't show domain 2025-11-24 22:43:25 +08:00
43d706a184 💄 Adjust the comment row styling 2025-11-24 22:34:38 +08:00
98df275f88 🐛 Fix compose article unable to scroll close #194 2025-11-24 22:30:56 +08:00
5663df6ef1 🚀 Launch 3.3.0+148 2025-11-23 13:07:15 +08:00
e996a0c95f 👽 Update the verification mark 2025-11-23 13:04:07 +08:00
a090e93f57 🗑️ Remove the chat role display in message 2025-11-23 12:56:02 +08:00
c69034c071 Better notification list 2025-11-23 12:55:49 +08:00
369ea6cf5b 🐛 Fix unmounted setState 2025-11-23 12:46:49 +08:00
2e371b5296 💄 More accurate notification unread count 2025-11-23 12:45:19 +08:00
2e9d61bcfa Chat unread indicator across all chat 2025-11-23 12:40:52 +08:00
9c2b5b0dfa 🐛 Fix further remote messages will not be loaded 2025-11-23 12:25:46 +08:00
3b40f515b3 🐛 Fix file list go back to wrong page 2025-11-23 12:18:57 +08:00
5ee61dbef2 Pagination in chat message sync 2025-11-23 12:18:46 +08:00
b151ef6686 🐛 Try to fix message loading 2025-11-23 11:54:51 +08:00
ff934d0f08 💄 Update the captcha style 2025-11-23 11:39:52 +08:00
abe5ded896 🚀 Launch 3.3.0+147 2025-11-23 02:01:00 +08:00
f1d72a5215 🐛 Fix android build no check 2025-11-23 02:00:13 +08:00
864cbe73b7 🐛 Try to fix share intent fails 2025-11-23 01:54:01 +08:00
108a6da074 🌐 Localized files 2025-11-23 01:43:54 +08:00
f9a09599c9 ⬆️ Upgrade dependecies 2025-11-23 01:23:43 +08:00
9067dadd3e 🐛 Fix reaction sheet popover goes out of the screen 2025-11-23 01:21:54 +08:00
09f8df1e78 💄 Optimize design of the call content 2025-11-23 01:12:04 +08:00
2c5f246c55 💄 Redesign the video of the call 2025-11-23 00:53:00 +08:00
a66c6ea654 💫 Animated call overlay 2025-11-23 00:35:42 +08:00
3ad4bb4518 ♻️ Rebuild the call 2025-11-23 00:26:40 +08:00
53f0dcb825 Optimize performance for message item 2025-11-22 20:46:41 +08:00
557f5a2389 👔 Hide the friends overview on mobile 2025-11-22 20:26:41 +08:00
78f14f890f 💄 Optimize embedded section of chat input 2025-11-22 20:11:01 +08:00
77b2effb34 💫 Update the animation of alert's dialog 2025-11-22 19:18:42 +08:00
f02b4abf65 💄 Optimize audio player height 2025-11-22 18:58:25 +08:00
3f37c4f761 ♻️ Remove platform alert and use flutter dialog instead 2025-11-22 18:56:18 +08:00
5deb910fa4 ♻️ Refactored all ScaffoldMessager to use unifined snackbar API 2025-11-22 18:42:12 +08:00
f50a19f573 🐛 Dozens of bug fixes 2025-11-22 18:36:10 +08:00
98c8a356e8 ♻️ Rebuild the activity heatmap to close #189 2025-11-22 16:19:23 +08:00
d0c16ea08f Site quick open page 2025-11-22 16:02:30 +08:00
f2c1b2a531 File management actions 2025-11-22 16:01:27 +08:00
3061f0c5a9 Site file edit 2025-11-22 15:43:35 +08:00
98f7f33c65 Site file management able to navigate folders 2025-11-22 15:24:16 +08:00
d9af5d32fd Site file management able to upload site 2025-11-22 14:59:44 +08:00
f2031697ec 🐛 Fix the site refresh didn't wrok 2025-11-22 14:44:41 +08:00
9b85b7573c 💄 Optimize publication site screen 2025-11-22 14:39:03 +08:00
4fb739b33b 💄 Desktop optimization for the site dashboard 2025-11-21 00:40:45 +08:00
c03ba3bc3a ♻️ Breakdown of the site detail page 2025-11-21 00:34:39 +08:00
fc65440420 🐛 Fix file upload in site 2025-11-21 00:24:35 +08:00
7b85533184 Pages management in site detail 2025-11-21 00:05:36 +08:00
77d9eb60c6 Page details 2025-11-20 22:40:20 +08:00
4d8953cd22 Site mode 2025-11-20 21:58:59 +08:00
fafa460fe8 Site basis 2025-11-20 21:29:08 +08:00
faf3a677d4 Rewind AI slop 2025-11-20 00:21:21 +08:00
0f644a0234 🐛 Fix chat list tiles renders wrong account 2025-11-20 00:11:13 +08:00
18d16fdd57 🐛 Fix bugs in message db 2025-11-20 00:01:36 +08:00
18e890d63c 💄 Optimize cloud file sizing 2025-11-19 22:56:38 +08:00
9c5e50c16a 🐛 Fix share post via screenshot entirely broke 2025-11-19 22:49:47 +08:00
96a2c8182e ⬆️ Upgrade dependecies 2025-11-19 21:41:57 +08:00
56b27c3e82 Use cached chat rooms for first time render chat 2025-11-19 00:50:22 +08:00
ad4bf94195 ♻️ Refactored chat db 2025-11-19 00:29:22 +08:00
122 changed files with 16394 additions and 1653 deletions

View File

@@ -180,6 +180,7 @@
"noFortuneData": "No fortune data available for this month.", "noFortuneData": "No fortune data available for this month.",
"creatorHub": "Creator Hub", "creatorHub": "Creator Hub",
"creatorHubDescription": "Manage posts, analytics, and more.", "creatorHubDescription": "Manage posts, analytics, and more.",
"publicationSites": "Publication Sites",
"developerPortal": "Developer Portal", "developerPortal": "Developer Portal",
"developerPortalDescription": "Build with Solar Network™.", "developerPortalDescription": "Build with Solar Network™.",
"statusCreateHint": "What's on your mind? Add a status.", "statusCreateHint": "What's on your mind? Add a status.",
@@ -232,6 +233,9 @@
"pickFile": "Pick a file", "pickFile": "Pick a file",
"uploading": "Uploading", "uploading": "Uploading",
"uploadingProgress": "Uploading {} of {}", "uploadingProgress": "Uploading {} of {}",
"upload": "Upload",
"uploadSuccess": "Upload successful!",
"wouldYouLikeToViewFile": "Would you like to view the file?",
"uploadAll": "Upload All", "uploadAll": "Upload All",
"stickerCopyPlaceholder": "Copy Placeholder", "stickerCopyPlaceholder": "Copy Placeholder",
"realmSelection": "Select a Realm", "realmSelection": "Select a Realm",
@@ -1109,7 +1113,6 @@
"deleteRecycledFiles": "Delete Recycled Files", "deleteRecycledFiles": "Delete Recycled Files",
"recycledFilesDeleted": "Recycled files deleted successfully", "recycledFilesDeleted": "Recycled files deleted successfully",
"failedToDeleteRecycledFiles": "Failed to delete recycled files", "failedToDeleteRecycledFiles": "Failed to delete recycled files",
"upload": "Upload",
"updateAvailable": "Update available", "updateAvailable": "Update available",
"noChangelogProvided": "No changelog provided.", "noChangelogProvided": "No changelog provided.",
"useSecondarySourceForDownload": "Use secondary source for download", "useSecondarySourceForDownload": "Use secondary source for download",
@@ -1339,5 +1342,137 @@
"orCreateWith": "Or\ncreate with", "orCreateWith": "Or\ncreate with",
"unindexedFiles": "Unindexed files", "unindexedFiles": "Unindexed files",
"folder": "Folder", "folder": "Folder",
"clearCompleted": "Clear Completed" "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"
}

View File

@@ -585,10 +585,10 @@
"unknownChat": "未知聊天", "unknownChat": "未知聊天",
"addAdditionalMessage": "添加附加消息……", "addAdditionalMessage": "添加附加消息……",
"uploadingFiles": "上传文件中……", "uploadingFiles": "上传文件中……",
"sharedSuccessfully": "分享成功", "sharedSuccessfully": "分享成功",
"shareSuccess": "分享成功", "shareSuccess": "分享成功",
"shareToSpecificChatSuccess": "成功分享至 {}", "shareToSpecificChatSuccess": "成功分享至 {}",
"wouldYouLikeToGoToChat": "是否前往该聊天?", "wouldYouLikeToGoToChat": "是否前往该聊天页面",
"no": "否", "no": "否",
"yes": "是", "yes": "是",
"navigateToChat": "前往聊天", "navigateToChat": "前往聊天",
@@ -1092,4 +1092,4 @@
"aiThought": "寻思", "aiThought": "寻思",
"aiThoughtTitle": "让 SN 酱寻思寻思", "aiThoughtTitle": "让 SN 酱寻思寻思",
"thoughtUnpaidHint": "寻思因为有未支付的订单而被禁用" "thoughtUnpaidHint": "寻思因为有未支付的订单而被禁用"
} }

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -140,21 +140,29 @@ class NotificationService: UNNotificationServiceExtension {
guard !attachmentUrls.isEmpty else { guard !attachmentUrls.isEmpty else {
print("Invalid URLs for attachments: \(attachmentUrls)") print("Invalid URLs for attachments: \(attachmentUrls)")
self.contentHandler?(content)
return return
} }
let targetSize = 512 let targetSize = 512
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit) 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 { for attachmentUrl in attachmentUrls {
guard let remoteUrl = URL(string: attachmentUrl) else { guard let remoteUrl = URL(string: attachmentUrl) else {
print("Invalid URL for attachment: \(attachmentUrl)") 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 ? [ KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [
.processor(scaleProcessor) .processor(scaleProcessor)
] : nil) { [weak self] result in ] : nil) { [weak self] result in
defer { dispatchGroup.leave() }
guard let self = self else { return } guard let self = self else { return }
switch result { switch result {
@@ -166,49 +174,34 @@ class NotificationService: UNNotificationServiceExtension {
do { do {
// Write the image data to a temporary file for UNNotificationAttachment // Write the image data to a temporary file for UNNotificationAttachment
try retrievalResult.image.pngData()?.write(to: cachedFileUrl) 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 { } catch {
print("Failed to write media to temporary file: \(error.localizedDescription)") print("Failed to write media to temporary file: \(error.localizedDescription)")
self.contentHandler?(content)
} }
case .failure(let error): case .failure(let error):
print("Failed to retrieve image: \(error.localizedDescription)") 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 dispatchGroup.notify(queue: .main) { [weak self] in
self.contentHandler?(content) guard let self = self else { return }
content.attachments = attachments
self.contentHandler?(content)
}
} }
private func createMessageIntent(with sender: INPerson, meta: [AnyHashable: Any], body: String) -> INSendMessageIntent { private func createMessageIntent(with sender: INPerson, meta: [AnyHashable: Any], body: String) -> INSendMessageIntent {
INSendMessageIntent( INSendMessageIntent(
recipients: nil, recipients: nil,

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
@@ -6,6 +8,58 @@ import 'package:island/pods/chat/chat_subscribe.dart';
part 'chat_summary.g.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 @riverpod
class ChatSummary extends _$ChatSummary { class ChatSummary extends _$ChatSummary {
@override @override
@@ -41,6 +95,14 @@ class ChatSummary extends _$ChatSummary {
state.whenData((summaries) { state.whenData((summaries) {
final summary = summaries[chatId]; final summary = summaries[chatId];
if (summary != null) { if (summary != null) {
// Decrement global unread count
final unreadToDecrement = summary.unreadCount;
if (unreadToDecrement > 0) {
ref
.read(chatUnreadCountNotifierProvider.notifier)
.decrement(unreadToDecrement);
}
state = AsyncData({ state = AsyncData({
...summaries, ...summaries,
chatId: SnChatSummary( chatId: SnChatSummary(

View File

@@ -6,6 +6,24 @@ part of 'chat_summary.dart';
// RiverpodGenerator // 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'; String _$chatSummaryHash() => r'33815a3bd81d20902b7063e8194fe336930df9b4';
/// See also [ChatSummary]. /// See also [ChatSummary].

View File

@@ -6,6 +6,7 @@ import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/database/drift_db.dart"; import "package:island/database/drift_db.dart";
import "package:island/database/message.dart"; import "package:island/database/message.dart";
import "package:island/models/account.dart";
import "package:island/models/chat.dart"; import "package:island/models/chat.dart";
import "package:island/models/file.dart"; import "package:island/models/file.dart";
import "package:island/models/poll.dart"; import "package:island/models/poll.dart";
@@ -20,6 +21,7 @@ import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:uuid/uuid.dart"; import "package:uuid/uuid.dart";
import "package:island/screens/chat/chat.dart"; import "package:island/screens/chat/chat.dart";
import "package:island/pods/chat/chat_rooms.dart"; import "package:island/pods/chat/chat_rooms.dart";
import "package:island/screens/account/profile.dart";
part 'messages_notifier.g.dart'; part 'messages_notifier.g.dart';
@@ -43,8 +45,11 @@ class MessagesNotifier extends _$MessagesNotifier {
bool _isSyncing = false; bool _isSyncing = false;
bool _isJumping = false; bool _isJumping = false;
bool _isUpdatingState = false; bool _isUpdatingState = false;
bool _allRemoteMessagesFetched = false;
DateTime? _lastPauseTime; DateTime? _lastPauseTime;
late final Future<SnAccount?> Function(String) _fetchAccount;
@override @override
FutureOr<List<LocalChatMessage>> build(String roomId) async { FutureOr<List<LocalChatMessage>> build(String roomId) async {
_roomId = roomId; _roomId = roomId;
@@ -53,6 +58,15 @@ class MessagesNotifier extends _$MessagesNotifier {
final room = await ref.watch(chatroomProvider(roomId).future); final room = await ref.watch(chatroomProvider(roomId).future);
final identity = await ref.watch(chatroomIdentityProvider(roomId).future); final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
// Initialize fetch account method for corrupted data recovery
_fetchAccount = (String accountId) async {
try {
return await ref.watch(accountProvider(accountId).future);
} catch (_) {
return null;
}
};
if (room == null) { if (room == null) {
throw Exception('Room not found'); throw Exception('Room not found');
} }
@@ -133,6 +147,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_roomId, _roomId,
searchQuery, searchQuery,
withAttachments: withAttachments, withAttachments: withAttachments,
fetchAccount: _fetchAccount,
); );
} else { } else {
final chatMessagesFromDb = await _database.getMessagesForRoom( final chatMessagesFromDb = await _database.getMessagesForRoom(
@@ -140,8 +155,16 @@ class MessagesNotifier extends _$MessagesNotifier {
offset: offset, offset: offset,
limit: take, limit: take,
); );
dbMessages = dbMessages = await Future.wait(
chatMessagesFromDb.map(_database.companionToMessage).toList(); chatMessagesFromDb
.map(
(msg) => _database.companionToMessage(
msg,
fetchAccount: _fetchAccount,
),
)
.toList(),
);
} }
List<LocalChatMessage> filteredMessages = dbMessages; List<LocalChatMessage> filteredMessages = dbMessages;
@@ -202,8 +225,14 @@ class MessagesNotifier extends _$MessagesNotifier {
offset: offset, offset: offset,
limit: take, limit: take,
); );
final dbMessages = final dbMessages = await Future.wait(
chatMessagesFromDb.map(_database.companionToMessage).toList(); chatMessagesFromDb
.map(
(msg) =>
_database.companionToMessage(msg, fetchAccount: _fetchAccount),
)
.toList(),
);
// Always ensure unique messages to prevent duplicate keys // Always ensure unique messages to prevent duplicate keys
final uniqueMessages = <LocalChatMessage>[]; final uniqueMessages = <LocalChatMessage>[];
@@ -250,6 +279,7 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
if (offset >= _totalCount!) { if (offset >= _totalCount!) {
_allRemoteMessagesFetched = true;
return []; return [];
} }
@@ -271,7 +301,7 @@ class MessagesNotifier extends _$MessagesNotifier {
}).toList(); }).toList();
for (final message in messages) { for (final message in messages) {
await _database.saveMessage(_database.messageToCompanion(message)); await _database.saveMessageWithSender(message);
if (message.nonce != null) { if (message.nonce != null) {
_pendingMessages.removeWhere( _pendingMessages.removeWhere(
(_, pendingMsg) => pendingMsg.nonce == message.nonce, (_, pendingMsg) => pendingMsg.nonce == message.nonce,
@@ -279,6 +309,11 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
} }
// Check if we've fetched all remote messages
if (offset + messages.length >= _totalCount!) {
_allRemoteMessagesFetched = true;
}
return messages; return messages;
} }
@@ -288,6 +323,7 @@ class MessagesNotifier extends _$MessagesNotifier {
return; return;
} }
_isSyncing = true; _isSyncing = true;
_allRemoteMessagesFetched = false;
talker.log('Starting message sync'); talker.log('Starting message sync');
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true); Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
@@ -300,7 +336,10 @@ class MessagesNotifier extends _$MessagesNotifier {
final lastMessage = final lastMessage =
dbMessages.isEmpty dbMessages.isEmpty
? null ? null
: _database.companionToMessage(dbMessages.first); : await _database.companionToMessage(
dbMessages.first,
fetchAccount: _fetchAccount,
);
if (lastMessage == null) { if (lastMessage == null) {
talker.log('No local messages, fetching from network'); talker.log('No local messages, fetching from network');
@@ -312,19 +351,48 @@ class MessagesNotifier extends _$MessagesNotifier {
return; return;
} }
final resp = await _apiClient.post( // Sync with pagination support using timestamp-based cursor
'/sphere/chat/${_room.id}/sync', int? totalMessages;
data: { int syncedCount = 0;
'last_sync_timestamp': int lastSyncTimestamp =
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch, lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch;
},
);
final response = MessageSyncResponse.fromJson(resp.data); do {
talker.log('Sync response: ${response.messages.length} changes'); final resp = await _apiClient.post(
for (final message in response.messages) { '/sphere/chat/${_room.id}/sync',
await receiveMessage(message); 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) { } catch (err, stackTrace) {
talker.log( talker.log(
'Error syncing messages', 'Error syncing messages',
@@ -363,14 +431,35 @@ class MessagesNotifier extends _$MessagesNotifier {
withAttachments: _withAttachments, 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; 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) { 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 { } else {
return []; // If searching, and no local messages, don't fetch from network // For search queries, return local only
return localMessages;
} }
} catch (e) { } catch (e) {
final localMessages = await _getCachedMessages( final localMessages = await _getCachedMessages(
@@ -390,6 +479,7 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> loadInitial() async { Future<void> loadInitial() async {
talker.log('Loading initial messages'); talker.log('Loading initial messages');
_allRemoteMessagesFetched = false;
if (_searchQuery == null || _searchQuery!.isEmpty) { if (_searchQuery == null || _searchQuery!.isEmpty) {
syncMessages(); syncMessages();
} }
@@ -411,6 +501,7 @@ class MessagesNotifier extends _$MessagesNotifier {
if (!_hasMore || state is AsyncLoading) return; if (!_hasMore || state is AsyncLoading) return;
talker.log('Loading more messages'); talker.log('Loading more messages');
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
try { try {
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
final offset = currentMessages.length; final offset = currentMessages.length;
@@ -432,6 +523,10 @@ class MessagesNotifier extends _$MessagesNotifier {
stackTrace: stackTrace, stackTrace: stackTrace,
); );
showErrorAlert(err); showErrorAlert(err);
} finally {
Future.microtask(
() => ref.read(isSyncingProvider.notifier).state = false,
);
} }
} }
@@ -467,7 +562,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages[localMessage.id] = localMessage; _pendingMessages[localMessage.id] = localMessage;
_fileUploadProgress[localMessage.id] = {}; _fileUploadProgress[localMessage.id] = {};
await _database.saveMessage(_database.messageToCompanion(localMessage)); await _database.saveMessageWithSender(localMessage);
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
state = AsyncValue.data([localMessage, ...currentMessages]); state = AsyncValue.data([localMessage, ...currentMessages]);
@@ -518,7 +613,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages.remove(localMessage.id); _pendingMessages.remove(localMessage.id);
await _database.deleteMessage(localMessage.id); await _database.deleteMessage(localMessage.id);
await _database.saveMessage(_database.messageToCompanion(updatedMessage)); await _database.saveMessageWithSender(updatedMessage);
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
if (editingTo != null) { if (editingTo != null) {
@@ -600,7 +695,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages.remove(pendingMessageId); _pendingMessages.remove(pendingMessageId);
await _database.deleteMessage(pendingMessageId); await _database.deleteMessage(pendingMessageId);
await _database.saveMessage(_database.messageToCompanion(updatedMessage)); await _database.saveMessageWithSender(updatedMessage);
final newMessages = final newMessages =
(state.value ?? []).map((m) { (state.value ?? []).map((m) {
@@ -657,7 +752,7 @@ class MessagesNotifier extends _$MessagesNotifier {
); );
} }
await _database.saveMessage(_database.messageToCompanion(localMessage)); await _database.saveMessageWithSender(localMessage);
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
final existingIndex = currentMessages.indexWhere( final existingIndex = currentMessages.indexWhere(
@@ -754,7 +849,7 @@ class MessagesNotifier extends _$MessagesNotifier {
messageToUpdate.status, messageToUpdate.status,
); );
await _database.saveMessage(_database.messageToCompanion(deletedMessage)); await _database.saveMessageWithSender(deletedMessage);
if (messageIndex != -1) { if (messageIndex != -1) {
final newList = [...currentMessages]; final newList = [...currentMessages];
@@ -878,6 +973,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_searchQuery = null; _searchQuery = null;
_withLinks = null; _withLinks = null;
_withAttachments = null; _withAttachments = null;
_allRemoteMessagesFetched = false;
loadInitial(); loadInitial();
} }
@@ -888,7 +984,10 @@ class MessagesNotifier extends _$MessagesNotifier {
await (_database.select(_database.chatMessages) await (_database.select(_database.chatMessages)
..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull(); ..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull();
if (localMessage != null) { if (localMessage != null) {
return _database.companionToMessage(localMessage); return _database.companionToMessage(
localMessage,
fetchAccount: _fetchAccount,
);
} }
final response = await _apiClient.get( final response = await _apiClient.get(
@@ -900,7 +999,7 @@ class MessagesNotifier extends _$MessagesNotifier {
MessageStatus.sent, MessageStatus.sent,
); );
await _database.saveMessage(_database.messageToCompanion(message)); await _database.saveMessageWithSender(message);
return message; return message;
} catch (e) { } catch (e) {
if (e is DioException) return null; if (e is DioException) return null;

View File

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

View File

@@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/models/reference.dart'; import 'package:island/models/reference.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
@@ -5,10 +6,7 @@ import 'package:island/pods/network.dart';
part 'file_references.g.dart'; part 'file_references.g.dart';
@riverpod @riverpod
Future<List<Reference>> fileReferences( Future<List<Reference>> fileReferences(Ref ref, String fileId) async {
FileReferencesRef ref,
String fileId,
) async {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
final response = await client.get('/drive/files/$fileId/references'); final response = await client.get('/drive/files/$fileId/references');
final list = response.data as List<dynamic>; final list = response.data as List<dynamic>;

View File

@@ -6,7 +6,7 @@ part of 'file_references.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$fileReferencesHash() => r'464562fbdc9452d8a5ffbd2d9d9343cdb43f1876'; String _$fileReferencesHash() => r'd66c678c221f61978bdb242b98e6dbe31d0c204b';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -32,7 +32,6 @@ import 'package:island/screens/account/me/account_settings.dart';
import 'package:island/screens/chat/chat.dart'; import 'package:island/screens/chat/chat.dart';
import 'package:island/screens/chat/room.dart'; import 'package:island/screens/chat/room.dart';
import 'package:island/screens/chat/room_detail.dart'; import 'package:island/screens/chat/room_detail.dart';
import 'package:island/screens/chat/call.dart';
import 'package:island/screens/chat/search_messages.dart'; import 'package:island/screens/chat/search_messages.dart';
import 'package:island/screens/thought/think.dart'; import 'package:island/screens/thought/think.dart';
import 'package:island/screens/creators/hub.dart'; import 'package:island/screens/creators/hub.dart';
@@ -43,6 +42,8 @@ import 'package:island/screens/stickers/pack_detail.dart';
import 'package:island/screens/discovery/feeds/feed_marketplace.dart'; import 'package:island/screens/discovery/feeds/feed_marketplace.dart';
import 'package:island/screens/discovery/feeds/feed_detail.dart'; import 'package:island/screens/discovery/feeds/feed_detail.dart';
import 'package:island/screens/creators/poll/poll_list.dart'; import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/screens/creators/sites/site_detail.dart';
import 'package:island/screens/creators/sites/site_list.dart';
import 'package:island/screens/creators/webfeed/webfeed_list.dart'; import 'package:island/screens/creators/webfeed/webfeed_list.dart';
import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/compose_article.dart'; import 'package:island/screens/posts/compose_article.dart';
@@ -117,14 +118,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return ArticleEditScreen(id: id); return ArticleEditScreen(id: id);
}, },
), ),
GoRoute(
name: 'chatCall',
path: '/chat/:id/call',
builder: (context, state) {
final id = state.pathParameters['id']!;
return CallScreen(roomId: id);
},
),
GoRoute( GoRoute(
name: 'logs', name: 'logs',
path: '/logs', path: '/logs',
@@ -484,6 +477,29 @@ final routerProvider = Provider<GoRouter>((ref) {
return CreatorPollListScreen(pubName: name); return CreatorPollListScreen(pubName: name);
}, },
), ),
// Site list route
GoRoute(
name: 'creatorSites',
path: ':name/sites',
builder: (context, state) {
final name = state.pathParameters['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( GoRoute(
name: 'creatorStickers', name: 'creatorStickers',

View File

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

View File

@@ -62,6 +62,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'accountDeletionHint'.tr(), 'accountDeletionHint'.tr(),
'accountDeletion'.tr(), 'accountDeletion'.tr(),
isDanger: true,
); );
if (!confirm || !context.mounted) return; if (!confirm || !context.mounted) return;
try { try {

View File

@@ -26,6 +26,7 @@ class AuthFactorSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'authFactorDeleteHint'.tr(), 'authFactorDeleteHint'.tr(),
'authFactorDelete'.tr(), 'authFactorDelete'.tr(),
isDanger: true,
); );
if (!confirm || !context.mounted) return; if (!confirm || !context.mounted) return;
try { try {

View File

@@ -82,6 +82,7 @@ class AccountConnectionSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'accountConnectionDeleteHint'.tr(), 'accountConnectionDeleteHint'.tr(),
'accountConnectionDelete'.tr(), 'accountConnectionDelete'.tr(),
isDanger: true,
); );
if (!confirm || !context.mounted) return; if (!confirm || !context.mounted) return;
try { try {
@@ -332,6 +333,7 @@ class AccountConnectionsSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'accountConnectionDeleteHint'.tr(), 'accountConnectionDeleteHint'.tr(),
'accountConnectionDelete'.tr(), 'accountConnectionDelete'.tr(),
isDanger: true,
); );
if (confirm && context.mounted) { if (confirm && context.mounted) {
try { try {

View File

@@ -20,6 +20,7 @@ class ContactMethodSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'contactMethodDeleteHint'.tr(), 'contactMethodDeleteHint'.tr(),
'contactMethodDelete'.tr(), 'contactMethodDelete'.tr(),
isDanger: true,
); );
if (!confirm || !context.mounted) return; if (!confirm || !context.mounted) return;
try { try {

View File

@@ -6,11 +6,12 @@ import 'package:island/widgets/content/sheet.dart';
class CaptchaScreen extends ConsumerWidget { class CaptchaScreen extends ConsumerWidget {
static Future<String?> show(BuildContext context) { static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>( return Navigator.push<String>(
context: context, context,
isScrollControlled: true, MaterialPageRoute(
isDismissible: false, builder: (context) => const CaptchaScreen(),
builder: (context) => const CaptchaScreen(), fullscreenDialog: true,
),
); );
} }

View File

@@ -1,5 +1,3 @@
// ignore_for_file: invalid_runtime_check_with_js_interop_types
import 'dart:ui_web' as ui; import 'dart:ui_web' as ui;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
@@ -10,11 +8,12 @@ import 'package:flutter/material.dart';
class CaptchaScreen extends ConsumerStatefulWidget { class CaptchaScreen extends ConsumerStatefulWidget {
static Future<String?> show(BuildContext context) { static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>( return Navigator.push<String>(
context: context, context,
isDismissible: false, MaterialPageRoute(
isScrollControlled: true, builder: (context) => const CaptchaScreen(),
builder: (context) => const CaptchaScreen(), fullscreenDialog: true,
),
); );
} }
@@ -29,7 +28,9 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
void _setupWebListener(String serverUrl) async { void _setupWebListener(String serverUrl) async {
web.window.onMessage.listen((event) { web.window.onMessage.listen((event) {
// ignore: invalid_runtime_check_with_js_interop_types
if (event.data != null && event.data is String) { if (event.data != null && event.data is String) {
// ignore: invalid_runtime_check_with_js_interop_types
final message = event.data as String; final message = event.data as String;
if (message.startsWith("captcha_tk=")) { if (message.startsWith("captcha_tk=")) {
String token = message.replaceFirst("captcha_tk=", ""); String token = message.replaceFirst("captcha_tk=", "");

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ import "package:island/widgets/chat/chat_input.dart";
import "package:island/widgets/chat/chat_link_attachments.dart"; import "package:island/widgets/chat/chat_link_attachments.dart";
import "package:island/widgets/chat/public_room_preview.dart"; import "package:island/widgets/chat/public_room_preview.dart";
import "package:island/screens/thought/think_sheet.dart"; import "package:island/screens/thought/think_sheet.dart";
import "package:island/screens/chat/widgets/message_item_wrapper.dart";
class ChatRoomScreen extends HookConsumerWidget { class ChatRoomScreen extends HookConsumerWidget {
final String id; final String id;
@@ -148,9 +149,6 @@ class ChatRoomScreen extends HookConsumerWidget {
final inputKey = useMemoized(() => GlobalKey()); final inputKey = useMemoized(() => GlobalKey());
final inputHeight = useState<double>(80.0); final inputHeight = useState<double>(80.0);
// Track previous height for smooth animations
final previousInputHeight = usePrevious<double>(inputHeight.value);
// Periodic height measurement for dynamic sizing // Periodic height measurement for dynamic sizing
useEffect(() { useEffect(() {
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) { final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
@@ -181,6 +179,38 @@ class ChatRoomScreen extends HookConsumerWidget {
final isSelectionMode = useState<bool>(false); final isSelectionMode = useState<bool>(false);
final selectedMessages = useState<Set<String>>({}); final selectedMessages = useState<Set<String>>({});
final roomOpenTime = useMemoized(() => DateTime.now());
final onMessageAction = useCallback(
(String action, LocalChatMessage message) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(message.id);
case MessageItemAction.edit:
messageEditingTo.value = message.toRemoteMessage();
messageController.text = messageEditingTo.value?.content ?? '';
attachments.value =
messageEditingTo.value!.attachments
.map((e) => UniversalFile.fromAttachment(e))
.toList();
case MessageItemAction.forward:
messageForwardingTo.value = message.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value = message.toRemoteMessage();
case MessageItemAction.resend:
messagesNotifier.retryMessage(message.id);
}
},
[
messagesNotifier,
messageEditingTo,
messageController,
attachments,
messageForwardingTo,
messageReplyingTo,
],
);
var isLoading = false; var isLoading = false;
var isScrollingToMessage = false; // Flag to prevent scroll conflicts var isScrollingToMessage = false; // Flag to prevent scroll conflicts
@@ -624,428 +654,70 @@ class ChatRoomScreen extends HookConsumerWidget {
} }
} }
Widget chatMessageListWidget(List<LocalChatMessage> messageList) => Widget chatMessageListWidget(
previousInputHeight != null && previousInputHeight != inputHeight.value List<LocalChatMessage> messageList,
? TweenAnimationBuilder<double>( ) => AnimatedPadding(
tween: Tween<double>( duration: const Duration(milliseconds: 200),
begin: previousInputHeight, curve: Curves.easeOut,
end: inputHeight.value, padding: EdgeInsets.only(
), bottom: MediaQuery.of(context).padding.bottom + 8 + inputHeight.value,
duration: const Duration(milliseconds: 200), ),
curve: Curves.easeOut, child: SuperListView.builder(
builder: listController: listController,
(context, height, child) => SuperListView.builder( controller: scrollController,
listController: listController, reverse: true, // Show newest messages at the bottom
padding: EdgeInsets.only( itemCount: messageList.length,
top: 16, findChildIndexCallback: (key) {
bottom: if (key is! ValueKey<String>) return null;
MediaQuery.of(context).padding.bottom + 8 + height, final messageId = key.value.substring(messageKeyPrefix.length);
), final index = messageList.indexWhere(
controller: scrollController, (m) => (m.nonce ?? m.id) == messageId,
reverse: true, // Show newest messages at the bottom );
itemCount: messageList.length, return index >= 0 ? index : null;
findChildIndexCallback: (key) { },
if (key is! ValueKey<String>) return null; extentEstimation: (_, _) => 40,
final messageId = key.value.substring( itemBuilder: (context, index) {
messageKeyPrefix.length, final message = messageList[index];
); final nextMessage =
final index = messageList.indexWhere( index < messageList.length - 1 ? messageList[index + 1] : null;
(m) => (m.nonce ?? m.id) == messageId, final isLastInGroup =
); nextMessage == null ||
// Return null for invalid indices to let SuperListView handle it properly nextMessage.senderId != message.senderId ||
return index >= 0 ? index : null; nextMessage.createdAt
}, .difference(message.createdAt)
extentEstimation: (_, _) => 40, .inMinutes
itemBuilder: (context, index) { .abs() >
final message = messageList[index]; 3;
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( return MessageItemWrapper(
skipError: true, key: key,
data: message: message,
(identity) => GestureDetector( index: index,
onLongPress: () { isLastInGroup: isLastInGroup,
if (!isSelectionMode.value) { isSelectionMode: isSelectionMode.value,
toggleSelectionMode(); selectedMessages: selectedMessages.value,
toggleMessageSelection(message.id); chatIdentity: chatIdentity,
} toggleSelectionMode: toggleSelectionMode,
}, toggleMessageSelection: toggleMessageSelection,
onTap: () { onMessageAction: onMessageAction,
if (isSelectionMode.value) { onJump:
toggleMessageSelection(message.id); (messageId) => scrollToMessage(
} messageId: messageId,
}, messageList: messageList,
child: Container( messagesNotifier: messagesNotifier,
color: listController: listController,
selectedMessages.value.contains(message.id) scrollController: scrollController,
? Theme.of(context) ref: ref,
.colorScheme ),
.primaryContainer attachmentProgress: attachmentProgress.value,
.withOpacity(0.3) disableAnimation: settings.disableAnimation,
: null, roomOpenTime: roomOpenTime,
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,
),
),
),
]),
],
),
),
),
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,
);
},
),
)
: SuperListView.builder(
listController: listController,
padding: EdgeInsets.only(
top: 16,
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;
// Use a stable animation key that doesn't change during message lifecycle
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,
),
),
),
]),
],
),
),
),
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,
);
},
);
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
@@ -1066,7 +738,11 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
), ),
actions: [ actions: [
AudioCallButton(roomId: id), chatRoom.when(
data: (data) => AudioCallButton(room: data!),
error: (err, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
IconButton( IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
onPressed: () async { onPressed: () async {
@@ -1167,7 +843,14 @@ class ChatRoomScreen extends HookConsumerWidget {
left: 0, left: 0,
right: 0, right: 0,
top: 0, top: 0,
child: CallOverlayBar().padding(horizontal: 8, top: 12), child: chatRoom.when(
data:
(data) => CallOverlayBar(
room: data!,
).padding(horizontal: 8, top: 12),
error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
), ),
if (isSyncing) if (isSyncing)
Positioned( Positioned(

View File

@@ -487,6 +487,7 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteChatRoomHint'.tr(), 'deleteChatRoomHint'.tr(),
'deleteChatRoom'.tr(), 'deleteChatRoom'.tr(),
isDanger: true,
).then((confirm) async { ).then((confirm) async {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);

View File

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

View File

@@ -304,16 +304,18 @@ class CreatorHubScreen extends HookConsumerWidget {
} }
void deletePublisher() { void deletePublisher() {
showConfirmAlert('deletePublisherHint'.tr(), 'deletePublisher'.tr()).then( showConfirmAlert(
(confirm) { 'deletePublisherHint'.tr(),
if (confirm) { 'deletePublisher'.tr(),
final client = ref.watch(apiClientProvider); isDanger: true,
client.delete('/sphere/publishers/${currentPublisher.value!.name}'); ).then((confirm) {
ref.invalidate(publishersManagedProvider); if (confirm) {
currentPublisher.value = null; 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( 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( ListTile(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
@@ -585,7 +602,7 @@ class CreatorHubScreen extends HookConsumerWidget {
).padding(horizontal: 12), ).padding(horizontal: 12),
buildNavigationWidget(true), buildNavigationWidget(true),
], ],
) ).padding(vertical: 24)
: Column( : Column(
spacing: 12, spacing: 12,
children: [ children: [
@@ -831,7 +848,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
try { try {
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
await apiClient.post( await apiClient.post(
'/sphere/publishers/$publisherUname/invites', '/sphere/publishers/invites/$publisherUname',
data: {'related_user_id': result.id, 'role': 0}, data: {'related_user_id': result.id, 'role': 0},
); );
// Refresh both providers // Refresh both providers
@@ -1119,7 +1136,7 @@ class _PublisherInviteSheet extends HookConsumerWidget {
try { try {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
await client.post( await client.post(
'/publishers/invites/${invite.publisher!.name}/accept', '/sphere/publishers/invites/${invite.publisher!.name}/accept',
); );
ref.invalidate(publisherInvitesProvider); ref.invalidate(publisherInvitesProvider);
ref.invalidate(publishersManagedProvider); ref.invalidate(publishersManagedProvider);
@@ -1132,7 +1149,7 @@ class _PublisherInviteSheet extends HookConsumerWidget {
try { try {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
await client.post( await client.post(
'/publishers/invites/${invite.publisher!.name}/decline', '/sphere/publishers/invites/${invite.publisher!.name}/decline',
); );
ref.invalidate(publisherInvitesProvider); ref.invalidate(publisherInvitesProvider);
} catch (err) { } catch (err) {

View File

@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.dart'; import 'package:island/models/poll.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/screens/poll/poll_editor.dart'; import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/poll/poll_feedback.dart'; import 'package:island/widgets/poll/poll_feedback.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -234,19 +235,9 @@ class _CreatorPollItem extends HookConsumerWidget {
'/sphere/polls/${pollWithStats.id}', '/sphere/polls/${pollWithStats.id}',
); );
ref.invalidate(pollListNotifierProvider(pubName)); ref.invalidate(pollListNotifierProvider(pubName));
if (context.mounted) { showSnackBar('Poll deleted successfully');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Poll deleted successfully'),
),
);
}
} catch (e) { } catch (e) {
if (context.mounted) { showErrorAlert(e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to delete poll')),
);
}
} }
} }
}, },

View File

@@ -0,0 +1,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,
),
);
}
}

View File

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

View File

@@ -0,0 +1,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,
],
),
],
),
),
);
}
}

View 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);
}
}
},
),
],
),
],
),
),
),
);
}
}

View File

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

View File

@@ -288,6 +288,7 @@ class StickerPackActionMenu extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteStickerPackHint'.tr(), 'deleteStickerPackHint'.tr(),
'deleteStickerPack'.tr(), 'deleteStickerPack'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);

View File

@@ -70,6 +70,7 @@ class WebfeedForm extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'Are you sure you want to delete this web feed? This action cannot be undone.', 'Are you sure you want to delete this web feed? This action cannot be undone.',
'Delete Web Feed', 'Delete Web Feed',
isDanger: true,
); );
if (confirmed != true) return; if (confirmed != true) return;

View File

@@ -211,6 +211,7 @@ class AppSecretsScreen extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteSecretHint'.tr(), 'deleteSecretHint'.tr(),
'deleteSecret'.tr(), 'deleteSecret'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);

View File

@@ -231,6 +231,7 @@ class CustomAppsScreen extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteCustomAppHint'.tr(), 'deleteCustomAppHint'.tr(),
'deleteCustomApp'.tr(), 'deleteCustomApp'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.read( final client = ref.read(

View File

@@ -159,9 +159,11 @@ class BotKeysScreen extends HookConsumerWidget {
} }
void revokeKey(String keyId) { void revokeKey(String keyId) {
showConfirmAlert('revokeBotKeyHint'.tr(), 'revokeBotKey'.tr()).then(( showConfirmAlert(
confirm, 'revokeBotKeyHint'.tr(),
) { 'revokeBotKey'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
client client

View File

@@ -172,6 +172,7 @@ class BotsScreen extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteBotHint'.tr(), 'deleteBotHint'.tr(),
'deleteBot'.tr(), 'deleteBot'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);

View File

@@ -631,6 +631,7 @@ class _ProjectListTile extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteProjectHint'.tr(), 'deleteProjectHint'.tr(),
'deleteProject'.tr(), 'deleteProject'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);

View File

@@ -1,3 +1,4 @@
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -30,7 +31,9 @@ import 'package:island/widgets/publisher/publisher_card.dart';
import 'package:island/widgets/web_article_card.dart'; import 'package:island/widgets/web_article_card.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/services/event_bus.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:styled_widget/styled_widget.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
part 'explore.g.dart'; part 'explore.g.dart';
@@ -239,23 +242,74 @@ class ExploreScreen extends HookConsumerWidget {
final appBar = isWide ? null : _buildAppBar(tabController, context); final appBar = isWide ? null : _buildAppBar(tabController, context);
return AppScaffold( final dragging = useState(false);
isNoBackground: false,
appBar: appBar, return DropTarget(
body: onDragDone: (detail) {
isWide dragging.value = false;
? _buildWideBody( if (detail.files.isNotEmpty) {
context, showModalBottomSheet(
ref, context: context,
filterBar, isScrollControlled: true,
user, useRootNavigator: true,
notificationCount, builder: (context) => ShareSheet.files(files: detail.files),
query, );
events, }
selectedDay, },
currentFilter.value, onDragEntered: (_) => dragging.value = true,
) onDragExited: (_) => dragging.value = false,
: _buildNarrowBody(context, ref, currentFilter.value), 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,
),
),
],
),
),
),
),
],
),
); );
} }
@@ -545,6 +599,7 @@ class ExploreScreen extends HookConsumerWidget {
SliverToBoxAdapter( SliverToBoxAdapter(
child: FriendsOverviewWidget( child: FriendsOverviewWidget(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
hideWhenEmpty: true,
), ),
), ),
if (notificationCount.value != null && if (notificationCount.value != null &&
@@ -581,7 +636,7 @@ class _DiscoveryActivityItem extends StatelessWidget {
final height = type == 'post' ? 280.0 : 180.0; final height = type == 'post' ? 280.0 : 180.0;
final contentWidget = switch (type) { final contentWidget = switch (type) {
'post' => ListView.separated( 'post' => SuperListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: items.length, itemCount: items.length,
separatorBuilder: (context, index) => const Gap(12), separatorBuilder: (context, index) => const Gap(12),

View File

@@ -36,7 +36,7 @@ class FileListScreen extends HookConsumerWidget {
isNoBackground: false, isNoBackground: false,
appBar: AppBar( appBar: AppBar(
title: Text('files').tr(), title: Text('files').tr(),
leading: const PageBackButton(), leading: const PageBackButton(backTo: '/account'),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.bar_chart), icon: const Icon(Symbols.bar_chart),

View File

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

View File

@@ -10,6 +10,7 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.dart'; import 'package:island/route.dart';
import 'package:island/widgets/alert.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/markdown.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
@@ -68,6 +69,16 @@ class NotificationUnreadCountNotifier
void clear() async { void clear() async {
state = AsyncData(0); 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 @riverpod
@@ -115,8 +126,36 @@ class NotificationListNotifier extends _$NotificationListNotifier
class NotificationSheet extends HookConsumerWidget { class NotificationSheet extends HookConsumerWidget {
const NotificationSheet({super.key}); 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 @override
Widget build(BuildContext context, WidgetRef ref) { 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 { Future<void> markAllRead() async {
showLoadingModal(context); showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
@@ -149,12 +188,30 @@ class NotificationSheet extends HookConsumerWidget {
} }
final notification = data.items[index]; 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( return ListTile(
isThreeLine: true, isThreeLine: true,
contentPadding: EdgeInsets.symmetric( contentPadding: EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 8, 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), title: Text(notification.title),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -187,6 +244,29 @@ class NotificationSheet extends HookConsumerWidget {
).colorScheme.onSurface.withOpacity(0.8), ).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: trailing:

View File

@@ -256,21 +256,25 @@ class ArticleComposeScreen extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ComposeFormFields( Expanded(
state: state, child: SingleChildScrollView(
showPublisherAvatar: false, child: ComposeFormFields(
onPublisherTap: () { state: state,
showModalBottomSheet( showPublisherAvatar: false,
isScrollControlled: true, onPublisherTap: () {
context: context, showModalBottomSheet(
builder: (context) => const PublisherModal(), isScrollControlled: true,
).then((value) { context: context,
if (value != null) { builder: (context) => const PublisherModal(),
state.currentPublisher.value = value; ).then((value) {
} if (value != null) {
}); state.currentPublisher.value = value;
}, }
).padding(top: 16), });
},
).padding(top: 16),
),
),
// Attachments preview // Attachments preview
ValueListenableBuilder<List<UniversalFile>>( ValueListenableBuilder<List<UniversalFile>>(

View File

@@ -145,9 +145,11 @@ class PostActionButtons extends HookConsumerWidget {
message: 'delete'.tr(), message: 'delete'.tr(),
child: FilledButton.tonal( child: FilledButton.tonal(
onPressed: () { onPressed: () {
showConfirmAlert('deletePostHint'.tr(), 'deletePost'.tr()).then(( showConfirmAlert(
confirm, 'deletePostHint'.tr(),
) { 'deletePost'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);
client client

View File

@@ -427,6 +427,7 @@ class _RealmActionMenu extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteRealmHint'.tr(), 'deleteRealmHint'.tr(),
'deleteRealm'.tr(), 'deleteRealm'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);

View File

@@ -14,6 +14,7 @@ import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/chat/chat_summary.dart';
final currentRouteProvider = StateProvider<String?>((ref) => null); final currentRouteProvider = StateProvider<String?>((ref) => null);
@@ -50,6 +51,8 @@ class TabsScreen extends HookConsumerWidget {
notificationUnreadCountNotifierProvider, notificationUnreadCountNotifierProvider,
); );
final chatUnreadCount = ref.watch(chatUnreadCountNotifierProvider);
final wideScreen = isWideScreen(context); final wideScreen = isWideScreen(context);
final destinations = [ final destinations = [
@@ -59,7 +62,11 @@ class TabsScreen extends HookConsumerWidget {
), ),
NavigationDestination( NavigationDestination(
label: 'chat'.tr(), 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( NavigationDestination(
label: 'realms'.tr(), label: 'realms'.tr(),

View File

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

View File

@@ -150,6 +150,7 @@ class AccountSessionSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'authDeviceLogoutHint'.tr(), 'authDeviceLogoutHint'.tr(),
'authDeviceLogout'.tr(), 'authDeviceLogout'.tr(),
isDanger: true,
); );
if (!confirm || !context.mounted) return; if (!confirm || !context.mounted) return;
try { try {
@@ -276,6 +277,7 @@ class AccountSessionSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'authDeviceLogoutHint'.tr(), 'authDeviceLogoutHint'.tr(),
'authDeviceLogout'.tr(), 'authDeviceLogout'.tr(),
isDanger: true,
); );
if (confirm && context.mounted) { if (confirm && context.mounted) {
try { try {

View File

@@ -25,12 +25,24 @@ const Map<String, Color> kUsernamePlainColors = {
'white': Colors.white, 'white': Colors.white,
}; };
const kVerificationMarkColors = [ const List<IconData> kVerificationMarkIcons = [
Symbols.build_circle,
Symbols.verified,
Symbols.verified,
Symbols.account_balance,
Symbols.palette,
Symbols.code,
Symbols.masks,
];
const List<Color> kVerificationMarkColors = [
Colors.teal, Colors.teal,
Colors.blue,
Colors.amber,
Colors.blueGrey,
Colors.lightBlue, Colors.lightBlue,
Colors.indigo,
Colors.red,
Colors.orange,
Colors.blue,
Colors.blueAccent,
]; ];
class AccountName extends StatelessWidget { class AccountName extends StatelessWidget {
@@ -39,6 +51,7 @@ class AccountName extends StatelessWidget {
final String? textOverride; final String? textOverride;
final bool ignorePermissions; final bool ignorePermissions;
final bool hideVerificationMark; final bool hideVerificationMark;
final bool hideOverlay;
const AccountName({ const AccountName({
super.key, super.key,
required this.account, required this.account,
@@ -46,6 +59,7 @@ class AccountName extends StatelessWidget {
this.textOverride, this.textOverride,
this.ignorePermissions = false, this.ignorePermissions = false,
this.hideVerificationMark = false, this.hideVerificationMark = false,
this.hideOverlay = false,
}); });
Alignment _parseGradientDirection(String direction) { Alignment _parseGradientDirection(String direction) {
@@ -189,20 +203,33 @@ class AccountName extends StatelessWidget {
), ),
), ),
if (account.perkSubscription != null) if (account.perkSubscription != null)
StellarMembershipMark(membership: account.perkSubscription!), StellarMembershipMark(
membership: account.perkSubscription!,
hideOverlay: hideOverlay,
),
if (account.profile.verification != null && if (account.profile.verification != null &&
!hideVerificationMark) !hideVerificationMark)
VerificationMark(mark: account.profile.verification!), VerificationMark(
if (account.automatedId != null) mark: account.profile.verification!,
Tooltip( hideOverlay: hideOverlay,
message: 'accountAutomated'.tr(),
child: Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
),
), ),
if (account.automatedId != null)
hideOverlay
? Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
)
: Tooltip(
message: 'accountAutomated'.tr(),
child: Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
),
),
], ],
); );
} }
@@ -233,19 +260,32 @@ class AccountName extends StatelessWidget {
), ),
), ),
if (account.perkSubscription != null) if (account.perkSubscription != null)
StellarMembershipMark(membership: account.perkSubscription!), StellarMembershipMark(
if (account.profile.verification != null) membership: account.perkSubscription!,
VerificationMark(mark: account.profile.verification!), hideOverlay: hideOverlay,
if (account.automatedId != null)
Tooltip(
message: 'accountAutomated'.tr(),
child: Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
),
), ),
if (account.profile.verification != null)
VerificationMark(
mark: account.profile.verification!,
hideOverlay: hideOverlay,
),
if (account.automatedId != null)
hideOverlay
? Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
)
: Tooltip(
message: 'accountAutomated'.tr(),
child: Icon(
Symbols.smart_toy,
size: 16,
color: nameStyle.color,
fill: 1,
),
),
], ],
); );
} }
@@ -253,39 +293,54 @@ class AccountName extends StatelessWidget {
class VerificationMark extends StatelessWidget { class VerificationMark extends StatelessWidget {
final SnVerificationMark mark; final SnVerificationMark mark;
const VerificationMark({super.key, required this.mark}); final bool hideOverlay;
const VerificationMark({
super.key,
required this.mark,
this.hideOverlay = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Tooltip( final icon = Icon(
richMessage: TextSpan( (kVerificationMarkIcons.length > mark.type && mark.type >= 0)
text: mark.title ?? 'No title', ? kVerificationMarkIcons[mark.type]
children: [ : Symbols.verified,
TextSpan(text: '\n'), size: 16,
TextSpan( color:
text: mark.description ?? 'descriptionNone'.tr(), (kVerificationMarkColors.length > mark.type && mark.type >= 0)
style: TextStyle(fontWeight: FontWeight.normal), ? kVerificationMarkColors[mark.type]
), : Colors.blue,
], fill: 1,
style: TextStyle(fontWeight: FontWeight.bold),
),
child: Icon(
mark.type == 4
? Symbols.play_circle
: mark.type == 0
? Symbols.build_circle
: Symbols.verified,
size: 16,
color: kVerificationMarkColors[mark.type],
fill: 1,
),
); );
return hideOverlay
? icon
: Tooltip(
richMessage: TextSpan(
text: mark.title ?? 'No title',
children: [
TextSpan(text: '\n'),
TextSpan(
text: mark.description ?? 'descriptionNone'.tr(),
style: TextStyle(fontWeight: FontWeight.normal),
),
],
style: TextStyle(fontWeight: FontWeight.bold),
),
child: icon,
);
} }
} }
class StellarMembershipMark extends StatelessWidget { class StellarMembershipMark extends StatelessWidget {
final SnWalletSubscriptionRef membership; final SnWalletSubscriptionRef membership;
const StellarMembershipMark({super.key, required this.membership}); final bool hideOverlay;
const StellarMembershipMark({
super.key,
required this.membership,
this.hideOverlay = false,
});
String _getMembershipTierName(String identifier) { String _getMembershipTierName(String identifier) {
switch (identifier) { switch (identifier) {
@@ -321,20 +376,24 @@ class StellarMembershipMark extends StatelessWidget {
final tierColor = _getMembershipTierColor(membership.identifier); final tierColor = _getMembershipTierColor(membership.identifier);
final tierIcon = Symbols.kid_star; final tierIcon = Symbols.kid_star;
return Tooltip( final icon = Icon(tierIcon, size: 16, color: tierColor, fill: 1);
richMessage: TextSpan(
text: 'stellarMembership'.tr(), return hideOverlay
children: [ ? icon
TextSpan(text: '\n'), : Tooltip(
TextSpan( richMessage: TextSpan(
text: 'currentMembershipMember'.tr(args: [tierName]), text: 'stellarMembership'.tr(),
style: TextStyle(fontWeight: FontWeight.normal), children: [
TextSpan(text: '\n'),
TextSpan(
text: 'currentMembershipMember'.tr(args: [tierName]),
style: TextStyle(fontWeight: FontWeight.normal),
),
],
style: TextStyle(fontWeight: FontWeight.bold),
), ),
], child: icon,
style: TextStyle(fontWeight: FontWeight.bold), );
),
child: Icon(tierIcon, size: 16, color: tierColor, fill: 1),
);
} }
} }
@@ -348,13 +407,14 @@ class VerificationStatusCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Icon( Icon(
mark.type == 4 (kVerificationMarkIcons.length > mark.type && mark.type >= 0)
? Symbols.play_circle ? kVerificationMarkIcons[mark.type]
: mark.type == 0
? Symbols.build_circle
: Symbols.verified, : Symbols.verified,
size: 32, size: 32,
color: kVerificationMarkColors[mark.type], color:
(kVerificationMarkColors.length > mark.type && mark.type >= 0)
? kVerificationMarkColors[mark.type]
: Colors.blue,
fill: 1, fill: 1,
).alignment(Alignment.centerLeft), ).alignment(Alignment.centerLeft),
const Gap(8), const Gap(8),

View File

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

View File

@@ -1,13 +1,15 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:island/main.dart'; import 'package:island/main.dart';
import 'package:island/talker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:top_snackbar_flutter/top_snack_bar.dart'; import 'package:top_snackbar_flutter/top_snack_bar.dart';
export 'content/alert.native.dart'
if (dart.library.html) 'content/alert.web.dart';
void showSnackBar(String message, {SnackBarAction? action}) { void showSnackBar(String message, {SnackBarAction? action}) {
final context = globalOverlay.currentState!.context; final context = globalOverlay.currentState!.context;
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
@@ -29,43 +31,60 @@ void showSnackBar(String message, {SnackBarAction? action}) {
), ),
), ),
), ),
curve: Curves.easeInOut,
snackBarPosition: SnackBarPosition.bottom, snackBarPosition: SnackBarPosition.bottom,
); );
} }
void clearSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).clearSnackBars();
}
OverlayEntry? _loadingOverlay; OverlayEntry? _loadingOverlay;
GlobalKey<_FadeOverlayState> _loadingOverlayKey = GlobalKey(); GlobalKey<_FadeOverlayState> _loadingOverlayKey = GlobalKey();
class _FadeOverlay extends StatefulWidget { class _FadeOverlay extends StatefulWidget {
const _FadeOverlay({super.key, required this.child}); const _FadeOverlay({
final Widget child; super.key,
this.child,
this.builder,
this.duration = const Duration(milliseconds: 200),
this.curve = Curves.linear,
}) : assert(child != null || builder != null);
final Widget? child;
final Widget Function(BuildContext, Animation<double>)? builder;
final Duration duration;
final Curve curve;
@override @override
State<_FadeOverlay> createState() => _FadeOverlayState(); State<_FadeOverlay> createState() => _FadeOverlayState();
} }
class _FadeOverlayState extends State<_FadeOverlay> { class _FadeOverlayState extends State<_FadeOverlay>
bool _visible = false; with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { _controller = AnimationController(vsync: this, duration: widget.duration);
setState(() => _visible = true); _controller.forward();
}); }
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> animateOut() async {
await _controller.reverse();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedOpacity( final animation = CurvedAnimation(parent: _controller, curve: widget.curve);
opacity: _visible ? 1.0 : 0.0, if (widget.builder != null) {
duration: const Duration(milliseconds: 200), return widget.builder!(context, animation);
child: widget.child, }
); return FadeTransition(opacity: animation, child: widget.child);
} }
} }
@@ -109,10 +128,250 @@ void hideLoadingModal(BuildContext context) async {
final state = entry.mounted ? _loadingOverlayKey.currentState : null; final state = entry.mounted ? _loadingOverlayKey.currentState : null;
if (state != null) { if (state != null) {
// ignore: invalid_use_of_protected_member await state.animateOut();
state.setState(() => state._visible = false);
await Future.delayed(const Duration(milliseconds: 200));
} }
entry.remove(); entry.remove();
} }
String _parseRemoteError(DioException err) {
String? message;
if (err.response?.data is String) {
message = err.response?.data;
} else if (err.response?.data?['message'] != null) {
message = <String?>[
err.response?.data?['message']?.toString(),
err.response?.data?['detail']?.toString(),
].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n');
} else if (err.response?.data?['errors'] != null) {
final errors = err.response?.data['errors'] as Map<String, dynamic>;
message = errors.values
.map(
(ele) =>
(ele as List<dynamic>).map((ele) => ele.toString()).join('\n'),
)
.join('\n');
}
if (message == null || message.isEmpty) message = err.response?.statusMessage;
message ??= err.message;
return message ?? err.toString();
}
// Track active overlay dialogs for dismissal
final List<void Function()> _activeOverlayDialogs = [];
Future<T?> showOverlayDialog<T>({
required Widget Function(BuildContext context, void Function(T? result) close)
builder,
bool barrierDismissible = true,
}) {
final completer = Completer<T?>();
final key = GlobalKey<_FadeOverlayState>();
late OverlayEntry entry;
void close(T? result) async {
if (completer.isCompleted) return;
final state = key.currentState;
if (state != null) {
await state.animateOut();
}
entry.remove();
_activeOverlayDialogs.remove(close);
completer.complete(result);
}
entry = OverlayEntry(
builder:
(context) => _FadeOverlay(
key: key,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
builder: (context, animation) {
return Stack(
children: [
Positioned.fill(
child: FadeTransition(
opacity: animation,
child: GestureDetector(
onTap: barrierDismissible ? () => close(null) : null,
behavior: HitTestBehavior.opaque,
child: const ColoredBox(color: Colors.black54),
),
),
),
Center(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.05),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: builder(context, close),
),
),
),
],
);
},
),
);
_activeOverlayDialogs.add(() => close(null));
globalOverlay.currentState!.insert(entry);
return completer.future;
}
// Close the topmost overlay dialog if any exists
bool closeTopmostOverlayDialog() {
if (_activeOverlayDialogs.isNotEmpty) {
final closeFunc = _activeOverlayDialogs.last;
closeFunc();
return true;
}
return false;
}
const kDialogMaxWidth = 480.0;
void showErrorAlert(dynamic err, {IconData? icon}) {
if (err is Error) {
talker.error('Something went wrong...', err, err.stackTrace);
}
final text = switch (err) {
String _ => err,
DioException _ => _parseRemoteError(err),
Exception _ => err.toString(),
_ => err.toString(),
};
showOverlayDialog<void>(
builder:
(context, close) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
child: AlertDialog(
title: null,
titlePadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon ?? Icons.error_outline_rounded,
size: 48,
color: Theme.of(context).colorScheme.error,
),
const Gap(16),
Text(
'somethingWentWrong'.tr(),
style: Theme.of(context).textTheme.titleLarge,
),
const Gap(8),
Text(text),
],
),
actions: [
TextButton(
onPressed: () => close(null),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
),
);
}
void showInfoAlert(String message, String title, {IconData? icon}) {
showOverlayDialog<void>(
builder:
(context, close) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
child: AlertDialog(
title: null,
titlePadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon ?? Symbols.info_rounded,
fill: 1,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const Gap(16),
Text(title, style: Theme.of(context).textTheme.titleLarge),
const Gap(8),
Text(message),
const Gap(8),
],
),
actions: [
TextButton(
onPressed: () => close(null),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
),
);
}
Future<bool> showConfirmAlert(
String message,
String title, {
IconData? icon,
bool isDanger = false,
}) async {
final result = await showOverlayDialog<bool>(
builder:
(context, close) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
child: AlertDialog(
title: null,
titlePadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon ?? Symbols.help_rounded,
size: 48,
fill: 1,
color: Theme.of(context).colorScheme.primary,
),
const Gap(16),
Text(title, style: Theme.of(context).textTheme.titleLarge),
const Gap(8),
Text(message),
const Gap(8),
],
),
actions: [
TextButton(
onPressed: () => close(false),
child: Text(
MaterialLocalizations.of(context).cancelButtonLabel,
),
),
TextButton(
onPressed: () => close(true),
style:
isDanger
? TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
)
: null,
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
),
);
return result ?? false;
}

View File

@@ -13,6 +13,7 @@ import 'package:island/route.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/upload_overlay.dart'; import 'package:island/widgets/upload_overlay.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -364,6 +365,12 @@ class PopAction extends Action<PopIntent> {
@override @override
void invoke(PopIntent intent) { void invoke(PopIntent intent) {
// First, try to close any overlay dialogs
if (closeTopmostOverlayDialog()) {
return;
}
// If no overlay to close, pop the route
if (ref.watch(routerProvider).canPop()) { if (ref.watch(routerProvider).canPop()) {
ref.read(routerProvider).pop(); ref.read(routerProvider).pop();
} }

View File

@@ -130,6 +130,17 @@ class _AppWrapperState extends ConsumerState<AppWrapper>
return; return;
} }
// Special handling for share intent deep links
// Share intents are handled by SharingIntentService showing a modal,
// not by routing to a page
if (path == '/share') {
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
windowManager.show();
}
return;
}
final router = ref.read(routerProvider); final router = ref.read(routerProvider);
if (uri.queryParameters.isNotEmpty) { if (uri.queryParameters.isNotEmpty) {
path = path =

View File

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

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/chat/call.dart';
import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:styled_widget/styled_widget.dart';
class CallContent extends HookConsumerWidget {
const CallContent({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.watch(callNotifierProvider.notifier);
if (!callState.isConnected) {
return const Center(child: CircularProgressIndicator());
}
if (callNotifier.participants.isEmpty) {
return const Center(child: Text('No participants in call'));
}
final participants = callNotifier.participants;
final allAudioOnly = participants.every(
(p) =>
!(p.hasVideo &&
p.remoteParticipant.trackPublications.values.any(
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO &&
!pub.muted &&
!pub.isDisposed,
)),
);
if (allAudioOnly) {
// Audio-only: show avatars in a compact row
return Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
for (final live in participants)
SpeakingRippleAvatar(
live: live,
size: 72,
).padding(horizontal: 4),
],
),
),
);
}
// Show all participants in a responsive grid
return LayoutBuilder(
builder: (context, constraints) {
// Calculate width for responsive 2-column layout
final itemWidth = (constraints.maxWidth / 2) - 16;
return Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
for (final participant in participants)
SizedBox(
width: itemWidth,
child: CallParticipantTile(live: participant),
),
],
);
},
);
}
}

View File

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

View File

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

View File

@@ -45,6 +45,8 @@ void _insertPlaceholder(TextEditingController controller, String placeholder) {
const kInputDrawerExpandedHeight = 180.0; const kInputDrawerExpandedHeight = 180.0;
const kExpandedSectionTabHeight = 32.0;
class _ExpandedSection extends StatelessWidget { class _ExpandedSection extends StatelessWidget {
final TextEditingController messageController; final TextEditingController messageController;
final SnPoll? selectedPoll; final SnPoll? selectedPoll;
@@ -75,9 +77,23 @@ class _ExpandedSection extends StatelessWidget {
length: 2, length: 2,
child: Column( child: Column(
children: [ children: [
TabBar( PreferredSize(
splashBorderRadius: const BorderRadius.all(Radius.circular(40)), preferredSize: const Size.fromHeight(kExpandedSectionTabHeight),
tabs: [Tab(text: 'Features'), Tab(text: 'Stickers')], child: TabBar(
splashBorderRadius: const BorderRadius.all(
Radius.circular(40),
),
tabs: [
Tab(
text: 'features'.tr(),
height: kExpandedSectionTabHeight,
),
Tab(
text: 'stickers'.tr(),
height: kExpandedSectionTabHeight,
),
],
),
), ),
SizedBox( SizedBox(
height: kInputDrawerExpandedHeight, height: kInputDrawerExpandedHeight,
@@ -248,6 +264,7 @@ class ChatInput extends HookConsumerWidget {
void send() { void send() {
inputFocusNode.requestFocus(); inputFocusNode.requestFocus();
if (isExpanded.value) isExpanded.value = false;
onSend.call(); onSend.call();
} }

View File

@@ -596,6 +596,7 @@ class MessageHoverActionMenu extends StatelessWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'deleteMessageConfirmation'.tr(), 'deleteMessageConfirmation'.tr(),
'deleteMessage'.tr(), 'deleteMessage'.tr(),
isDanger: true,
); );
if (confirmed) { if (confirmed) {

View File

@@ -77,27 +77,12 @@ class MessageSenderInfo extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( AccountName(
children: [ account: sender.account,
AccountName( style: Theme.of(context).textTheme.bodySmall?.copyWith(
account: sender.account, color: textColor,
style: Theme.of(context).textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w500,
color: textColor, ),
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 4),
Badge(
label:
Text(
sender.role >= 100
? 'permissionOwner'
: sender.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
),
],
), ),
Text( Text(
timestamp, timestamp,

View File

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

View File

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

View File

@@ -215,6 +215,8 @@ class CloudFileList extends HookConsumerWidget {
} }
if (files.length == 1) { if (files.length == 1) {
final isImage = files.first.mimeType?.startsWith('image') ?? false; final isImage = files.first.mimeType?.startsWith('image') ?? false;
final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
final ratio = files.first.fileMeta?['ratio'] as num?;
final widgetItem = ClipRRect( final widgetItem = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _CloudFileListEntry( child: _CloudFileListEntry(
@@ -242,7 +244,15 @@ class CloudFileList extends HookConsumerWidget {
minWidth: minWidth ?? 0, minWidth: minWidth ?? 0,
maxWidth: files.length == 1 ? maxWidth : double.infinity, maxWidth: files.length == 1 ? maxWidth : double.infinity,
), ),
child: IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)), child:
(ratio == null && isImage)
? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
: (ratio == null && isAudio)
? IntrinsicHeight(child: widgetItem)
: AspectRatio(
aspectRatio: ratio?.toDouble() ?? 1,
child: widgetItem,
),
); );
} }
@@ -408,8 +418,6 @@ class _CloudFileListEntry extends HookConsumerWidget {
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value; final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {}; final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
final ratio = meta['ratio'] as num?;
final fit = BoxFit.cover; final fit = BoxFit.cover;
Widget bg = const SizedBox.shrink(); Widget bg = const SizedBox.shrink();
@@ -448,9 +456,7 @@ class _CloudFileListEntry extends HookConsumerWidget {
fit: fit, fit: fit,
useInternalGate: false, useInternalGate: false,
)) ))
: IntrinsicWidth( : const SizedBox.shrink();
child: IntrinsicHeight(child: const SizedBox.shrink()),
);
Widget overlays; Widget overlays;
if (lockedByDS) { if (lockedByDS) {
@@ -481,7 +487,7 @@ class _CloudFileListEntry extends HookConsumerWidget {
onTap?.call(); onTap?.call();
} }
}, },
child: AspectRatio(aspectRatio: ratio?.toDouble() ?? 1, child: content), child: content,
); );
} }
} }

View File

@@ -55,14 +55,18 @@ class _EmbedLinkWidgetState extends State<EmbedLinkWidget> {
stream.removeListener(listener); stream.removeListener(listener);
final aspectRatio = info.image.width / info.image.height; final aspectRatio = info.image.width / info.image.height;
setState(() { if (mounted) {
_isSquare = aspectRatio >= 0.9 && aspectRatio <= 1.1; setState(() {
}); _isSquare = aspectRatio >= 0.9 && aspectRatio <= 1.1;
});
}
} catch (e) { } catch (e) {
// If error, assume not square // If error, assume not square
setState(() { if (mounted) {
_isSquare = false; setState(() {
}); _isSquare = false;
});
}
} }
} }

View File

@@ -166,7 +166,6 @@ class MarkdownTextContent extends HookConsumerWidget {
label: 'copyToClipboard'.tr(), label: 'copyToClipboard'.tr(),
onPressed: () { onPressed: () {
Clipboard.setData(ClipboardData(text: href)); Clipboard.setData(ClipboardData(text: href));
clearSnackBar(context);
}, },
), ),
); );

View File

@@ -15,6 +15,7 @@ class NetworkStatusSheet extends HookConsumerWidget {
final wsState = ref.watch(websocketStateProvider); final wsState = ref.watch(websocketStateProvider);
return SheetScaffold( return SheetScaffold(
heightFactor: 0.4,
titleText: titleText:
wsState == WebSocketState.connected() wsState == WebSocketState.connected()
? 'Connection Status' ? 'Connection Status'

View File

@@ -51,7 +51,10 @@ class SheetScaffold extends StatelessWidget {
const Spacer(), const Spacer(),
...actions, ...actions,
IconButton( IconButton(
icon: const Icon(Symbols.close), icon: Icon(
Symbols.close,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: onPressed:
() => () =>
onClose != null onClose != null

View File

@@ -512,6 +512,7 @@ class FileListView extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'Are you sure you want to delete the selected files?', 'Are you sure you want to delete the selected files?',
'Delete Selected Files', 'Delete Selected Files',
isDanger: true,
); );
if (!confirmed) return; if (!confirmed) return;
if (context.mounted) { if (context.mounted) {
@@ -742,22 +743,25 @@ class FileListView extends HookConsumerWidget {
), ),
), ),
const Gap(16), const Gap(16),
Row( SingleChildScrollView(
mainAxisAlignment: MainAxisAlignment.center, scrollDirection: Axis.horizontal,
children: [ child: Row(
ElevatedButton.icon( mainAxisAlignment: MainAxisAlignment.center,
onPressed: onPickAndUpload, children: [
icon: const Icon(Symbols.upload_file), ElevatedButton.icon(
label: const Text('Upload Files'), onPressed: onPickAndUpload,
), icon: const Icon(Symbols.upload_file),
const Gap(12), label: const Text('Upload Files'),
OutlinedButton.icon( ),
onPressed: const Gap(12),
() => onShowCreateDirectory(ref.context, currentPath), OutlinedButton.icon(
icon: const Icon(Symbols.create_new_folder), onPressed:
label: const Text('Create Directory'), () => onShowCreateDirectory(ref.context, currentPath),
), icon: const Icon(Symbols.create_new_folder),
], label: const Text('Create Directory'),
),
],
),
), ),
], ],
), ),
@@ -785,6 +789,7 @@ class FileListView extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(), 'confirmDeleteFile'.tr(),
'deleteFile'.tr(), 'deleteFile'.tr(),
isDanger: true,
); );
if (!confirmed) return; if (!confirmed) return;
@@ -1153,6 +1158,7 @@ class FileListView extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(), 'confirmDeleteFile'.tr(),
'deleteFile'.tr(), 'deleteFile'.tr(),
isDanger: true,
); );
if (!confirmed) return; if (!confirmed) return;
@@ -1221,6 +1227,7 @@ class FileListView extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(), 'confirmDeleteFile'.tr(),
'deleteFile'.tr(), 'deleteFile'.tr(),
isDanger: true,
); );
if (!confirmed) return; if (!confirmed) return;
@@ -1263,6 +1270,7 @@ class FileListView extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(), 'confirmDeleteFile'.tr(),
'deleteFile'.tr(), 'deleteFile'.tr(),
isDanger: true,
); );
if (!confirmed) return; if (!confirmed) return;

View File

@@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_shared.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -56,9 +57,7 @@ class ComposeEmbedSheet extends HookConsumerWidget {
void saveEmbedView() { void saveEmbedView() {
final uri = uriController.text.trim(); final uri = uriController.text.trim();
if (uri.isEmpty) { if (uri.isEmpty) {
ScaffoldMessenger.of( showSnackBar('embedUriRequired'.tr());
context,
).showSnackBar(SnackBar(content: Text('embedUriRequired'.tr())));
return; return;
} }

View File

@@ -751,12 +751,7 @@ class ComposeLogic {
return post; return post;
} catch (err) { } catch (err) {
// Show error message if context is mounted showErrorAlert(err);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $err')));
}
rethrow; rethrow;
} finally { } finally {
state.submitting.value = false; state.submitting.value = false;

View File

@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/post_detail.dart';
import 'package:island/services/compose_storage_db.dart'; import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_card.dart'; import 'package:island/widgets/post/compose_card.dart';
@@ -50,19 +51,33 @@ class PostComposeSheet extends HookConsumerWidget {
final restoredInitialState = useState<PostComposeInitialState?>(null); final restoredInitialState = useState<PostComposeInitialState?>(null);
final prompted = useState(false); final prompted = useState(false);
final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost; // Fetch full post data if we're editing a post
final fullPostData =
originalPost != null
? ref.watch(postProvider(originalPost!.id))
: const AsyncValue.data(null);
// Use the full post data if available, otherwise fall back to originalPost
final effectiveOriginalPost = fullPostData.when(
data: (fullPost) => fullPost ?? originalPost,
loading: () => originalPost,
error: (_, _) => originalPost,
);
final repliedPost =
initialState?.replyingTo ?? effectiveOriginalPost?.repliedPost;
final forwardedPost = final forwardedPost =
initialState?.forwardingTo ?? originalPost?.forwardedPost; initialState?.forwardingTo ?? effectiveOriginalPost?.forwardedPost;
// Create compose state // Create compose state
final ComposeState state = useMemoized( final ComposeState state = useMemoized(
() => ComposeLogic.createState( () => ComposeLogic.createState(
originalPost: originalPost, originalPost: effectiveOriginalPost,
forwardedPost: forwardedPost, forwardedPost: forwardedPost,
repliedPost: repliedPost, repliedPost: repliedPost,
postType: 0, postType: 0,
), ),
[originalPost, forwardedPost, repliedPost], [effectiveOriginalPost, forwardedPost, repliedPost],
); );
// Add a listener to the entire state to trigger rebuilds // Add a listener to the entire state to trigger rebuilds
@@ -112,7 +127,7 @@ class PostComposeSheet extends HookConsumerWidget {
ref, ref,
state, state,
context, context,
originalPost: originalPost, originalPost: effectiveOriginalPost,
repliedPost: repliedPost, repliedPost: repliedPost,
forwardedPost: forwardedPost, forwardedPost: forwardedPost,
onSuccess: () { onSuccess: () {
@@ -139,8 +154,13 @@ class PostComposeSheet extends HookConsumerWidget {
height: 24, height: 24,
child: const CircularProgressIndicator(strokeWidth: 2), child: const CircularProgressIndicator(strokeWidth: 2),
) )
: Icon(originalPost != null ? Symbols.edit : Symbols.upload), : Icon(
tooltip: originalPost != null ? 'postUpdate'.tr() : 'postPublish'.tr(), effectiveOriginalPost != null ? Symbols.edit : Symbols.upload,
),
tooltip:
effectiveOriginalPost != null
? 'postUpdate'.tr()
: 'postPublish'.tr(),
), ),
]; ];
@@ -148,7 +168,7 @@ class PostComposeSheet extends HookConsumerWidget {
titleText: 'postCompose'.tr(), titleText: 'postCompose'.tr(),
actions: actions, actions: actions,
child: PostComposeCard( child: PostComposeCard(
originalPost: originalPost, originalPost: effectiveOriginalPost,
initialState: restoredInitialState.value ?? initialState, initialState: restoredInitialState.value ?? initialState,
onCancel: () => Navigator.of(context).pop(), onCancel: () => Navigator.of(context).pop(),
onSubmit: () { onSubmit: () {

View File

@@ -122,6 +122,7 @@ class DraftManagerSheet extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'clearAllDraftsConfirm'.tr(), 'clearAllDraftsConfirm'.tr(),
'clearAllDrafts'.tr(), 'clearAllDrafts'.tr(),
isDanger: true,
); );
if (confirmed == true) { if (confirmed == true) {

View File

@@ -197,6 +197,7 @@ class PostActionableItem extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deletePostHint'.tr(), 'deletePostHint'.tr(),
'deletePost'.tr(), 'deletePost'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);

View File

@@ -69,22 +69,24 @@ class PostItemCreator extends HookConsumerWidget {
title: 'delete'.tr(), title: 'delete'.tr(),
image: MenuImage.icon(Symbols.delete), image: MenuImage.icon(Symbols.delete),
callback: () { callback: () {
showConfirmAlert('deletePostHint'.tr(), 'deletePost'.tr()).then( showConfirmAlert(
(confirm) { 'deletePostHint'.tr(),
if (confirm) { 'deletePost'.tr(),
final client = ref.watch(apiClientProvider); isDanger: true,
client ).then((confirm) {
.delete('/sphere/posts/${item.id}') if (confirm) {
.catchError((err) { final client = ref.watch(apiClientProvider);
showErrorAlert(err); client
return err; .delete('/sphere/posts/${item.id}')
}) .catchError((err) {
.then((_) { showErrorAlert(err);
onRefresh?.call(); return err;
}); })
} .then((_) {
}, onRefresh?.call();
); });
}
});
}, },
), ),
MenuSeparator(), MenuSeparator(),

View File

@@ -45,6 +45,7 @@ class PostItemScreenshot extends ConsumerWidget {
children: [ children: [
Gap(renderingPadding.vertical), Gap(renderingPadding.vertical),
PostHeader( PostHeader(
hideOverlay: true,
item: item, item: item,
isFullPost: isFullPost, isFullPost: isFullPost,
isInteractive: false, isInteractive: false,
@@ -73,6 +74,7 @@ class PostItemScreenshot extends ConsumerWidget {
isFullPost: isFullPost, isFullPost: isFullPost,
isTextSelectable: false, isTextSelectable: false,
isInteractive: false, isInteractive: false,
hideOverlay: true,
), ),
if (isShowReference) if (isShowReference)
ReferencedPostWidget( ReferencedPostWidget(

View File

@@ -631,12 +631,33 @@ class CustomReactionForm extends HookConsumerWidget {
), ),
suffixIcon: InkWell( suffixIcon: InkWell(
onTapDown: (details) async { onTapDown: (details) async {
final screenSize = MediaQuery.sizeOf(context);
const popoverWidth = 500.0;
const popoverHeight = 500.0;
const padding = 20.0;
// Calculate safe horizontal position (centered, but within bounds)
final maxHorizontalOffset = math.max(
padding,
screenSize.width - popoverWidth - padding,
);
final horizontalOffset = ((screenSize.width - popoverWidth) /
2)
.clamp(padding, maxHorizontalOffset);
// Calculate safe vertical position (bottom-aligned, but within bounds)
final maxVerticalOffset = math.max(
padding,
screenSize.height - popoverHeight - padding,
);
final verticalOffset = (screenSize.height -
popoverHeight -
padding)
.clamp(padding, maxVerticalOffset);
await showStickerPickerPopover( await showStickerPickerPopover(
context, context,
Offset( Offset(horizontalOffset, verticalOffset),
(MediaQuery.sizeOf(context).width - 500) / 2,
MediaQuery.sizeOf(context).height - 500,
),
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
onPick: (placeholder) { onPick: (placeholder) {
// Remove the surrounding : from the placeholder // Remove the surrounding : from the placeholder

View File

@@ -196,7 +196,7 @@ class PostReplyPreview extends HookConsumerWidget {
: (featuredReply!).map( : (featuredReply!).map(
data: data:
(data) => Row( (data) => Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8, spacing: 8,
children: [ children: [
ProfilePictureWidget( ProfilePictureWidget(
@@ -554,6 +554,7 @@ class PostHeader extends StatelessWidget {
final EdgeInsets renderingPadding; final EdgeInsets renderingPadding;
final bool isRelativeTime; final bool isRelativeTime;
final bool isCompact; final bool isCompact;
final bool hideOverlay;
const PostHeader({ const PostHeader({
super.key, super.key,
@@ -564,6 +565,7 @@ class PostHeader extends StatelessWidget {
this.renderingPadding = EdgeInsets.zero, this.renderingPadding = EdgeInsets.zero,
this.isRelativeTime = true, this.isRelativeTime = true,
this.isCompact = false, this.isCompact = false,
this.hideOverlay = false,
}); });
@override @override
@@ -606,6 +608,7 @@ class PostHeader extends StatelessWidget {
(item.publisher.account != null && (item.publisher.account != null &&
item.publisher.type == 0) item.publisher.type == 0)
? AccountName( ? AccountName(
hideOverlay: hideOverlay,
account: item.publisher.account!, account: item.publisher.account!,
textOverride: item.publisher.nick, textOverride: item.publisher.nick,
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
@@ -618,7 +621,10 @@ class PostHeader extends StatelessWidget {
).bold(), ).bold(),
), ),
if (item.publisher.verification != null) if (item.publisher.verification != null)
VerificationMark(mark: item.publisher.verification!), VerificationMark(
mark: item.publisher.verification!,
hideOverlay: hideOverlay,
),
if (item.realm == null) if (item.realm == null)
Flexible( Flexible(
child: child:
@@ -690,6 +696,7 @@ class PostBody extends ConsumerWidget {
final bool isInteractive; final bool isInteractive;
final EdgeInsets renderingPadding; final EdgeInsets renderingPadding;
final bool isRelativeTime; final bool isRelativeTime;
final bool hideOverlay;
const PostBody({ const PostBody({
super.key, super.key,
@@ -700,6 +707,7 @@ class PostBody extends ConsumerWidget {
this.isInteractive = true, this.isInteractive = true,
this.renderingPadding = EdgeInsets.zero, this.renderingPadding = EdgeInsets.zero,
this.isRelativeTime = true, this.isRelativeTime = true,
this.hideOverlay = false,
}); });
@override @override
@@ -771,27 +779,31 @@ class PostBody extends ConsumerWidget {
); );
} }
if (item.editedAt != null) { if (item.editedAt != null) {
final text = Text(
'editedAt'.tr(
args: [
!isFullPost && isRelativeTime
? item.editedAt!.formatRelative(context)
: item.editedAt!.formatSystem(),
],
),
).fontSize(13);
metadataChildren.add( metadataChildren.add(
Row( Row(
spacing: 8, spacing: 8,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Symbols.edit, size: 16), const Icon(Symbols.edit, size: 16),
Tooltip( hideOverlay
message: ? text
!isFullPost && isRelativeTime : Tooltip(
? item.editedAt!.formatSystem() message:
: item.editedAt!.formatRelative(context), !isFullPost && isRelativeTime
child: Text( ? item.editedAt!.formatSystem()
'editedAt'.tr( : item.editedAt!.formatRelative(context),
args: [ child: text,
!isFullPost && isRelativeTime
? item.editedAt!.formatRelative(context)
: item.editedAt!.formatSystem(),
],
), ),
).fontSize(13),
),
], ],
), ),
); );

View File

@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/services/file_uploader.dart'; import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
@@ -177,8 +178,11 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
// Show compose sheet // Show compose sheet
if (mounted) { if (mounted) {
PostComposeSheet.show(context, initialState: initialState); await PostComposeSheet.show(context, initialState: initialState);
Navigator.of(context).pop(); // Close the share sheet // Close the share sheet after the compose sheet is dismissed
if (mounted) {
Navigator.of(context).pop();
}
} }
} catch (e) { } catch (e) {
showErrorAlert(e); showErrorAlert(e);
@@ -281,23 +285,10 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
); );
// Show navigation prompt // Show navigation prompt
final shouldNavigate = await showDialog<bool>( final shouldNavigate = await showConfirmAlert(
context: context, 'wouldYouLikeToGoToChat'.tr(),
builder: 'shareSuccess'.tr(),
(context) => AlertDialog( icon: Symbols.check_circle,
title: Text('shareSuccess'.tr()),
content: Text('wouldYouLikeToGoToChat'.tr()),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('no'.tr()),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('yes'.tr()),
),
],
),
); );
// Close the share sheet // Close the share sheet
@@ -363,6 +354,92 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
} }
} }
Future<void> _uploadFiles() async {
if (widget.content.files == null || widget.content.files!.isEmpty) return;
setState(() => _isLoading = true);
try {
final universalFiles =
widget.content.files!.map((file) {
UniversalFileType fileType;
if (file.mimeType?.startsWith('image/') == true) {
fileType = UniversalFileType.image;
} else if (file.mimeType?.startsWith('video/') == true) {
fileType = UniversalFileType.video;
} else if (file.mimeType?.startsWith('audio/') == true) {
fileType = UniversalFileType.audio;
} else {
fileType = UniversalFileType.file;
}
return UniversalFile(data: file, type: fileType);
}).toList();
// Initialize progress tracking
final messageId = DateTime.now().millisecondsSinceEpoch.toString();
_fileUploadProgress[messageId] = List.filled(universalFiles.length, 0.0);
List<SnCloudFile> uploadedFiles = [];
// Upload each file
for (var idx = 0; idx < universalFiles.length; idx++) {
final file = universalFiles[idx];
final cloudFile =
await FileUploader.createCloudFile(
ref: ref,
fileData: file,
onProgress: (progress, _) {
if (mounted) {
setState(() {
_fileUploadProgress[messageId]?[idx] = progress ?? 0.0;
});
}
},
).future;
if (cloudFile == null) {
throw Exception('Failed to upload file: ${file.data.name}');
}
uploadedFiles.add(cloudFile);
}
if (mounted) {
// Show success message
showSnackBar('uploadSuccess'.tr());
// If single file, ask to view details
if (uploadedFiles.length == 1) {
final shouldView = await showConfirmAlert(
'wouldYouLikeToViewFile'.tr(),
'uploadSuccess'.tr(),
icon: Symbols.check_circle,
);
if (mounted) {
Navigator.of(context).pop(); // Close share sheet
if (shouldView == true) {
context.pushNamed(
'fileDetail',
pathParameters: {'id': uploadedFiles.first.id},
extra: uploadedFiles.first,
);
}
}
} else {
// Just close for multiple files
Navigator.of(context).pop();
}
}
} catch (e) {
if (mounted) {
showErrorAlert(e);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _copyToClipboard() async { Future<void> _copyToClipboard() async {
try { try {
String textToCopy = ''; String textToCopy = '';
@@ -452,6 +529,15 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
onTap: _isLoading ? null : _shareToPost, onTap: _isLoading ? null : _shareToPost,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
if (widget.content.type ==
ShareContentType.file) ...[
_CompactShareOption(
icon: Symbols.cloud_upload,
title: 'upload'.tr(),
onTap: _isLoading ? null : _uploadFiles,
),
const SizedBox(width: 12),
],
_CompactShareOption( _CompactShareOption(
icon: Symbols.content_copy, icon: Symbols.content_copy,
title: 'copy'.tr(), title: 'copy'.tr(),
@@ -650,19 +736,26 @@ class _ChatRoomsList extends ConsumerWidget {
} }
} }
class _ChatRoomOption extends StatelessWidget { class _ChatRoomOption extends HookConsumerWidget {
final SnChatRoom room; final SnChatRoom room;
final VoidCallback? onTap; final VoidCallback? onTap;
const _ChatRoomOption({required this.room, this.onTap}); const _ChatRoomOption({required this.room, this.onTap});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final userInfo = ref.watch(userInfoProvider);
final validMembers =
(room.members ?? [])
.where((m) => m.accountId != userInfo.value?.id)
.toList();
final isDirect = room.type == 1; // Assuming type 1 is direct chat final isDirect = room.type == 1; // Assuming type 1 is direct chat
final displayName = final displayName =
room.name ?? room.name ??
(isDirect && room.members != null (isDirect
? room.members!.map((m) => m.account.nick).join(', ') ? validMembers.map((m) => m.account.nick).join(', ')
: 'unknownChat'.tr()); : 'unknownChat'.tr());
return GestureDetector( return GestureDetector(
@@ -694,18 +787,22 @@ class _ChatRoomOption extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: child:
room.picture != null (isDirect && room.picture?.id == null)
? ClipRRect( ? SplitAvatarWidget(
borderRadius: BorderRadius.circular(16), filesId:
child: CloudFileWidget( validMembers
item: room.picture!, .map((e) => e.account.profile.picture?.id)
fit: BoxFit.cover, .toList(),
), radius: 16,
) )
: Icon( : room.picture?.id == null
isDirect ? Symbols.person : Symbols.group, ? CircleAvatar(
size: 20, radius: 16,
color: Theme.of(context).colorScheme.primary, child: Text(room.name![0].toUpperCase()),
)
: ProfilePictureWidget(
fileId: room.picture?.id,
radius: 16,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),

View File

@@ -0,0 +1,289 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:flutter_highlight/themes/monokai-sublime.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/site_file.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/site_files.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart';
class FileItem extends HookConsumerWidget {
final SnSiteFileEntry file;
final SnPublicationSite site;
final void Function(String path)? onNavigateDirectory;
const FileItem({
super.key,
required this.file,
required this.site,
this.onNavigateDirectory,
});
Future<void> _downloadFile(BuildContext context, WidgetRef ref) async {
try {
final apiClient = ref.read(apiClientProvider);
// Get downloads directory
Directory? directory;
if (Platform.isAndroid) {
directory = await getExternalStorageDirectory();
if (directory != null) {
directory = Directory('${directory.path}/Download');
}
} else {
directory = await getDownloadsDirectory();
}
if (directory == null) {
throw Exception('Unable to access downloads directory');
}
// Create directory if it doesn't exist
await directory.create(recursive: true);
// Generate file path
final fileName = file.relativePath.split('/').last;
final filePath = '${directory.path}/$fileName';
// Use Dio's download method to directly stream from server to file
await apiClient.download(
'/zone/sites/${site.id}/files/content/${file.relativePath}',
filePath,
);
showSnackBar('Downloaded to $filePath');
} catch (e) {
showErrorAlert(e);
}
}
Future<void> _showEditSheet(BuildContext context, WidgetRef ref) async {
try {
final fileContent = await ref.read(
siteFileContentProvider(
siteId: site.id,
relativePath: file.relativePath,
).future,
);
if (context.mounted) {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: false,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height,
),
barrierColor: Theme.of(context).colorScheme.surfaceContainerLow,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
builder: (BuildContext context) {
return FileEditorSheet(
file: file,
site: site,
initialContent: fileContent.content,
);
},
);
}
} catch (e) {
showErrorAlert(e);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
elevation: 0,
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
leading: Icon(
file.isDirectory ? Symbols.folder : Symbols.description,
color: theme.colorScheme.primary,
),
title: Text(file.relativePath),
subtitle: Text(
file.isDirectory
? 'Directory'
: '${(file.size / 1024).toStringAsFixed(1)} KB',
),
trailing: PopupMenuButton<String>(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'download',
child: Row(
children: [
const Icon(Symbols.download),
const Gap(16),
Text('Download'),
],
),
),
if (!file.isDirectory) ...[
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('Edit Content'),
],
),
),
],
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('Delete').textColor(Colors.red),
],
),
),
],
onSelected: (value) async {
switch (value) {
case 'download':
await _downloadFile(context, ref);
break;
case 'edit':
await _showEditSheet(context, ref);
break;
case 'delete':
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Delete File'),
content: Text(
'Are you sure you want to delete "${file.relativePath}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
try {
await ref
.read(
siteFilesNotifierProvider((
siteId: site.id,
path: null,
)).notifier,
)
.deleteFile(file.relativePath);
showSnackBar('File deleted successfully');
} catch (e) {
showErrorAlert(e);
}
}
break;
}
},
),
onTap: () {
if (file.isDirectory) {
onNavigateDirectory?.call(file.relativePath);
} else {
_showEditSheet(context, ref);
}
},
),
);
}
}
class FileEditorSheet extends HookConsumerWidget {
final SnSiteFileEntry file;
final SnPublicationSite site;
final String initialContent;
const FileEditorSheet({
super.key,
required this.file,
required this.site,
required this.initialContent,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final codeController = useMemoized(
() => CodeController(
text: initialContent,
language: null, // Let the editor auto-detect or use plain text
),
);
final isSaving = useState(false);
final saveFile = useCallback(() async {
if (codeController.text.trim().isEmpty) {
showSnackBar('contentCantEmpty'.tr());
return;
}
isSaving.value = true;
try {
await ref
.read(
siteFilesNotifierProvider((siteId: site.id, path: null)).notifier,
)
.updateFileContent(file.relativePath, codeController.text);
if (context.mounted) {
showSnackBar('File saved successfully');
Navigator.of(context).pop();
}
} catch (e) {
showErrorAlert(e);
} finally {
isSaving.value = false;
}
}, [codeController, ref, site.id, file.relativePath, context, isSaving]);
return SheetScaffold(
heightFactor: 1,
titleText: 'Edit ${file.relativePath}',
actions: [
FilledButton(
onPressed: isSaving.value ? null : saveFile,
child: Text(isSaving.value ? 'Saving...' : 'Save'),
),
],
child: SingleChildScrollView(
padding: EdgeInsets.zero,
child: CodeTheme(
data: CodeThemeData(styles: monokaiSublimeTheme),
child: CodeField(
controller: codeController,
minLines: 20,
maxLines: null,
),
),
),
);
}
}

View File

@@ -0,0 +1,139 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:dio/dio.dart';
import 'package:http_parser/http_parser.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/pods/site_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class FileManagementActionSection extends HookConsumerWidget {
final SnPublicationSite site;
final String pubName;
const FileManagementActionSection({
super.key,
required this.site,
required this.pubName,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
child: Column(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'fileActions'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
).padding(horizontal: 16, top: 16),
Column(
children: [
ListTile(
leading: Icon(
Symbols.delete_forever,
color: theme.colorScheme.error,
),
title: Text('purgeFiles'.tr()),
subtitle: Text('purgeFilesDescription'.tr()),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () => _purgeFiles(context, ref),
),
const Gap(8),
ListTile(
leading: Icon(
Symbols.upload,
color: theme.colorScheme.primary,
),
title: Text('deploySite'.tr()),
subtitle: Text('deploySiteDescription'.tr()),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () => _deploySite(context, ref),
),
],
).padding(vertical: 8),
],
),
],
),
);
}
Future<void> _purgeFiles(BuildContext context, WidgetRef ref) async {
final confirmed = await showConfirmAlert(
'purgeFilesConfirm'.tr(),
'confirmPurge'.tr(),
isDanger: true,
);
if (confirmed != true) return;
try {
final apiClient = ref.read(apiClientProvider);
await apiClient.delete('/zone/sites/${site.id}/files/purge');
if (context.mounted) {
showSnackBar('allFilesPurgedSuccess'.tr());
// Refresh the file management section
ref.invalidate(siteFilesProvider(siteId: site.id));
}
} catch (e) {
if (context.mounted) {
showSnackBar('failedToPurgeFiles'.tr(args: [e.toString()]));
}
}
}
Future<void> _deploySite(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['zip'],
allowMultiple: false,
);
if (result == null || result.files.isEmpty) {
return; // User canceled
}
final file = File(result.files.first.path!);
try {
final apiClient = ref.read(apiClientProvider);
// Create multipart form data
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
file.path,
filename: result.files.first.name,
contentType: MediaType('application', 'zip'),
),
});
await apiClient.post(
'/zone/sites/${site.id}/files/deploy',
data: formData,
);
if (context.mounted) {
showSnackBar('siteDeployedSuccess'.tr());
// Refresh the file management section
ref.invalidate(siteFilesProvider(siteId: site.id));
}
} catch (e) {
if (context.mounted) {
showSnackBar('failedToDeploySite'.tr(args: [e.toString()]));
}
}
}
}

View File

@@ -0,0 +1,312 @@
import 'dart:io';
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/publication_site.dart';
import 'package:island/pods/site_files.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/sites/file_upload_dialog.dart';
import 'package:island/widgets/sites/file_item.dart';
import 'package:material_symbols_icons/symbols.dart';
class FileManagementSection extends HookConsumerWidget {
final SnPublicationSite site;
final String pubName;
const FileManagementSection({
super.key,
required this.site,
required this.pubName,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentPath = useState<String?>(null);
final filesAsync = ref.watch(
siteFilesProvider(siteId: site.id, path: currentPath.value),
);
final theme = Theme.of(context);
return Card(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.folder, size: 20),
const Gap(8),
Text(
'fileManagement'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
PopupMenuButton<String>(
icon: const Icon(Symbols.upload),
onSelected: (String choice) async {
List<File> files = [];
List<Map<String, dynamic>>? results;
if (choice == 'files') {
final selectedFiles = await FilePicker.platform
.pickFiles(
allowMultiple: true,
type: FileType.any,
);
if (selectedFiles == null ||
selectedFiles.files.isEmpty) {
return; // User canceled
}
files =
selectedFiles.files
.map((f) => File(f.path!))
.toList();
} else if (choice == 'folder') {
final dirPath =
await FilePicker.platform.getDirectoryPath();
if (dirPath == null) return;
results = await _getFilesRecursive(dirPath);
files =
results.map((m) => m['file'] as File).toList();
if (files.isEmpty) {
showSnackBar('noFilesFoundInFolder'.tr());
return;
}
}
if (!context.mounted) return;
// Show upload dialog for path specification
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => FileUploadDialog(
selectedFiles: files,
site: site,
relativePaths:
results
?.map(
(m) => m['relativePath'] as String,
)
.toList(),
onUploadComplete: () {
// Refresh file list
ref.invalidate(
siteFilesProvider(
siteId: site.id,
path: currentPath.value,
),
);
},
),
);
},
itemBuilder:
(BuildContext context) => [
PopupMenuItem<String>(
value: 'files',
child: Row(
children: [
Icon(Symbols.file_copy),
Gap(12),
Text('siteFiles'.tr()),
],
),
),
PopupMenuItem<String>(
value: 'folder',
child: Row(
children: [
Icon(Symbols.folder),
Gap(12),
Text('siteFolder'.tr()),
],
),
),
],
style: ButtonStyle(
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
),
],
),
const Gap(8),
if (currentPath.value != null && currentPath.value!.isNotEmpty)
Container(
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
IconButton(
icon: Icon(Symbols.arrow_back),
onPressed: () {
final pathParts =
currentPath.value!
.split('/')
.where((part) => part.isNotEmpty)
.toList();
if (pathParts.isEmpty) {
currentPath.value = null;
} else {
pathParts.removeLast();
currentPath.value =
pathParts.isEmpty
? null
: pathParts.join('/');
}
},
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
InkWell(
onTap: () => currentPath.value = null,
child: Text('siteRoot'.tr()),
),
...() {
final parts =
currentPath.value!
.split('/')
.where((part) => part.isNotEmpty)
.toList();
final widgets = <Widget>[];
String currentBuilder = '';
for (final part in parts) {
currentBuilder +=
(currentBuilder.isEmpty ? '' : '/') +
part;
final pathToSet = currentBuilder;
widgets.addAll([
const Text(' / '),
InkWell(
onTap:
() => currentPath.value = pathToSet,
child: Text(part),
),
]);
}
return widgets;
}(),
],
),
),
],
),
),
const Gap(8),
filesAsync.when(
data: (files) {
if (files.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
Symbols.folder,
size: 48,
color: theme.colorScheme.outline,
),
const Gap(16),
Text(
'noFilesUploadedYet'.tr(),
style: theme.textTheme.bodyLarge,
),
const Gap(8),
Text(
'uploadFirstFile'.tr(),
style: theme.textTheme.bodySmall,
),
],
),
),
);
}
return ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: files.length,
itemBuilder: (context, index) {
final file = files[index];
return FileItem(
file: file,
site: site,
onNavigateDirectory:
(path) => currentPath.value = path,
);
},
);
},
loading:
() => const Center(child: CircularProgressIndicator()),
error:
(error, stack) => Center(
child: Column(
children: [
Text('failedToLoadFiles'.tr()),
const Gap(8),
ElevatedButton(
onPressed:
() => ref.invalidate(
siteFilesProvider(
siteId: site.id,
path: currentPath.value,
),
),
child: Text('retry'.tr()),
),
],
),
),
),
],
),
),
],
),
);
}
Future<List<Map<String, dynamic>>> _getFilesRecursive(String dirPath) async {
final List<Map<String, dynamic>> results = [];
try {
await for (final entity in Directory(dirPath).list(recursive: true)) {
if (entity is File) {
String relativePath = entity.path.substring(dirPath.length);
if (relativePath.startsWith('/')) {
relativePath = relativePath.substring(1);
}
if (relativePath.isEmpty) continue;
results.add({
'file': File(entity.path),
'relativePath': relativePath,
});
}
}
} catch (e) {
// Handle error if needed
}
return results;
}
}

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