Compare commits
85 Commits
f871cd3b62
...
3.3.0+136
| Author | SHA1 | Date | |
|---|---|---|---|
|
c6594ea2ce
|
|||
|
3bec6e683e
|
|||
|
83e92e2eed
|
|||
|
a83b929d42
|
|||
|
9423affa75
|
|||
|
cda23db609
|
|||
|
61074bc5a3
|
|||
|
5feafa9255
|
|||
|
e604577c1f
|
|||
|
af0ddd1273
|
|||
|
8a6bb34808
|
|||
|
4ef8445c77
|
|||
|
ec39ad6ca3
|
|||
|
eabb3154f1
|
|||
|
910bf20eef
|
|||
|
5efa9b2ae8
|
|||
|
dd3e39e891
|
|||
|
b6896ded23
|
|||
|
f28a73ff9c
|
|||
|
a014b64235
|
|||
|
7e0e7c20d7
|
|||
|
389fa515ba
|
|||
|
681ead02eb
|
|||
|
8d1c145b0b
|
|||
|
51b4754182
|
|||
|
8a2b321701
|
|||
|
f685a7a249
|
|||
|
76009147e9
|
|||
|
ce12f28e56
|
|||
|
3604373a1e
|
|||
|
9704a4c2c7
|
|||
|
67def56ad1
|
|||
|
1be33916af
|
|||
|
e8ff1bfd22
|
|||
|
3ae56f3d89
|
|||
|
707143e998
|
|||
|
1fd34eb2a3
|
|||
|
d7ca41e946
|
|||
|
ad9fb0719a
|
|||
|
e2d315afd4
|
|||
|
6124dbfd79
|
|||
|
5327f04ec0
|
|||
|
41c56a2319
|
|||
|
f9d033542e
|
|||
|
91784e65e6
|
|||
|
9d39c6a825
|
|||
|
537e49f1a4
|
|||
|
75bbd4df71
|
|||
|
6ef4580d93
|
|||
|
6ffd498761
|
|||
|
27157e7cc1
|
|||
|
bbb07d574a
|
|||
|
c660a419e2
|
|||
|
c3f61467c8
|
|||
|
9bc47df452
|
|||
|
9ef8ca4d45
|
|||
|
b55cbd08d1
|
|||
|
8c6bd0feaa
|
|||
|
7dd4b20628
|
|||
|
fec0cb7640
|
|||
|
75deb04a2b
|
|||
|
7c7ed21a96
|
|||
|
a201f20793
|
|||
|
598c51bc1a
|
|||
|
e1ea61c5f1
|
|||
|
ac424bde36
|
|||
|
b43b70df3f
|
|||
|
4321aa621a
|
|||
|
d5d275fb43
|
|||
|
6bb3307144
|
|||
|
391604d4a2
|
|||
|
1d9361c12f
|
|||
|
a129b9cdd0
|
|||
|
3bf815ac61
|
|||
|
77bae4d6fd
|
|||
|
0a301c4c9b
|
|||
|
27b390a51c
|
|||
|
018386d14e
|
|||
|
3825d7c6c7
|
|||
|
bf930291e4
|
|||
|
a8c4988790
|
|||
|
28dd204b1a
|
|||
|
3cbc1a59a7
|
|||
|
277e9ae3d1
|
|||
|
27b3ca25b7
|
@@ -75,3 +75,4 @@ dependencies {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
"deletePublisherHint": "Are you sure to delete this publisher? This will also deleted all the post and collections under this publisher.",
|
||||
"deletePost": "Delete Post",
|
||||
"deletePostHint": "Are you sure to delete this post?",
|
||||
"deleteMessage": "Delete Message",
|
||||
"deleteMessageConfirmation": "Are you sure you want to delete this message?",
|
||||
"copyLink": "Copy Link",
|
||||
"postCreateAccountTitle": "Thanks for joining!",
|
||||
"postCreateAccountNext": "What's next?",
|
||||
@@ -133,6 +135,11 @@
|
||||
"reactionPositive": "Postive",
|
||||
"reactionNegative": "Negative",
|
||||
"reactionNeutral": "Neutral",
|
||||
"customReaction": "Custom Reaction",
|
||||
"customReactions": "Custom Reactions",
|
||||
"stickerPlaceholder": "Sticker Placeholder",
|
||||
"reactionAttitude": "Reaction Attitude",
|
||||
"addReaction": "Add Reaction",
|
||||
"connectionConnected": "Connected",
|
||||
"connectionDisconnected": "Disconnected",
|
||||
"connectionReconnecting": "Reconnecting",
|
||||
@@ -164,8 +171,8 @@
|
||||
"checkInResultLevel3": "Good Luck",
|
||||
"checkInResultLevel4": "Best Luck",
|
||||
"checkInActivityTitle": "{} checked in on {} and got a {}",
|
||||
"eventCalander": "Event Calander",
|
||||
"eventCalanderEmpty": "No events on that day.",
|
||||
"eventCalendar": "Event Calendar",
|
||||
"eventCalendarEmpty": "No events on that day.",
|
||||
"fortuneGraph": "Fortune Trend",
|
||||
"noFortuneData": "No fortune data available for this month.",
|
||||
"creatorHub": "Creator Hub",
|
||||
@@ -261,7 +268,6 @@
|
||||
"walletCurrencyShortPoints": "NSP",
|
||||
"walletCurrencyGolds": "The Solar Dollars",
|
||||
"walletCurrencyShortGolds": "NSD",
|
||||
"retry": "Retry",
|
||||
"creatorHubUnselectedHint": "Pick / create a publisher to get started.",
|
||||
"relationships": "Relationships",
|
||||
"addFriend": "Send a Friend Request",
|
||||
@@ -312,6 +318,8 @@
|
||||
"settingsBackgroundImageClear": "Clear Background Image",
|
||||
"settingsBackgroundGenerateColor": "Generate color scheme from Bacground Image",
|
||||
"messageNone": "No content to display",
|
||||
"messageUpdateLinks": "Server generated links previews",
|
||||
"messageUpdateEdited": "Edited a message",
|
||||
"unreadMessages": {
|
||||
"one": "{} unread message",
|
||||
"other": "{} unread messages"
|
||||
@@ -471,7 +479,6 @@
|
||||
"accountProfileView": "View Profile",
|
||||
"unspecified": "Unspecified",
|
||||
"added": "Added",
|
||||
"preview": "Preview",
|
||||
"togglePreview": "Toggle Preview",
|
||||
"subscribe": "Subscribe",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
@@ -632,6 +639,10 @@
|
||||
"chatNotJoined": "You have not joined this chat yet.",
|
||||
"chatUnableJoin": "You can't join this chat due to it's access control settings.",
|
||||
"chatJoin": "Join the Chat",
|
||||
"chatReplyingTo": "Replying to {}",
|
||||
"chatForwarding": "Forwarding message",
|
||||
"chatEditing": "Editing message",
|
||||
"chatNoContent": "No content",
|
||||
"realmJoin": "Join the Realm",
|
||||
"realmJoinSuccess": "Successfully joined the realm.",
|
||||
"search": "Search",
|
||||
@@ -1016,6 +1027,10 @@
|
||||
"searchLinks": "Links",
|
||||
"searchAttachments": "Attachments",
|
||||
"noMessagesFound": "No messages found",
|
||||
"Searching...": "Searching...",
|
||||
"searchError": "Search failed. Please try again.",
|
||||
"tryDifferentKeywords": "Try different keywords or remove search filters",
|
||||
"retry": "Retry",
|
||||
"openInBrowser": "Open in Browser",
|
||||
"highlightPost": "Highlight Post",
|
||||
"filters": "Filters",
|
||||
@@ -1054,6 +1069,7 @@
|
||||
"iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">",
|
||||
"parseIframe": "Parse Iframe",
|
||||
"messageActions": "Message Actions",
|
||||
"messageContent": "Message Content",
|
||||
"viewEmbedLoadHint": "Tap to load",
|
||||
"levelingStage1": "Novice",
|
||||
"levelingStage2": "Apprentice",
|
||||
@@ -1095,6 +1111,9 @@
|
||||
"openReleasePage": "Open release page",
|
||||
"postCompose": "Compose Post",
|
||||
"postPublish": "Publish Post",
|
||||
"restoreDraftTitle": "Restore Draft",
|
||||
"restoreDraftMessage": "A draft was found. Do you want to restore it?",
|
||||
"draft": "Draft",
|
||||
"purchaseGift": "Purchase Gift",
|
||||
"selectRecipient": "Select Recipient",
|
||||
"changeRecipient": "Change Recipient",
|
||||
@@ -1198,5 +1217,40 @@
|
||||
"transferRemark": "Transfer Remark",
|
||||
"addRemarkForTransfer": "Add remark for transfer",
|
||||
"enterPinToConfirmTransfer": "Enter your 6-digit PIN to confirm transfer",
|
||||
"transferCreatedSuccessfully": "Transfer created successfully!"
|
||||
"transferCreatedSuccessfully": "Transfer created successfully!",
|
||||
"postUpdate": "Update",
|
||||
"fileMetadata": "File Metadata",
|
||||
"resend": "Resend",
|
||||
"fileInfoTitle": "File Information",
|
||||
"download": "Download",
|
||||
"info": "Info",
|
||||
"noStickers": "No Stickers",
|
||||
"noStickersInPack": "This pack does not contains stickers",
|
||||
"noStickerPacks": "No Sticker Packs",
|
||||
"refresh": "Refresh",
|
||||
"spoiler": "Spoiler",
|
||||
"activityHeatmap": "Activity Heatmap",
|
||||
"custom": "Custom",
|
||||
"usernameColor": "Username Color",
|
||||
"colorType": "Color Type",
|
||||
"plain": "Plain",
|
||||
"gradient": "Gradient",
|
||||
"colorValue": "Color Value",
|
||||
"gradientDirection": "Gradient Direction",
|
||||
"gradientDirectionToRight": "To Right",
|
||||
"gradientDirectionToLeft": "To Left",
|
||||
"gradientDirectionToBottom": "To Bottom",
|
||||
"gradientDirectionToTop": "To Top",
|
||||
"gradientDirectionToBottomRight": "To Bottom Right",
|
||||
"gradientDirectionToBottomLeft": "To Bottom Left",
|
||||
"gradientDirectionToTopRight": "To Top Right",
|
||||
"gradientDirectionToTopLeft": "To Top Left",
|
||||
"gradientColors": "Gradient Colors",
|
||||
"color": "Color",
|
||||
"addColor": "Add Color",
|
||||
"preview": "Preview",
|
||||
"availableWithYourPlan": "Available with your plan",
|
||||
"upgradeRequired": "Upgrade required",
|
||||
"settingsDisableAnimation": "Disable Animation",
|
||||
"addTag": "Add Tag"
|
||||
}
|
||||
|
||||
@@ -940,7 +940,7 @@
|
||||
"editBot": "编辑机器人",
|
||||
"botAutomatedBy": "由 {} 自动化",
|
||||
"botDetails": "机器人详情",
|
||||
"overview": "总揽",
|
||||
"overview": "总览",
|
||||
"keys": "密钥",
|
||||
"botNotFound": "机器人未找到。",
|
||||
"newBotKey": "新建密钥",
|
||||
@@ -1060,7 +1060,7 @@
|
||||
"selectPool": "选择储存池",
|
||||
"choosePool": "选择一个储存池",
|
||||
"errorLoadingPools": "加载池时出错",
|
||||
"quotaCostInfo": "此上传将消耗{} 配额点",
|
||||
"quotaCostInfo": "此上传将消耗 {} 配额点",
|
||||
"uploadConstraints": "上传限制",
|
||||
"fileSizeExceeded": "文件大小超过了 {} 的最大限制",
|
||||
"fileTypeNotAccepted": "此储存池不接受该文件类型",
|
||||
@@ -1076,5 +1076,10 @@
|
||||
"recycledFilesDeleted": "被回收文件成功删除",
|
||||
"failedToDeleteRecycledFiles": "删除被回收文件失败",
|
||||
"upload": "上传",
|
||||
"systemWallet": "中央统筹"
|
||||
}
|
||||
"systemWallet": "中央统筹",
|
||||
"postCompose": "撰写帖子",
|
||||
"postPublish": "发布帖子",
|
||||
"restoreDraftTitle": "恢复草稿",
|
||||
"restoreDraftMessage": "发现了一个草稿。你想要恢复它吗?",
|
||||
"draft": "草稿"
|
||||
}
|
||||
|
||||
@@ -1075,5 +1075,7 @@
|
||||
"deleteRecycledFiles": "刪除已回收檔案",
|
||||
"recycledFilesDeleted": "已回收檔案刪除成功",
|
||||
"failedToDeleteRecycledFiles": "已回收檔案刪除失敗",
|
||||
"upload": "上傳"
|
||||
}
|
||||
"upload": "上傳",
|
||||
"postCompose": "撰寫帖子",
|
||||
"postPublish": "發佈帖子"
|
||||
}
|
||||
|
||||
145
ios/Podfile.lock
145
ios/Podfile.lock
@@ -44,83 +44,83 @@ PODS:
|
||||
- Flutter
|
||||
- file_saver (0.0.1):
|
||||
- Flutter
|
||||
- Firebase/CoreOnly (12.2.0):
|
||||
- FirebaseCore (~> 12.2.0)
|
||||
- Firebase/Crashlytics (12.2.0):
|
||||
- Firebase/CoreOnly (12.4.0):
|
||||
- FirebaseCore (~> 12.4.0)
|
||||
- Firebase/Crashlytics (12.4.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseCrashlytics (~> 12.2.0)
|
||||
- Firebase/Messaging (12.2.0):
|
||||
- FirebaseCrashlytics (~> 12.4.0)
|
||||
- Firebase/Messaging (12.4.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseMessaging (~> 12.2.0)
|
||||
- firebase_analytics (12.0.2):
|
||||
- FirebaseMessaging (~> 12.4.0)
|
||||
- firebase_analytics (12.0.3):
|
||||
- firebase_core
|
||||
- FirebaseAnalytics (= 12.2.0)
|
||||
- FirebaseAnalytics (= 12.4.0)
|
||||
- Flutter
|
||||
- firebase_core (4.1.1):
|
||||
- Firebase/CoreOnly (= 12.2.0)
|
||||
- firebase_core (4.2.0):
|
||||
- Firebase/CoreOnly (= 12.4.0)
|
||||
- Flutter
|
||||
- firebase_crashlytics (5.0.2):
|
||||
- Firebase/Crashlytics (= 12.2.0)
|
||||
- firebase_crashlytics (5.0.3):
|
||||
- Firebase/Crashlytics (= 12.4.0)
|
||||
- firebase_core
|
||||
- Flutter
|
||||
- firebase_messaging (16.0.2):
|
||||
- Firebase/Messaging (= 12.2.0)
|
||||
- firebase_messaging (16.0.3):
|
||||
- Firebase/Messaging (= 12.4.0)
|
||||
- firebase_core
|
||||
- Flutter
|
||||
- FirebaseAnalytics (12.2.0):
|
||||
- FirebaseAnalytics/Default (= 12.2.0)
|
||||
- FirebaseCore (~> 12.2.0)
|
||||
- FirebaseInstallations (~> 12.2.0)
|
||||
- FirebaseAnalytics (12.4.0):
|
||||
- FirebaseAnalytics/Default (= 12.4.0)
|
||||
- FirebaseCore (~> 12.4.0)
|
||||
- FirebaseInstallations (~> 12.4.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- FirebaseAnalytics/Default (12.2.0):
|
||||
- FirebaseCore (~> 12.2.0)
|
||||
- FirebaseInstallations (~> 12.2.0)
|
||||
- GoogleAppMeasurement/Default (= 12.2.0)
|
||||
- FirebaseAnalytics/Default (12.4.0):
|
||||
- FirebaseCore (~> 12.4.0)
|
||||
- FirebaseInstallations (~> 12.4.0)
|
||||
- GoogleAppMeasurement/Default (= 12.4.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- FirebaseCore (12.2.0):
|
||||
- FirebaseCoreInternal (~> 12.2.0)
|
||||
- FirebaseCore (12.4.0):
|
||||
- FirebaseCoreInternal (~> 12.4.0)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/Logger (~> 8.1)
|
||||
- FirebaseCoreExtension (12.2.0):
|
||||
- FirebaseCore (~> 12.2.0)
|
||||
- FirebaseCoreInternal (12.2.0):
|
||||
- FirebaseCoreExtension (12.4.0):
|
||||
- FirebaseCore (~> 12.4.0)
|
||||
- FirebaseCoreInternal (12.4.0):
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- FirebaseCrashlytics (12.2.0):
|
||||
- FirebaseCore (~> 12.2.0)
|
||||
- FirebaseInstallations (~> 12.2.0)
|
||||
- FirebaseRemoteConfigInterop (~> 12.2.0)
|
||||
- FirebaseSessions (~> 12.2.0)
|
||||
- FirebaseCrashlytics (12.4.0):
|
||||
- FirebaseCore (~> 12.4.0)
|
||||
- FirebaseInstallations (~> 12.4.0)
|
||||
- FirebaseRemoteConfigInterop (~> 12.4.0)
|
||||
- FirebaseSessions (~> 12.4.0)
|
||||
- GoogleDataTransport (~> 10.1)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- FirebaseInstallations (12.2.0):
|
||||
- FirebaseCore (~> 12.2.0)
|
||||
- FirebaseInstallations (12.4.0):
|
||||
- FirebaseCore (~> 12.4.0)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- FirebaseMessaging (12.2.0):
|
||||
- FirebaseCore (~> 12.2.0)
|
||||
- FirebaseInstallations (~> 12.2.0)
|
||||
- FirebaseMessaging (12.4.0):
|
||||
- FirebaseCore (~> 12.4.0)
|
||||
- FirebaseInstallations (~> 12.4.0)
|
||||
- GoogleDataTransport (~> 10.1)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/Reachability (~> 8.1)
|
||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
||||
- nanopb (~> 3.30910.0)
|
||||
- FirebaseRemoteConfigInterop (12.2.0)
|
||||
- FirebaseSessions (12.2.0):
|
||||
- FirebaseCore (~> 12.2.0)
|
||||
- FirebaseCoreExtension (~> 12.2.0)
|
||||
- FirebaseInstallations (~> 12.2.0)
|
||||
- FirebaseRemoteConfigInterop (12.4.0)
|
||||
- FirebaseSessions (12.4.0):
|
||||
- FirebaseCore (~> 12.4.0)
|
||||
- FirebaseCoreExtension (~> 12.4.0)
|
||||
- FirebaseInstallations (~> 12.4.0)
|
||||
- GoogleDataTransport (~> 10.1)
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/UserDefaults (~> 8.1)
|
||||
@@ -157,27 +157,28 @@ PODS:
|
||||
- gal (1.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- GoogleAdsOnDeviceConversion (2.3.0):
|
||||
- GoogleAdsOnDeviceConversion (3.1.0):
|
||||
- GoogleUtilities/Environment (~> 8.1)
|
||||
- GoogleUtilities/Logger (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleAppMeasurement/Core (12.2.0):
|
||||
- GoogleAppMeasurement/Core (12.4.0):
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleAppMeasurement/Default (12.2.0):
|
||||
- GoogleAdsOnDeviceConversion (= 2.3.0)
|
||||
- GoogleAppMeasurement/Core (= 12.2.0)
|
||||
- GoogleAppMeasurement/IdentitySupport (= 12.2.0)
|
||||
- GoogleAppMeasurement/Default (12.4.0):
|
||||
- GoogleAdsOnDeviceConversion (~> 3.1.0)
|
||||
- GoogleAppMeasurement/Core (= 12.4.0)
|
||||
- GoogleAppMeasurement/IdentitySupport (= 12.4.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleAppMeasurement/IdentitySupport (12.2.0):
|
||||
- GoogleAppMeasurement/Core (= 12.2.0)
|
||||
- GoogleAppMeasurement/IdentitySupport (12.4.0):
|
||||
- GoogleAppMeasurement/Core (= 12.4.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||
- GoogleUtilities/Network (~> 8.1)
|
||||
@@ -217,7 +218,7 @@ PODS:
|
||||
- Flutter
|
||||
- irondash_engine_context (0.0.1):
|
||||
- Flutter
|
||||
- Kingfisher (8.5.0)
|
||||
- Kingfisher (8.6.0)
|
||||
- livekit_client (2.5.0):
|
||||
- Flutter
|
||||
- flutter_webrtc
|
||||
@@ -254,9 +255,9 @@ PODS:
|
||||
- record_ios (1.1.0):
|
||||
- Flutter
|
||||
- SAMKeychain (1.5.3)
|
||||
- SDWebImage (5.21.2):
|
||||
- SDWebImage/Core (= 5.21.2)
|
||||
- SDWebImage/Core (5.21.2)
|
||||
- SDWebImage (5.21.3):
|
||||
- SDWebImage/Core (= 5.21.3)
|
||||
- SDWebImage/Core (5.21.3)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@@ -486,20 +487,20 @@ SPEC CHECKSUMS:
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||
Firebase: 26f6f8d460603af3df970ad505b16b15f5e2e9a1
|
||||
firebase_analytics: 8c78ce6224e0623152379d6cc7ef3d9098477b7e
|
||||
firebase_core: dfc4bd142bee4bc53a5d482397ca322c2dd3165d
|
||||
firebase_crashlytics: e55dcf895eed0dd87c447dd5aff8db7f1bb8bbdb
|
||||
firebase_messaging: 38c66c1184695b0c87abe51d40fc590718abed1a
|
||||
FirebaseAnalytics: e04e23bc070e3014aa5cf4980f9df7ce5cd79ec8
|
||||
FirebaseCore: 311c48a147ad4a0ab7febbaed89e8025c67510cd
|
||||
FirebaseCoreExtension: 73af080c22a2f7b44cefa391dc08f7e4ee162cb5
|
||||
FirebaseCoreInternal: 56ea29f3dad2894f81b060f706f9d53509b6ed3b
|
||||
FirebaseCrashlytics: f83cbf176d5c637ade108c0aacf1ccbd5ec499bf
|
||||
FirebaseInstallations: 3e884b01feabdf67582a80f3250425a00979b4ed
|
||||
FirebaseMessaging: 43ec73bbfedd0c385a849bb91593ab4ad4b9e48e
|
||||
FirebaseRemoteConfigInterop: 0896fd52ab72586a355c8f389ff85aaa9e5375e1
|
||||
FirebaseSessions: f4692789e770bec66ce17d772c0e9561c4f11737
|
||||
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
|
||||
firebase_analytics: 1d024068b1d4707d5ba7a42a12976ddf3316d835
|
||||
firebase_core: 744984dbbed8b3036abf34f0b98d80f130a7e464
|
||||
firebase_crashlytics: f3a9a4338ab99b67042f64e9e22e1bf349cb44ed
|
||||
firebase_messaging: 82c70650c426a0a14873e1acdb9ec2b443c4e8b4
|
||||
FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f
|
||||
FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
|
||||
FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018
|
||||
FirebaseCoreInternal: d7f5a043c2cd01a08103ab586587c1468047bca6
|
||||
FirebaseCrashlytics: a6ece278a837c7e88de2d9b5da0a3542f2342395
|
||||
FirebaseInstallations: ae9f4902cb5bf1d0c5eaa31ec1f4e5495a0714e2
|
||||
FirebaseMessaging: d33971b7bb252745ea6cd31ab190d1a1df4b8ed5
|
||||
FirebaseRemoteConfigInterop: 1e31ec72b89c9924367c59bfb5ec9ab60d1d6766
|
||||
FirebaseSessions: ba7c7a7ca8696a8d540eb3fe3800fbe98c79786d
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
|
||||
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
|
||||
@@ -512,13 +513,13 @@ SPEC CHECKSUMS:
|
||||
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
|
||||
flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22
|
||||
gal: baecd024ebfd13c441269ca7404792a7152fde89
|
||||
GoogleAdsOnDeviceConversion: 9090c435cde08903e8dd1ba2c77fbec9e46d9afe
|
||||
GoogleAppMeasurement: 09f341dfa8527d1612a09cbfe809a242c0b737af
|
||||
GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1
|
||||
GoogleAppMeasurement: 1e718274b7e015cefd846ac1fcf7820c70dc017d
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
||||
Kingfisher: ff0d31a1f07bdff6a1ebb3ba08b8e6e567b6500c
|
||||
Kingfisher: 64278f126a815d0e2d391cdf71311b85882c4de0
|
||||
livekit_client: a6f5fa86ac28ccd7ded53626a5379961db311ab4
|
||||
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
@@ -535,7 +536,7 @@ SPEC CHECKSUMS:
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
|
||||
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/wasm.dart';
|
||||
import 'package:island/database/drift_db.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
AppDatabase constructDb() {
|
||||
return AppDatabase(connectOnWeb());
|
||||
@@ -9,12 +10,17 @@ AppDatabase constructDb() {
|
||||
DatabaseConnection connectOnWeb() {
|
||||
return DatabaseConnection.delayed(
|
||||
Future(() async {
|
||||
final result = await WasmDatabase.open(
|
||||
databaseName: 'solar_network_data',
|
||||
sqlite3Uri: Uri.parse('sqlite3.wasm'),
|
||||
driftWorkerUri: Uri.parse('drift_worker.dart.js'),
|
||||
);
|
||||
return result.resolvedExecutor;
|
||||
try {
|
||||
final result = await WasmDatabase.open(
|
||||
databaseName: 'solar_network_data',
|
||||
sqlite3Uri: Uri.parse('sqlite3.wasm'),
|
||||
driftWorkerUri: Uri.parse('drift_worker.dart.js'),
|
||||
);
|
||||
return result.resolvedExecutor;
|
||||
} catch (e, stackTrace) {
|
||||
talker.error('Failed to open WASM database...', e, stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,19 @@ class ProfileLinkConverter
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class UsernameColor with _$UsernameColor {
|
||||
const factory UsernameColor({
|
||||
@Default('plain') String type,
|
||||
String? value,
|
||||
String? direction,
|
||||
List<String>? colors,
|
||||
}) = _UsernameColor;
|
||||
|
||||
factory UsernameColor.fromJson(Map<String, dynamic> json) =>
|
||||
_$UsernameColorFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnAccountProfile with _$SnAccountProfile {
|
||||
const factory SnAccountProfile({
|
||||
@@ -79,6 +92,7 @@ sealed class SnAccountProfile with _$SnAccountProfile {
|
||||
required SnCloudFile? picture,
|
||||
required SnCloudFile? background,
|
||||
required SnVerificationMark? verification,
|
||||
UsernameColor? usernameColor,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
|
||||
@@ -622,10 +622,284 @@ as String,
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$UsernameColor {
|
||||
|
||||
String get type; String? get value; String? get direction; List<String>? get colors;
|
||||
/// Create a copy of UsernameColor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$UsernameColorCopyWith<UsernameColor> get copyWith => _$UsernameColorCopyWithImpl<UsernameColor>(this as UsernameColor, _$identity);
|
||||
|
||||
/// Serializes this UsernameColor to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is UsernameColor&&(identical(other.type, type) || other.type == type)&&(identical(other.value, value) || other.value == value)&&(identical(other.direction, direction) || other.direction == direction)&&const DeepCollectionEquality().equals(other.colors, colors));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,type,value,direction,const DeepCollectionEquality().hash(colors));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UsernameColor(type: $type, value: $value, direction: $direction, colors: $colors)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $UsernameColorCopyWith<$Res> {
|
||||
factory $UsernameColorCopyWith(UsernameColor value, $Res Function(UsernameColor) _then) = _$UsernameColorCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String type, String? value, String? direction, List<String>? colors
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$UsernameColorCopyWithImpl<$Res>
|
||||
implements $UsernameColorCopyWith<$Res> {
|
||||
_$UsernameColorCopyWithImpl(this._self, this._then);
|
||||
|
||||
final UsernameColor _self;
|
||||
final $Res Function(UsernameColor) _then;
|
||||
|
||||
/// Create a copy of UsernameColor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? value = freezed,Object? direction = freezed,Object? colors = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,value: freezed == value ? _self.value : value // ignore: cast_nullable_to_non_nullable
|
||||
as String?,direction: freezed == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable
|
||||
as String?,colors: freezed == colors ? _self.colors : colors // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [UsernameColor].
|
||||
extension UsernameColorPatterns on UsernameColor {
|
||||
/// 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( _UsernameColor value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UsernameColor() 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( _UsernameColor value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UsernameColor():
|
||||
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( _UsernameColor value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _UsernameColor() 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 type, String? value, String? direction, List<String>? colors)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UsernameColor() when $default != null:
|
||||
return $default(_that.type,_that.value,_that.direction,_that.colors);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 type, String? value, String? direction, List<String>? colors) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UsernameColor():
|
||||
return $default(_that.type,_that.value,_that.direction,_that.colors);}
|
||||
}
|
||||
/// 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 type, String? value, String? direction, List<String>? colors)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UsernameColor() when $default != null:
|
||||
return $default(_that.type,_that.value,_that.direction,_that.colors);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _UsernameColor implements UsernameColor {
|
||||
const _UsernameColor({this.type = 'plain', this.value, this.direction, final List<String>? colors}): _colors = colors;
|
||||
factory _UsernameColor.fromJson(Map<String, dynamic> json) => _$UsernameColorFromJson(json);
|
||||
|
||||
@override@JsonKey() final String type;
|
||||
@override final String? value;
|
||||
@override final String? direction;
|
||||
final List<String>? _colors;
|
||||
@override List<String>? get colors {
|
||||
final value = _colors;
|
||||
if (value == null) return null;
|
||||
if (_colors is EqualUnmodifiableListView) return _colors;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(value);
|
||||
}
|
||||
|
||||
|
||||
/// Create a copy of UsernameColor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$UsernameColorCopyWith<_UsernameColor> get copyWith => __$UsernameColorCopyWithImpl<_UsernameColor>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$UsernameColorToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UsernameColor&&(identical(other.type, type) || other.type == type)&&(identical(other.value, value) || other.value == value)&&(identical(other.direction, direction) || other.direction == direction)&&const DeepCollectionEquality().equals(other._colors, _colors));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,type,value,direction,const DeepCollectionEquality().hash(_colors));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UsernameColor(type: $type, value: $value, direction: $direction, colors: $colors)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$UsernameColorCopyWith<$Res> implements $UsernameColorCopyWith<$Res> {
|
||||
factory _$UsernameColorCopyWith(_UsernameColor value, $Res Function(_UsernameColor) _then) = __$UsernameColorCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String type, String? value, String? direction, List<String>? colors
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$UsernameColorCopyWithImpl<$Res>
|
||||
implements _$UsernameColorCopyWith<$Res> {
|
||||
__$UsernameColorCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _UsernameColor _self;
|
||||
final $Res Function(_UsernameColor) _then;
|
||||
|
||||
/// Create a copy of UsernameColor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? value = freezed,Object? direction = freezed,Object? colors = freezed,}) {
|
||||
return _then(_UsernameColor(
|
||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,value: freezed == value ? _self.value : value // ignore: cast_nullable_to_non_nullable
|
||||
as String?,direction: freezed == direction ? _self.direction : direction // ignore: cast_nullable_to_non_nullable
|
||||
as String?,colors: freezed == colors ? _self._colors : colors // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnAccountProfile {
|
||||
|
||||
String get id; String get firstName; String get middleName; String get lastName; String get bio; String get gender; String get pronouns; String get location; String get timeZone; DateTime? get birthday;@ProfileLinkConverter() List<ProfileLink> get links; DateTime? get lastSeenAt; SnAccountBadge? get activeBadge; int get experience; int get level; double get socialCredits; int get socialCreditsLevel; double get levelingProgress; SnCloudFile? get picture; SnCloudFile? get background; SnVerificationMark? get verification; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
|
||||
String get id; String get firstName; String get middleName; String get lastName; String get bio; String get gender; String get pronouns; String get location; String get timeZone; DateTime? get birthday;@ProfileLinkConverter() List<ProfileLink> get links; DateTime? get lastSeenAt; SnAccountBadge? get activeBadge; int get experience; int get level; double get socialCredits; int get socialCreditsLevel; double get levelingProgress; SnCloudFile? get picture; SnCloudFile? get background; SnVerificationMark? get verification; UsernameColor? get usernameColor; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
|
||||
/// Create a copy of SnAccountProfile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -638,16 +912,16 @@ $SnAccountProfileCopyWith<SnAccountProfile> get copyWith => _$SnAccountProfileCo
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.pronouns, pronouns) || other.pronouns == pronouns)&&(identical(other.location, location) || other.location == location)&&(identical(other.timeZone, timeZone) || other.timeZone == timeZone)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&const DeepCollectionEquality().equals(other.links, links)&&(identical(other.lastSeenAt, lastSeenAt) || other.lastSeenAt == lastSeenAt)&&(identical(other.activeBadge, activeBadge) || other.activeBadge == activeBadge)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.socialCredits, socialCredits) || other.socialCredits == socialCredits)&&(identical(other.socialCreditsLevel, socialCreditsLevel) || other.socialCreditsLevel == socialCreditsLevel)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.verification, verification) || other.verification == verification)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.pronouns, pronouns) || other.pronouns == pronouns)&&(identical(other.location, location) || other.location == location)&&(identical(other.timeZone, timeZone) || other.timeZone == timeZone)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&const DeepCollectionEquality().equals(other.links, links)&&(identical(other.lastSeenAt, lastSeenAt) || other.lastSeenAt == lastSeenAt)&&(identical(other.activeBadge, activeBadge) || other.activeBadge == activeBadge)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.socialCredits, socialCredits) || other.socialCredits == socialCredits)&&(identical(other.socialCreditsLevel, socialCreditsLevel) || other.socialCreditsLevel == socialCreditsLevel)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.verification, verification) || other.verification == verification)&&(identical(other.usernameColor, usernameColor) || other.usernameColor == usernameColor)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hashAll([runtimeType,id,firstName,middleName,lastName,bio,gender,pronouns,location,timeZone,birthday,const DeepCollectionEquality().hash(links),lastSeenAt,activeBadge,experience,level,socialCredits,socialCreditsLevel,levelingProgress,picture,background,verification,createdAt,updatedAt,deletedAt]);
|
||||
int get hashCode => Object.hashAll([runtimeType,id,firstName,middleName,lastName,bio,gender,pronouns,location,timeZone,birthday,const DeepCollectionEquality().hash(links),lastSeenAt,activeBadge,experience,level,socialCredits,socialCreditsLevel,levelingProgress,picture,background,verification,usernameColor,createdAt,updatedAt,deletedAt]);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, gender: $gender, pronouns: $pronouns, location: $location, timeZone: $timeZone, birthday: $birthday, links: $links, lastSeenAt: $lastSeenAt, activeBadge: $activeBadge, experience: $experience, level: $level, socialCredits: $socialCredits, socialCreditsLevel: $socialCreditsLevel, levelingProgress: $levelingProgress, picture: $picture, background: $background, verification: $verification, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, gender: $gender, pronouns: $pronouns, location: $location, timeZone: $timeZone, birthday: $birthday, links: $links, lastSeenAt: $lastSeenAt, activeBadge: $activeBadge, experience: $experience, level: $level, socialCredits: $socialCredits, socialCreditsLevel: $socialCreditsLevel, levelingProgress: $levelingProgress, picture: $picture, background: $background, verification: $verification, usernameColor: $usernameColor, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
@@ -658,11 +932,11 @@ abstract mixin class $SnAccountProfileCopyWith<$Res> {
|
||||
factory $SnAccountProfileCopyWith(SnAccountProfile value, $Res Function(SnAccountProfile) _then) = _$SnAccountProfileCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday,@ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double socialCredits, int socialCreditsLevel, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday,@ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double socialCredits, int socialCreditsLevel, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, UsernameColor? usernameColor, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
$SnAccountBadgeCopyWith<$Res>? get activeBadge;$SnCloudFileCopyWith<$Res>? get picture;$SnCloudFileCopyWith<$Res>? get background;$SnVerificationMarkCopyWith<$Res>? get verification;
|
||||
$SnAccountBadgeCopyWith<$Res>? get activeBadge;$SnCloudFileCopyWith<$Res>? get picture;$SnCloudFileCopyWith<$Res>? get background;$SnVerificationMarkCopyWith<$Res>? get verification;$UsernameColorCopyWith<$Res>? get usernameColor;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
@@ -675,7 +949,7 @@ class _$SnAccountProfileCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnAccountProfile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? middleName = null,Object? lastName = null,Object? bio = null,Object? gender = null,Object? pronouns = null,Object? location = null,Object? timeZone = null,Object? birthday = freezed,Object? links = null,Object? lastSeenAt = freezed,Object? activeBadge = freezed,Object? experience = null,Object? level = null,Object? socialCredits = null,Object? socialCreditsLevel = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? verification = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? middleName = null,Object? lastName = null,Object? bio = null,Object? gender = null,Object? pronouns = null,Object? location = null,Object? timeZone = null,Object? birthday = freezed,Object? links = null,Object? lastSeenAt = freezed,Object? activeBadge = freezed,Object? experience = null,Object? level = null,Object? socialCredits = null,Object? socialCreditsLevel = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? verification = freezed,Object? usernameColor = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||
@@ -698,7 +972,8 @@ as int,levelingProgress: null == levelingProgress ? _self.levelingProgress : lev
|
||||
as double,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable
|
||||
as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
|
||||
as SnCloudFile?,verification: freezed == verification ? _self.verification : verification // ignore: cast_nullable_to_non_nullable
|
||||
as SnVerificationMark?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as SnVerificationMark?,usernameColor: freezed == usernameColor ? _self.usernameColor : usernameColor // ignore: cast_nullable_to_non_nullable
|
||||
as UsernameColor?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
@@ -752,6 +1027,18 @@ $SnVerificationMarkCopyWith<$Res>? get verification {
|
||||
return $SnVerificationMarkCopyWith<$Res>(_self.verification!, (value) {
|
||||
return _then(_self.copyWith(verification: value));
|
||||
});
|
||||
}/// Create a copy of SnAccountProfile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$UsernameColorCopyWith<$Res>? get usernameColor {
|
||||
if (_self.usernameColor == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $UsernameColorCopyWith<$Res>(_self.usernameColor!, (value) {
|
||||
return _then(_self.copyWith(usernameColor: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,10 +1118,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, @ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double socialCredits, int socialCreditsLevel, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, @ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double socialCredits, int socialCreditsLevel, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, UsernameColor? usernameColor, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnAccountProfile() when $default != null:
|
||||
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.socialCredits,_that.socialCreditsLevel,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.socialCredits,_that.socialCreditsLevel,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.usernameColor,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -852,10 +1139,10 @@ return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.b
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, @ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double socialCredits, int socialCreditsLevel, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, @ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double socialCredits, int socialCreditsLevel, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, UsernameColor? usernameColor, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnAccountProfile():
|
||||
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.socialCredits,_that.socialCreditsLevel,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
||||
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.socialCredits,_that.socialCreditsLevel,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.usernameColor,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
@@ -869,10 +1156,10 @@ return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.b
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, @ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double socialCredits, int socialCreditsLevel, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday, @ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double socialCredits, int socialCreditsLevel, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, UsernameColor? usernameColor, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnAccountProfile() when $default != null:
|
||||
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.socialCredits,_that.socialCreditsLevel,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.bio,_that.gender,_that.pronouns,_that.location,_that.timeZone,_that.birthday,_that.links,_that.lastSeenAt,_that.activeBadge,_that.experience,_that.level,_that.socialCredits,_that.socialCreditsLevel,_that.levelingProgress,_that.picture,_that.background,_that.verification,_that.usernameColor,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -884,7 +1171,7 @@ return $default(_that.id,_that.firstName,_that.middleName,_that.lastName,_that.b
|
||||
@JsonSerializable()
|
||||
|
||||
class _SnAccountProfile implements SnAccountProfile {
|
||||
const _SnAccountProfile({required this.id, this.firstName = '', this.middleName = '', this.lastName = '', this.bio = '', this.gender = '', this.pronouns = '', this.location = '', this.timeZone = '', this.birthday, @ProfileLinkConverter() final List<ProfileLink> links = const [], this.lastSeenAt, this.activeBadge, required this.experience, required this.level, this.socialCredits = 100, this.socialCreditsLevel = 0, required this.levelingProgress, required this.picture, required this.background, required this.verification, required this.createdAt, required this.updatedAt, required this.deletedAt}): _links = links;
|
||||
const _SnAccountProfile({required this.id, this.firstName = '', this.middleName = '', this.lastName = '', this.bio = '', this.gender = '', this.pronouns = '', this.location = '', this.timeZone = '', this.birthday, @ProfileLinkConverter() final List<ProfileLink> links = const [], this.lastSeenAt, this.activeBadge, required this.experience, required this.level, this.socialCredits = 100, this.socialCreditsLevel = 0, required this.levelingProgress, required this.picture, required this.background, required this.verification, this.usernameColor, required this.createdAt, required this.updatedAt, required this.deletedAt}): _links = links;
|
||||
factory _SnAccountProfile.fromJson(Map<String, dynamic> json) => _$SnAccountProfileFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@@ -914,6 +1201,7 @@ class _SnAccountProfile implements SnAccountProfile {
|
||||
@override final SnCloudFile? picture;
|
||||
@override final SnCloudFile? background;
|
||||
@override final SnVerificationMark? verification;
|
||||
@override final UsernameColor? usernameColor;
|
||||
@override final DateTime createdAt;
|
||||
@override final DateTime updatedAt;
|
||||
@override final DateTime? deletedAt;
|
||||
@@ -931,16 +1219,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.pronouns, pronouns) || other.pronouns == pronouns)&&(identical(other.location, location) || other.location == location)&&(identical(other.timeZone, timeZone) || other.timeZone == timeZone)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&const DeepCollectionEquality().equals(other._links, _links)&&(identical(other.lastSeenAt, lastSeenAt) || other.lastSeenAt == lastSeenAt)&&(identical(other.activeBadge, activeBadge) || other.activeBadge == activeBadge)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.socialCredits, socialCredits) || other.socialCredits == socialCredits)&&(identical(other.socialCreditsLevel, socialCreditsLevel) || other.socialCreditsLevel == socialCreditsLevel)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.verification, verification) || other.verification == verification)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.middleName, middleName) || other.middleName == middleName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.gender, gender) || other.gender == gender)&&(identical(other.pronouns, pronouns) || other.pronouns == pronouns)&&(identical(other.location, location) || other.location == location)&&(identical(other.timeZone, timeZone) || other.timeZone == timeZone)&&(identical(other.birthday, birthday) || other.birthday == birthday)&&const DeepCollectionEquality().equals(other._links, _links)&&(identical(other.lastSeenAt, lastSeenAt) || other.lastSeenAt == lastSeenAt)&&(identical(other.activeBadge, activeBadge) || other.activeBadge == activeBadge)&&(identical(other.experience, experience) || other.experience == experience)&&(identical(other.level, level) || other.level == level)&&(identical(other.socialCredits, socialCredits) || other.socialCredits == socialCredits)&&(identical(other.socialCreditsLevel, socialCreditsLevel) || other.socialCreditsLevel == socialCreditsLevel)&&(identical(other.levelingProgress, levelingProgress) || other.levelingProgress == levelingProgress)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.verification, verification) || other.verification == verification)&&(identical(other.usernameColor, usernameColor) || other.usernameColor == usernameColor)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hashAll([runtimeType,id,firstName,middleName,lastName,bio,gender,pronouns,location,timeZone,birthday,const DeepCollectionEquality().hash(_links),lastSeenAt,activeBadge,experience,level,socialCredits,socialCreditsLevel,levelingProgress,picture,background,verification,createdAt,updatedAt,deletedAt]);
|
||||
int get hashCode => Object.hashAll([runtimeType,id,firstName,middleName,lastName,bio,gender,pronouns,location,timeZone,birthday,const DeepCollectionEquality().hash(_links),lastSeenAt,activeBadge,experience,level,socialCredits,socialCreditsLevel,levelingProgress,picture,background,verification,usernameColor,createdAt,updatedAt,deletedAt]);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, gender: $gender, pronouns: $pronouns, location: $location, timeZone: $timeZone, birthday: $birthday, links: $links, lastSeenAt: $lastSeenAt, activeBadge: $activeBadge, experience: $experience, level: $level, socialCredits: $socialCredits, socialCreditsLevel: $socialCreditsLevel, levelingProgress: $levelingProgress, picture: $picture, background: $background, verification: $verification, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
return 'SnAccountProfile(id: $id, firstName: $firstName, middleName: $middleName, lastName: $lastName, bio: $bio, gender: $gender, pronouns: $pronouns, location: $location, timeZone: $timeZone, birthday: $birthday, links: $links, lastSeenAt: $lastSeenAt, activeBadge: $activeBadge, experience: $experience, level: $level, socialCredits: $socialCredits, socialCreditsLevel: $socialCreditsLevel, levelingProgress: $levelingProgress, picture: $picture, background: $background, verification: $verification, usernameColor: $usernameColor, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
@@ -951,11 +1239,11 @@ abstract mixin class _$SnAccountProfileCopyWith<$Res> implements $SnAccountProfi
|
||||
factory _$SnAccountProfileCopyWith(_SnAccountProfile value, $Res Function(_SnAccountProfile) _then) = __$SnAccountProfileCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday,@ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double socialCredits, int socialCreditsLevel, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
String id, String firstName, String middleName, String lastName, String bio, String gender, String pronouns, String location, String timeZone, DateTime? birthday,@ProfileLinkConverter() List<ProfileLink> links, DateTime? lastSeenAt, SnAccountBadge? activeBadge, int experience, int level, double socialCredits, int socialCreditsLevel, double levelingProgress, SnCloudFile? picture, SnCloudFile? background, SnVerificationMark? verification, UsernameColor? usernameColor, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
@override $SnAccountBadgeCopyWith<$Res>? get activeBadge;@override $SnCloudFileCopyWith<$Res>? get picture;@override $SnCloudFileCopyWith<$Res>? get background;@override $SnVerificationMarkCopyWith<$Res>? get verification;
|
||||
@override $SnAccountBadgeCopyWith<$Res>? get activeBadge;@override $SnCloudFileCopyWith<$Res>? get picture;@override $SnCloudFileCopyWith<$Res>? get background;@override $SnVerificationMarkCopyWith<$Res>? get verification;@override $UsernameColorCopyWith<$Res>? get usernameColor;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
@@ -968,7 +1256,7 @@ class __$SnAccountProfileCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnAccountProfile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? middleName = null,Object? lastName = null,Object? bio = null,Object? gender = null,Object? pronouns = null,Object? location = null,Object? timeZone = null,Object? birthday = freezed,Object? links = null,Object? lastSeenAt = freezed,Object? activeBadge = freezed,Object? experience = null,Object? level = null,Object? socialCredits = null,Object? socialCreditsLevel = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? verification = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? middleName = null,Object? lastName = null,Object? bio = null,Object? gender = null,Object? pronouns = null,Object? location = null,Object? timeZone = null,Object? birthday = freezed,Object? links = null,Object? lastSeenAt = freezed,Object? activeBadge = freezed,Object? experience = null,Object? level = null,Object? socialCredits = null,Object? socialCreditsLevel = null,Object? levelingProgress = null,Object? picture = freezed,Object? background = freezed,Object? verification = freezed,Object? usernameColor = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_SnAccountProfile(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||
@@ -991,7 +1279,8 @@ as int,levelingProgress: null == levelingProgress ? _self.levelingProgress : lev
|
||||
as double,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable
|
||||
as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
|
||||
as SnCloudFile?,verification: freezed == verification ? _self.verification : verification // ignore: cast_nullable_to_non_nullable
|
||||
as SnVerificationMark?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as SnVerificationMark?,usernameColor: freezed == usernameColor ? _self.usernameColor : usernameColor // ignore: cast_nullable_to_non_nullable
|
||||
as UsernameColor?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
@@ -1046,6 +1335,18 @@ $SnVerificationMarkCopyWith<$Res>? get verification {
|
||||
return $SnVerificationMarkCopyWith<$Res>(_self.verification!, (value) {
|
||||
return _then(_self.copyWith(verification: value));
|
||||
});
|
||||
}/// Create a copy of SnAccountProfile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$UsernameColorCopyWith<$Res>? get usernameColor {
|
||||
if (_self.usernameColor == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $UsernameColorCopyWith<$Res>(_self.usernameColor!, (value) {
|
||||
return _then(_self.copyWith(usernameColor: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,23 @@ _ProfileLink _$ProfileLinkFromJson(Map<String, dynamic> json) =>
|
||||
Map<String, dynamic> _$ProfileLinkToJson(_ProfileLink instance) =>
|
||||
<String, dynamic>{'name': instance.name, 'url': instance.url};
|
||||
|
||||
_UsernameColor _$UsernameColorFromJson(Map<String, dynamic> json) =>
|
||||
_UsernameColor(
|
||||
type: json['type'] as String? ?? 'plain',
|
||||
value: json['value'] as String?,
|
||||
direction: json['direction'] as String?,
|
||||
colors:
|
||||
(json['colors'] as List<dynamic>?)?.map((e) => e as String).toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UsernameColorToJson(_UsernameColor instance) =>
|
||||
<String, dynamic>{
|
||||
'type': instance.type,
|
||||
'value': instance.value,
|
||||
'direction': instance.direction,
|
||||
'colors': instance.colors,
|
||||
};
|
||||
|
||||
_SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) =>
|
||||
_SnAccountProfile(
|
||||
id: json['id'] as String,
|
||||
@@ -113,6 +130,12 @@ _SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) =>
|
||||
: SnVerificationMark.fromJson(
|
||||
json['verification'] as Map<String, dynamic>,
|
||||
),
|
||||
usernameColor:
|
||||
json['username_color'] == null
|
||||
? null
|
||||
: UsernameColor.fromJson(
|
||||
json['username_color'] as Map<String, dynamic>,
|
||||
),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt:
|
||||
@@ -144,6 +167,7 @@ Map<String, dynamic> _$SnAccountProfileToJson(_SnAccountProfile instance) =>
|
||||
'picture': instance.picture?.toJson(),
|
||||
'background': instance.background?.toJson(),
|
||||
'verification': instance.verification?.toJson(),
|
||||
'username_color': instance.usernameColor?.toJson(),
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
|
||||
16
lib/models/autocomplete_response.dart
Normal file
16
lib/models/autocomplete_response.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'autocomplete_response.freezed.dart';
|
||||
part 'autocomplete_response.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class AutocompleteSuggestion with _$AutocompleteSuggestion {
|
||||
const factory AutocompleteSuggestion({
|
||||
required String type,
|
||||
required String keyword,
|
||||
required dynamic data,
|
||||
}) = _AutocompleteSuggestion;
|
||||
|
||||
factory AutocompleteSuggestion.fromJson(Map<String, dynamic> json) =>
|
||||
_$AutocompleteSuggestionFromJson(json);
|
||||
}
|
||||
277
lib/models/autocomplete_response.freezed.dart
Normal file
277
lib/models/autocomplete_response.freezed.dart
Normal file
@@ -0,0 +1,277 @@
|
||||
// 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 'autocomplete_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$AutocompleteSuggestion {
|
||||
|
||||
String get type; String get keyword; dynamic get data;
|
||||
/// Create a copy of AutocompleteSuggestion
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$AutocompleteSuggestionCopyWith<AutocompleteSuggestion> get copyWith => _$AutocompleteSuggestionCopyWithImpl<AutocompleteSuggestion>(this as AutocompleteSuggestion, _$identity);
|
||||
|
||||
/// Serializes this AutocompleteSuggestion to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AutocompleteSuggestion&&(identical(other.type, type) || other.type == type)&&(identical(other.keyword, keyword) || other.keyword == keyword)&&const DeepCollectionEquality().equals(other.data, data));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,type,keyword,const DeepCollectionEquality().hash(data));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AutocompleteSuggestion(type: $type, keyword: $keyword, data: $data)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $AutocompleteSuggestionCopyWith<$Res> {
|
||||
factory $AutocompleteSuggestionCopyWith(AutocompleteSuggestion value, $Res Function(AutocompleteSuggestion) _then) = _$AutocompleteSuggestionCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String type, String keyword, dynamic data
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$AutocompleteSuggestionCopyWithImpl<$Res>
|
||||
implements $AutocompleteSuggestionCopyWith<$Res> {
|
||||
_$AutocompleteSuggestionCopyWithImpl(this._self, this._then);
|
||||
|
||||
final AutocompleteSuggestion _self;
|
||||
final $Res Function(AutocompleteSuggestion) _then;
|
||||
|
||||
/// Create a copy of AutocompleteSuggestion
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? keyword = null,Object? data = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,keyword: null == keyword ? _self.keyword : keyword // ignore: cast_nullable_to_non_nullable
|
||||
as String,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
|
||||
as dynamic,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [AutocompleteSuggestion].
|
||||
extension AutocompleteSuggestionPatterns on AutocompleteSuggestion {
|
||||
/// 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( _AutocompleteSuggestion value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _AutocompleteSuggestion() 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( _AutocompleteSuggestion value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _AutocompleteSuggestion():
|
||||
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( _AutocompleteSuggestion value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _AutocompleteSuggestion() 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 type, String keyword, dynamic data)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AutocompleteSuggestion() when $default != null:
|
||||
return $default(_that.type,_that.keyword,_that.data);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 type, String keyword, dynamic data) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AutocompleteSuggestion():
|
||||
return $default(_that.type,_that.keyword,_that.data);}
|
||||
}
|
||||
/// 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 type, String keyword, dynamic data)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AutocompleteSuggestion() when $default != null:
|
||||
return $default(_that.type,_that.keyword,_that.data);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _AutocompleteSuggestion implements AutocompleteSuggestion {
|
||||
const _AutocompleteSuggestion({required this.type, required this.keyword, required this.data});
|
||||
factory _AutocompleteSuggestion.fromJson(Map<String, dynamic> json) => _$AutocompleteSuggestionFromJson(json);
|
||||
|
||||
@override final String type;
|
||||
@override final String keyword;
|
||||
@override final dynamic data;
|
||||
|
||||
/// Create a copy of AutocompleteSuggestion
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$AutocompleteSuggestionCopyWith<_AutocompleteSuggestion> get copyWith => __$AutocompleteSuggestionCopyWithImpl<_AutocompleteSuggestion>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$AutocompleteSuggestionToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AutocompleteSuggestion&&(identical(other.type, type) || other.type == type)&&(identical(other.keyword, keyword) || other.keyword == keyword)&&const DeepCollectionEquality().equals(other.data, data));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,type,keyword,const DeepCollectionEquality().hash(data));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AutocompleteSuggestion(type: $type, keyword: $keyword, data: $data)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$AutocompleteSuggestionCopyWith<$Res> implements $AutocompleteSuggestionCopyWith<$Res> {
|
||||
factory _$AutocompleteSuggestionCopyWith(_AutocompleteSuggestion value, $Res Function(_AutocompleteSuggestion) _then) = __$AutocompleteSuggestionCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String type, String keyword, dynamic data
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$AutocompleteSuggestionCopyWithImpl<$Res>
|
||||
implements _$AutocompleteSuggestionCopyWith<$Res> {
|
||||
__$AutocompleteSuggestionCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _AutocompleteSuggestion _self;
|
||||
final $Res Function(_AutocompleteSuggestion) _then;
|
||||
|
||||
/// Create a copy of AutocompleteSuggestion
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? keyword = null,Object? data = freezed,}) {
|
||||
return _then(_AutocompleteSuggestion(
|
||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,keyword: null == keyword ? _self.keyword : keyword // ignore: cast_nullable_to_non_nullable
|
||||
as String,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
|
||||
as dynamic,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
23
lib/models/autocomplete_response.g.dart
Normal file
23
lib/models/autocomplete_response.g.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'autocomplete_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_AutocompleteSuggestion _$AutocompleteSuggestionFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _AutocompleteSuggestion(
|
||||
type: json['type'] as String,
|
||||
keyword: json['keyword'] as String,
|
||||
data: json['data'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AutocompleteSuggestionToJson(
|
||||
_AutocompleteSuggestion instance,
|
||||
) => <String, dynamic>{
|
||||
'type': instance.type,
|
||||
'keyword': instance.keyword,
|
||||
'data': instance.data,
|
||||
};
|
||||
26
lib/models/heatmap.dart
Normal file
26
lib/models/heatmap.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'heatmap.freezed.dart';
|
||||
part 'heatmap.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class SnHeatmap with _$SnHeatmap {
|
||||
const factory SnHeatmap({
|
||||
required String unit,
|
||||
@JsonKey(name: 'period_start') required DateTime periodStart,
|
||||
@JsonKey(name: 'period_end') required DateTime periodEnd,
|
||||
required List<SnHeatmapItem> items,
|
||||
}) = _SnHeatmap;
|
||||
|
||||
factory SnHeatmap.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnHeatmapFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnHeatmapItem with _$SnHeatmapItem {
|
||||
const factory SnHeatmapItem({required DateTime date, required int count}) =
|
||||
_SnHeatmapItem;
|
||||
|
||||
factory SnHeatmapItem.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnHeatmapItemFromJson(json);
|
||||
}
|
||||
546
lib/models/heatmap.freezed.dart
Normal file
546
lib/models/heatmap.freezed.dart
Normal file
@@ -0,0 +1,546 @@
|
||||
// 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 'heatmap.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnHeatmap {
|
||||
|
||||
String get unit;@JsonKey(name: 'period_start') DateTime get periodStart;@JsonKey(name: 'period_end') DateTime get periodEnd; List<SnHeatmapItem> get items;
|
||||
/// Create a copy of SnHeatmap
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnHeatmapCopyWith<SnHeatmap> get copyWith => _$SnHeatmapCopyWithImpl<SnHeatmap>(this as SnHeatmap, _$identity);
|
||||
|
||||
/// Serializes this SnHeatmap to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnHeatmap&&(identical(other.unit, unit) || other.unit == unit)&&(identical(other.periodStart, periodStart) || other.periodStart == periodStart)&&(identical(other.periodEnd, periodEnd) || other.periodEnd == periodEnd)&&const DeepCollectionEquality().equals(other.items, items));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,unit,periodStart,periodEnd,const DeepCollectionEquality().hash(items));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnHeatmap(unit: $unit, periodStart: $periodStart, periodEnd: $periodEnd, items: $items)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $SnHeatmapCopyWith<$Res> {
|
||||
factory $SnHeatmapCopyWith(SnHeatmap value, $Res Function(SnHeatmap) _then) = _$SnHeatmapCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String unit,@JsonKey(name: 'period_start') DateTime periodStart,@JsonKey(name: 'period_end') DateTime periodEnd, List<SnHeatmapItem> items
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$SnHeatmapCopyWithImpl<$Res>
|
||||
implements $SnHeatmapCopyWith<$Res> {
|
||||
_$SnHeatmapCopyWithImpl(this._self, this._then);
|
||||
|
||||
final SnHeatmap _self;
|
||||
final $Res Function(SnHeatmap) _then;
|
||||
|
||||
/// Create a copy of SnHeatmap
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? unit = null,Object? periodStart = null,Object? periodEnd = null,Object? items = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
unit: null == unit ? _self.unit : unit // ignore: cast_nullable_to_non_nullable
|
||||
as String,periodStart: null == periodStart ? _self.periodStart : periodStart // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,periodEnd: null == periodEnd ? _self.periodEnd : periodEnd // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnHeatmapItem>,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [SnHeatmap].
|
||||
extension SnHeatmapPatterns on SnHeatmap {
|
||||
/// 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( _SnHeatmap value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmap() 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( _SnHeatmap value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmap():
|
||||
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( _SnHeatmap value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmap() 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 unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List<SnHeatmapItem> items)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmap() when $default != null:
|
||||
return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);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 unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List<SnHeatmapItem> items) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmap():
|
||||
return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);}
|
||||
}
|
||||
/// 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 unit, @JsonKey(name: 'period_start') DateTime periodStart, @JsonKey(name: 'period_end') DateTime periodEnd, List<SnHeatmapItem> items)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmap() when $default != null:
|
||||
return $default(_that.unit,_that.periodStart,_that.periodEnd,_that.items);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _SnHeatmap implements SnHeatmap {
|
||||
const _SnHeatmap({required this.unit, @JsonKey(name: 'period_start') required this.periodStart, @JsonKey(name: 'period_end') required this.periodEnd, required final List<SnHeatmapItem> items}): _items = items;
|
||||
factory _SnHeatmap.fromJson(Map<String, dynamic> json) => _$SnHeatmapFromJson(json);
|
||||
|
||||
@override final String unit;
|
||||
@override@JsonKey(name: 'period_start') final DateTime periodStart;
|
||||
@override@JsonKey(name: 'period_end') final DateTime periodEnd;
|
||||
final List<SnHeatmapItem> _items;
|
||||
@override List<SnHeatmapItem> get items {
|
||||
if (_items is EqualUnmodifiableListView) return _items;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_items);
|
||||
}
|
||||
|
||||
|
||||
/// Create a copy of SnHeatmap
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$SnHeatmapCopyWith<_SnHeatmap> get copyWith => __$SnHeatmapCopyWithImpl<_SnHeatmap>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$SnHeatmapToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnHeatmap&&(identical(other.unit, unit) || other.unit == unit)&&(identical(other.periodStart, periodStart) || other.periodStart == periodStart)&&(identical(other.periodEnd, periodEnd) || other.periodEnd == periodEnd)&&const DeepCollectionEquality().equals(other._items, _items));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,unit,periodStart,periodEnd,const DeepCollectionEquality().hash(_items));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnHeatmap(unit: $unit, periodStart: $periodStart, periodEnd: $periodEnd, items: $items)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$SnHeatmapCopyWith<$Res> implements $SnHeatmapCopyWith<$Res> {
|
||||
factory _$SnHeatmapCopyWith(_SnHeatmap value, $Res Function(_SnHeatmap) _then) = __$SnHeatmapCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String unit,@JsonKey(name: 'period_start') DateTime periodStart,@JsonKey(name: 'period_end') DateTime periodEnd, List<SnHeatmapItem> items
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$SnHeatmapCopyWithImpl<$Res>
|
||||
implements _$SnHeatmapCopyWith<$Res> {
|
||||
__$SnHeatmapCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _SnHeatmap _self;
|
||||
final $Res Function(_SnHeatmap) _then;
|
||||
|
||||
/// Create a copy of SnHeatmap
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? unit = null,Object? periodStart = null,Object? periodEnd = null,Object? items = null,}) {
|
||||
return _then(_SnHeatmap(
|
||||
unit: null == unit ? _self.unit : unit // ignore: cast_nullable_to_non_nullable
|
||||
as String,periodStart: null == periodStart ? _self.periodStart : periodStart // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,periodEnd: null == periodEnd ? _self.periodEnd : periodEnd // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnHeatmapItem>,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnHeatmapItem {
|
||||
|
||||
DateTime get date; int get count;
|
||||
/// Create a copy of SnHeatmapItem
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnHeatmapItemCopyWith<SnHeatmapItem> get copyWith => _$SnHeatmapItemCopyWithImpl<SnHeatmapItem>(this as SnHeatmapItem, _$identity);
|
||||
|
||||
/// Serializes this SnHeatmapItem to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnHeatmapItem&&(identical(other.date, date) || other.date == date)&&(identical(other.count, count) || other.count == count));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,date,count);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnHeatmapItem(date: $date, count: $count)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $SnHeatmapItemCopyWith<$Res> {
|
||||
factory $SnHeatmapItemCopyWith(SnHeatmapItem value, $Res Function(SnHeatmapItem) _then) = _$SnHeatmapItemCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
DateTime date, int count
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$SnHeatmapItemCopyWithImpl<$Res>
|
||||
implements $SnHeatmapItemCopyWith<$Res> {
|
||||
_$SnHeatmapItemCopyWithImpl(this._self, this._then);
|
||||
|
||||
final SnHeatmapItem _self;
|
||||
final $Res Function(SnHeatmapItem) _then;
|
||||
|
||||
/// Create a copy of SnHeatmapItem
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? date = null,Object? count = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,count: null == count ? _self.count : count // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [SnHeatmapItem].
|
||||
extension SnHeatmapItemPatterns on SnHeatmapItem {
|
||||
/// 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( _SnHeatmapItem value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmapItem() 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( _SnHeatmapItem value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmapItem():
|
||||
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( _SnHeatmapItem value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmapItem() 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( DateTime date, int count)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmapItem() when $default != null:
|
||||
return $default(_that.date,_that.count);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( DateTime date, int count) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmapItem():
|
||||
return $default(_that.date,_that.count);}
|
||||
}
|
||||
/// 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( DateTime date, int count)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnHeatmapItem() when $default != null:
|
||||
return $default(_that.date,_that.count);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _SnHeatmapItem implements SnHeatmapItem {
|
||||
const _SnHeatmapItem({required this.date, required this.count});
|
||||
factory _SnHeatmapItem.fromJson(Map<String, dynamic> json) => _$SnHeatmapItemFromJson(json);
|
||||
|
||||
@override final DateTime date;
|
||||
@override final int count;
|
||||
|
||||
/// Create a copy of SnHeatmapItem
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$SnHeatmapItemCopyWith<_SnHeatmapItem> get copyWith => __$SnHeatmapItemCopyWithImpl<_SnHeatmapItem>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$SnHeatmapItemToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnHeatmapItem&&(identical(other.date, date) || other.date == date)&&(identical(other.count, count) || other.count == count));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,date,count);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnHeatmapItem(date: $date, count: $count)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$SnHeatmapItemCopyWith<$Res> implements $SnHeatmapItemCopyWith<$Res> {
|
||||
factory _$SnHeatmapItemCopyWith(_SnHeatmapItem value, $Res Function(_SnHeatmapItem) _then) = __$SnHeatmapItemCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
DateTime date, int count
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$SnHeatmapItemCopyWithImpl<$Res>
|
||||
implements _$SnHeatmapItemCopyWith<$Res> {
|
||||
__$SnHeatmapItemCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _SnHeatmapItem _self;
|
||||
final $Res Function(_SnHeatmapItem) _then;
|
||||
|
||||
/// Create a copy of SnHeatmapItem
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? date = null,Object? count = null,}) {
|
||||
return _then(_SnHeatmapItem(
|
||||
date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,count: null == count ? _self.count : count // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
37
lib/models/heatmap.g.dart
Normal file
37
lib/models/heatmap.g.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'heatmap.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_SnHeatmap _$SnHeatmapFromJson(Map<String, dynamic> json) => _SnHeatmap(
|
||||
unit: json['unit'] as String,
|
||||
periodStart: DateTime.parse(json['period_start'] as String),
|
||||
periodEnd: DateTime.parse(json['period_end'] as String),
|
||||
items:
|
||||
(json['items'] as List<dynamic>)
|
||||
.map((e) => SnHeatmapItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnHeatmapToJson(_SnHeatmap instance) =>
|
||||
<String, dynamic>{
|
||||
'unit': instance.unit,
|
||||
'period_start': instance.periodStart.toIso8601String(),
|
||||
'period_end': instance.periodEnd.toIso8601String(),
|
||||
'items': instance.items.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
_SnHeatmapItem _$SnHeatmapItemFromJson(Map<String, dynamic> json) =>
|
||||
_SnHeatmapItem(
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
count: (json['count'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnHeatmapItemToJson(_SnHeatmapItem instance) =>
|
||||
<String, dynamic>{
|
||||
'date': instance.date.toIso8601String(),
|
||||
'count': instance.count,
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/post_category.dart';
|
||||
import 'package:island/models/post_tag.dart';
|
||||
@@ -142,3 +143,21 @@ sealed class SnPostAward with _$SnPostAward {
|
||||
factory SnPostAward.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnPostAwardFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnPostReaction with _$SnPostReaction {
|
||||
const factory SnPostReaction({
|
||||
required String id,
|
||||
required String symbol,
|
||||
required int attitude,
|
||||
required String postId,
|
||||
required String accountId,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
@Default(null) SnAccount? account,
|
||||
DateTime? deletedAt,
|
||||
}) = _SnPostReaction;
|
||||
|
||||
factory SnPostReaction.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnPostReactionFromJson(json);
|
||||
}
|
||||
|
||||
@@ -1900,4 +1900,309 @@ as DateTime?,
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnPostReaction {
|
||||
|
||||
String get id; String get symbol; int get attitude; String get postId; String get accountId; DateTime get createdAt; DateTime get updatedAt; SnAccount? get account; DateTime? get deletedAt;
|
||||
/// Create a copy of SnPostReaction
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnPostReactionCopyWith<SnPostReaction> get copyWith => _$SnPostReactionCopyWithImpl<SnPostReaction>(this as SnPostReaction, _$identity);
|
||||
|
||||
/// Serializes this SnPostReaction to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPostReaction&&(identical(other.id, id) || other.id == id)&&(identical(other.symbol, symbol) || other.symbol == symbol)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.postId, postId) || other.postId == postId)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.account, account) || other.account == account)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,symbol,attitude,postId,accountId,createdAt,updatedAt,account,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnPostReaction(id: $id, symbol: $symbol, attitude: $attitude, postId: $postId, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, account: $account, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $SnPostReactionCopyWith<$Res> {
|
||||
factory $SnPostReactionCopyWith(SnPostReaction value, $Res Function(SnPostReaction) _then) = _$SnPostReactionCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String symbol, int attitude, String postId, String accountId, DateTime createdAt, DateTime updatedAt, SnAccount? account, DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
$SnAccountCopyWith<$Res>? get account;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$SnPostReactionCopyWithImpl<$Res>
|
||||
implements $SnPostReactionCopyWith<$Res> {
|
||||
_$SnPostReactionCopyWithImpl(this._self, this._then);
|
||||
|
||||
final SnPostReaction _self;
|
||||
final $Res Function(SnPostReaction) _then;
|
||||
|
||||
/// Create a copy of SnPostReaction
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? symbol = null,Object? attitude = null,Object? postId = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? account = freezed,Object? deletedAt = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,symbol: null == symbol ? _self.symbol : symbol // ignore: cast_nullable_to_non_nullable
|
||||
as String,attitude: null == attitude ? _self.attitude : attitude // ignore: cast_nullable_to_non_nullable
|
||||
as int,postId: null == postId ? _self.postId : postId // 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,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
));
|
||||
}
|
||||
/// Create a copy of SnPostReaction
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnAccountCopyWith<$Res>? get account {
|
||||
if (_self.account == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
|
||||
return _then(_self.copyWith(account: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [SnPostReaction].
|
||||
extension SnPostReactionPatterns on SnPostReaction {
|
||||
/// 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( _SnPostReaction value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnPostReaction() 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( _SnPostReaction value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnPostReaction():
|
||||
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( _SnPostReaction value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnPostReaction() 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 symbol, int attitude, String postId, String accountId, DateTime createdAt, DateTime updatedAt, SnAccount? account, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnPostReaction() when $default != null:
|
||||
return $default(_that.id,_that.symbol,_that.attitude,_that.postId,_that.accountId,_that.createdAt,_that.updatedAt,_that.account,_that.deletedAt);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String symbol, int attitude, String postId, String accountId, DateTime createdAt, DateTime updatedAt, SnAccount? account, DateTime? deletedAt) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnPostReaction():
|
||||
return $default(_that.id,_that.symbol,_that.attitude,_that.postId,_that.accountId,_that.createdAt,_that.updatedAt,_that.account,_that.deletedAt);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String symbol, int attitude, String postId, String accountId, DateTime createdAt, DateTime updatedAt, SnAccount? account, DateTime? deletedAt)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnPostReaction() when $default != null:
|
||||
return $default(_that.id,_that.symbol,_that.attitude,_that.postId,_that.accountId,_that.createdAt,_that.updatedAt,_that.account,_that.deletedAt);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _SnPostReaction implements SnPostReaction {
|
||||
const _SnPostReaction({required this.id, required this.symbol, required this.attitude, required this.postId, required this.accountId, required this.createdAt, required this.updatedAt, this.account = null, this.deletedAt});
|
||||
factory _SnPostReaction.fromJson(Map<String, dynamic> json) => _$SnPostReactionFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override final String symbol;
|
||||
@override final int attitude;
|
||||
@override final String postId;
|
||||
@override final String accountId;
|
||||
@override final DateTime createdAt;
|
||||
@override final DateTime updatedAt;
|
||||
@override@JsonKey() final SnAccount? account;
|
||||
@override final DateTime? deletedAt;
|
||||
|
||||
/// Create a copy of SnPostReaction
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$SnPostReactionCopyWith<_SnPostReaction> get copyWith => __$SnPostReactionCopyWithImpl<_SnPostReaction>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$SnPostReactionToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPostReaction&&(identical(other.id, id) || other.id == id)&&(identical(other.symbol, symbol) || other.symbol == symbol)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.postId, postId) || other.postId == postId)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.account, account) || other.account == account)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,symbol,attitude,postId,accountId,createdAt,updatedAt,account,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnPostReaction(id: $id, symbol: $symbol, attitude: $attitude, postId: $postId, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, account: $account, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$SnPostReactionCopyWith<$Res> implements $SnPostReactionCopyWith<$Res> {
|
||||
factory _$SnPostReactionCopyWith(_SnPostReaction value, $Res Function(_SnPostReaction) _then) = __$SnPostReactionCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String symbol, int attitude, String postId, String accountId, DateTime createdAt, DateTime updatedAt, SnAccount? account, DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
@override $SnAccountCopyWith<$Res>? get account;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$SnPostReactionCopyWithImpl<$Res>
|
||||
implements _$SnPostReactionCopyWith<$Res> {
|
||||
__$SnPostReactionCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _SnPostReaction _self;
|
||||
final $Res Function(_SnPostReaction) _then;
|
||||
|
||||
/// Create a copy of SnPostReaction
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? symbol = null,Object? attitude = null,Object? postId = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? account = freezed,Object? deletedAt = freezed,}) {
|
||||
return _then(_SnPostReaction(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,symbol: null == symbol ? _self.symbol : symbol // ignore: cast_nullable_to_non_nullable
|
||||
as String,attitude: null == attitude ? _self.attitude : attitude // ignore: cast_nullable_to_non_nullable
|
||||
as int,postId: null == postId ? _self.postId : postId // 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,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of SnPostReaction
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnAccountCopyWith<$Res>? get account {
|
||||
if (_self.account == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
|
||||
return _then(_self.copyWith(account: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
|
||||
@@ -230,3 +230,35 @@ Map<String, dynamic> _$SnPostAwardToJson(_SnPostAward instance) =>
|
||||
'updated_at': instance.updatedAt?.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_SnPostReaction _$SnPostReactionFromJson(Map<String, dynamic> json) =>
|
||||
_SnPostReaction(
|
||||
id: json['id'] as String,
|
||||
symbol: json['symbol'] as String,
|
||||
attitude: (json['attitude'] as num).toInt(),
|
||||
postId: json['post_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),
|
||||
account:
|
||||
json['account'] == null
|
||||
? null
|
||||
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
|
||||
deletedAt:
|
||||
json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnPostReactionToJson(_SnPostReaction instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'symbol': instance.symbol,
|
||||
'attitude': instance.attitude,
|
||||
'post_id': instance.postId,
|
||||
'account_id': instance.accountId,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'account': instance.account?.toJson(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
@@ -312,7 +312,7 @@ class ServerStateNotifier extends StateNotifier<ServerState> {
|
||||
: super(ServerState(status: 'Server not started'));
|
||||
|
||||
Future<void> start() async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS && !kIsWeb) {
|
||||
if (!kIsWeb && !Platform.isAndroid && !Platform.isIOS) {
|
||||
try {
|
||||
await server.start();
|
||||
state = state.copyWith(status: 'Server running');
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'call.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$callNotifierHash() => r'438b516c8b786f47495a3435e22b400cd0ca2772';
|
||||
String _$callNotifierHash() => r'a8ca3f625c0db3ad9992033ae70864ce15efc281';
|
||||
|
||||
/// See also [CallNotifier].
|
||||
@ProviderFor(CallNotifier)
|
||||
|
||||
@@ -39,6 +39,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
bool _hasMore = true;
|
||||
bool _isSyncing = false;
|
||||
bool _isJumping = false;
|
||||
bool _isUpdatingState = false;
|
||||
DateTime? _lastPauseTime;
|
||||
|
||||
@override
|
||||
@@ -92,17 +93,43 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
return messages;
|
||||
}
|
||||
|
||||
Future<void> _updateStateSafely(List<LocalChatMessage> messages) async {
|
||||
if (_isUpdatingState) {
|
||||
talker.log('State update already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
_isUpdatingState = true;
|
||||
try {
|
||||
// Ensure messages are properly sorted and deduplicated
|
||||
final sortedMessages = _sortMessages(messages);
|
||||
final uniqueMessages = <LocalChatMessage>[];
|
||||
final seenIds = <String>{};
|
||||
for (final message in sortedMessages) {
|
||||
if (seenIds.add(message.id)) {
|
||||
uniqueMessages.add(message);
|
||||
}
|
||||
}
|
||||
state = AsyncValue.data(uniqueMessages);
|
||||
} finally {
|
||||
_isUpdatingState = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<LocalChatMessage>> _getCachedMessages({
|
||||
int offset = 0,
|
||||
int take = 20,
|
||||
String? searchQuery,
|
||||
bool? withLinks,
|
||||
bool? withAttachments,
|
||||
}) async {
|
||||
talker.log('Getting cached messages from offset $offset, take $take');
|
||||
final List<LocalChatMessage> dbMessages;
|
||||
if (_searchQuery != null && _searchQuery!.isNotEmpty) {
|
||||
|
||||
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||
dbMessages = await _database.searchMessages(
|
||||
_roomId,
|
||||
_searchQuery ?? '',
|
||||
withAttachments: _withAttachments,
|
||||
searchQuery,
|
||||
withAttachments: withAttachments,
|
||||
);
|
||||
} else {
|
||||
final chatMessagesFromDb = await _database.getMessagesForRoom(
|
||||
@@ -116,11 +143,18 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
List<LocalChatMessage> filteredMessages = dbMessages;
|
||||
|
||||
if (_withLinks == true) {
|
||||
if (withLinks == true) {
|
||||
filteredMessages =
|
||||
filteredMessages.where((msg) => _hasLink(msg)).toList();
|
||||
}
|
||||
|
||||
if (withAttachments == true) {
|
||||
filteredMessages =
|
||||
filteredMessages
|
||||
.where((msg) => msg.toRemoteMessage().attachments.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final dbLocalMessages = filteredMessages;
|
||||
|
||||
// Always ensure unique messages to prevent duplicate keys
|
||||
@@ -154,6 +188,51 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
return uniqueMessages;
|
||||
}
|
||||
|
||||
/// Get all messages without search filters for jump operations
|
||||
Future<List<LocalChatMessage>> _getAllMessagesForJump({
|
||||
int offset = 0,
|
||||
int take = 20,
|
||||
}) async {
|
||||
talker.log('Getting all messages for jump from offset $offset, take $take');
|
||||
final chatMessagesFromDb = await _database.getMessagesForRoom(
|
||||
_roomId,
|
||||
offset: offset,
|
||||
limit: take,
|
||||
);
|
||||
final dbMessages =
|
||||
chatMessagesFromDb.map(_database.companionToMessage).toList();
|
||||
|
||||
// Always ensure unique messages to prevent duplicate keys
|
||||
final uniqueMessages = <LocalChatMessage>[];
|
||||
final seenIds = <String>{};
|
||||
for (final message in dbMessages) {
|
||||
if (seenIds.add(message.id)) {
|
||||
uniqueMessages.add(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (offset == 0) {
|
||||
final pendingForRoom =
|
||||
_pendingMessages.values
|
||||
.where((msg) => msg.roomId == _roomId)
|
||||
.toList();
|
||||
|
||||
final allMessages = [...pendingForRoom, ...uniqueMessages];
|
||||
_sortMessages(allMessages);
|
||||
|
||||
final finalUniqueMessages = <LocalChatMessage>[];
|
||||
final finalSeenIds = <String>{};
|
||||
for (final message in allMessages) {
|
||||
if (finalSeenIds.add(message.id)) {
|
||||
finalUniqueMessages.add(message);
|
||||
}
|
||||
}
|
||||
return finalUniqueMessages;
|
||||
}
|
||||
|
||||
return uniqueMessages;
|
||||
}
|
||||
|
||||
Future<List<LocalChatMessage>> _fetchAndCacheMessages({
|
||||
int offset = 0,
|
||||
int take = 20,
|
||||
@@ -241,16 +320,6 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
final response = MessageSyncResponse.fromJson(resp.data);
|
||||
talker.log('Sync response: ${response.messages.length} changes');
|
||||
for (final message in response.messages) {
|
||||
switch (message.type) {
|
||||
case "messages.update":
|
||||
case "messages.update.links":
|
||||
await receiveMessageUpdate(message);
|
||||
break;
|
||||
case "messages.delete":
|
||||
await receiveMessageDeletion(message.id.toString());
|
||||
break;
|
||||
}
|
||||
// Still need receive the message to show the history actions
|
||||
await receiveMessage(message);
|
||||
}
|
||||
} catch (err, stackTrace) {
|
||||
@@ -286,6 +355,9 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
final localMessages = await _getCachedMessages(
|
||||
offset: offset,
|
||||
take: take,
|
||||
searchQuery: _searchQuery,
|
||||
withLinks: _withLinks,
|
||||
withAttachments: _withAttachments,
|
||||
);
|
||||
|
||||
if (localMessages.isNotEmpty) {
|
||||
@@ -301,6 +373,9 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
final localMessages = await _getCachedMessages(
|
||||
offset: offset,
|
||||
take: take,
|
||||
searchQuery: _searchQuery,
|
||||
withLinks: _withLinks,
|
||||
withAttachments: _withAttachments,
|
||||
);
|
||||
|
||||
if (localMessages.isNotEmpty) {
|
||||
@@ -316,7 +391,13 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
syncMessages();
|
||||
}
|
||||
|
||||
final messages = await _getCachedMessages(offset: 0, take: _pageSize);
|
||||
final messages = await _getCachedMessages(
|
||||
offset: 0,
|
||||
take: _pageSize,
|
||||
searchQuery: _searchQuery,
|
||||
withLinks: _withLinks,
|
||||
withAttachments: _withAttachments,
|
||||
);
|
||||
|
||||
_hasMore = messages.length == _pageSize;
|
||||
|
||||
@@ -548,6 +629,13 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
Future<void> receiveMessage(SnChatMessage remoteMessage) async {
|
||||
if (remoteMessage.chatRoomId != _roomId) return;
|
||||
|
||||
// Block message receiving during jumps to prevent list resets
|
||||
if (_isJumping) {
|
||||
talker.log('Blocking message receive during jump operation');
|
||||
return;
|
||||
}
|
||||
|
||||
talker.log('Received new message ${remoteMessage.id}');
|
||||
|
||||
final localMessage = LocalChatMessage.fromRemoteMessage(
|
||||
@@ -593,6 +681,13 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
|
||||
if (remoteMessage.chatRoomId != _roomId) return;
|
||||
|
||||
// Block message updates during jumps to prevent list resets
|
||||
if (_isJumping) {
|
||||
talker.log('Blocking message update during jump operation');
|
||||
return;
|
||||
}
|
||||
|
||||
talker.log('Received message update ${remoteMessage.id}');
|
||||
|
||||
final targetId = remoteMessage.meta['message_id'] ?? remoteMessage.id;
|
||||
@@ -600,6 +695,8 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
remoteMessage.copyWith(
|
||||
id: targetId,
|
||||
meta: Map.of(remoteMessage.meta)..remove('message_id'),
|
||||
type: 'text',
|
||||
editedAt: remoteMessage.createdAt,
|
||||
),
|
||||
MessageStatus.sent,
|
||||
);
|
||||
@@ -616,6 +713,12 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
Future<void> receiveMessageDeletion(String messageId) async {
|
||||
// Block message deletions during jumps to prevent list resets
|
||||
if (_isJumping) {
|
||||
talker.log('Blocking message deletion during jump operation');
|
||||
return;
|
||||
}
|
||||
|
||||
talker.log('Received message deletion $messageId');
|
||||
_pendingMessages.remove(messageId);
|
||||
|
||||
@@ -654,13 +757,34 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
Future<void> deleteMessage(String messageId) async {
|
||||
talker.log('Deleting message $messageId');
|
||||
|
||||
// Fetch message to check its status before attempting server delete
|
||||
final message = await fetchMessageById(messageId);
|
||||
if (message == null) {
|
||||
talker.log('Message $messageId not found for deletion');
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip server delete for failed messages (never successfully sent)
|
||||
if (message.status == MessageStatus.failed) {
|
||||
talker.log('Skipping server delete for failed message $messageId');
|
||||
// For failed messages, remove them completely from the active list
|
||||
_pendingMessages.remove(messageId);
|
||||
await _database.deleteMessage(messageId);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final newMessages =
|
||||
currentMessages.where((m) => m.id != messageId).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId');
|
||||
await receiveMessageDeletion(messageId);
|
||||
} catch (err, stackTrace) {
|
||||
talker.log(
|
||||
'Error deleting message $messageId',
|
||||
|
||||
exception: err,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
@@ -668,11 +792,78 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void searchMessages(String query, {bool? withLinks, bool? withAttachments}) {
|
||||
/// Get search results without updating shared state
|
||||
Future<List<LocalChatMessage>> getSearchResults(
|
||||
String query, {
|
||||
bool? withLinks,
|
||||
bool? withAttachments,
|
||||
}) async {
|
||||
final trimmedQuery = query.trim();
|
||||
final hasFilters = [withLinks, withAttachments].any((e) => e == true);
|
||||
|
||||
if (trimmedQuery.isEmpty && !hasFilters) {
|
||||
return [];
|
||||
}
|
||||
|
||||
talker.log(
|
||||
'Getting search results for query: $trimmedQuery, filters: links=$withLinks, attachments=$withAttachments',
|
||||
);
|
||||
|
||||
try {
|
||||
// When filtering without query, get more messages to ensure we find all matches
|
||||
final take = (trimmedQuery.isEmpty && hasFilters) ? 1000 : 50;
|
||||
final messages = await _getCachedMessages(
|
||||
offset: 0,
|
||||
take: take,
|
||||
searchQuery: trimmedQuery.isNotEmpty ? trimmedQuery : null,
|
||||
withLinks: withLinks,
|
||||
withAttachments: withAttachments,
|
||||
); // Limit initial search results
|
||||
return messages;
|
||||
} catch (e, stackTrace) {
|
||||
talker.log(
|
||||
'Error getting search results',
|
||||
exception: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> searchMessages(
|
||||
String query, {
|
||||
bool? withLinks,
|
||||
bool? withAttachments,
|
||||
}) async {
|
||||
_searchQuery = query.trim();
|
||||
_withLinks = withLinks;
|
||||
_withAttachments = withAttachments;
|
||||
loadInitial();
|
||||
|
||||
if (_searchQuery!.isEmpty) {
|
||||
state = AsyncValue.data([]);
|
||||
return;
|
||||
}
|
||||
|
||||
talker.log('Searching messages with query: $_searchQuery');
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
final messages = await _getCachedMessages(
|
||||
offset: 0,
|
||||
take: 50,
|
||||
searchQuery: _searchQuery,
|
||||
withLinks: _withLinks,
|
||||
withAttachments: _withAttachments,
|
||||
); // Limit initial search results
|
||||
state = AsyncValue.data(messages);
|
||||
} catch (e, stackTrace) {
|
||||
talker.log(
|
||||
'Error searching messages',
|
||||
exception: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
state = AsyncValue.error(e, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
void clearSearch() {
|
||||
@@ -717,6 +908,9 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
_isJumping = true;
|
||||
|
||||
// Clear flashing messages when starting a new jump
|
||||
ref.read(flashingMessagesProvider.notifier).state = {};
|
||||
|
||||
try {
|
||||
talker.log('Fetching message $messageId');
|
||||
final message = await fetchMessageById(messageId);
|
||||
@@ -739,10 +933,11 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
talker.log(
|
||||
'Message $messageId not in current state, loading messages around it',
|
||||
'Message $messageId not in current state, calculating position and loading messages around it',
|
||||
);
|
||||
|
||||
// Count messages newer than this one
|
||||
// Count messages newer than the target message to calculate optimal offset
|
||||
// Use full message list (not filtered by search) for accurate position calculation
|
||||
final query = _database.customSelect(
|
||||
'SELECT COUNT(*) as count FROM chat_messages WHERE room_id = ? AND created_at > ?',
|
||||
variables: [
|
||||
@@ -754,13 +949,17 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
final result = await query.getSingle();
|
||||
final newerCount = result.read<int>('count');
|
||||
|
||||
// Load messages around this position
|
||||
// Calculate offset to position target message in the middle of the loaded chunk
|
||||
const chunkSize = 100; // Load 100 messages around the target
|
||||
final offset =
|
||||
(newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt();
|
||||
talker.log('Loading messages with offset $offset, take $_pageSize');
|
||||
final loadedMessages = await _getCachedMessages(
|
||||
(newerCount - chunkSize ~/ 2).clamp(0, double.infinity).toInt();
|
||||
talker.log(
|
||||
'Calculated offset $offset for target message (newer: $newerCount, chunk: $chunkSize)',
|
||||
);
|
||||
// Use full message list (not filtered by search) for jump operations
|
||||
final loadedMessages = await _getAllMessagesForJump(
|
||||
offset: offset,
|
||||
take: _pageSize,
|
||||
take: chunkSize,
|
||||
);
|
||||
|
||||
// Check if loaded messages are already in current state
|
||||
@@ -772,7 +971,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
);
|
||||
|
||||
if (newMessages.isNotEmpty) {
|
||||
// Merge with current messages
|
||||
// Merge with current messages more safely
|
||||
final allMessages = [...currentMessages, ...newMessages];
|
||||
final uniqueMessages = <LocalChatMessage>[];
|
||||
final seenIds = <String>{};
|
||||
@@ -781,17 +980,37 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
uniqueMessages.add(message);
|
||||
}
|
||||
}
|
||||
_sortMessages(uniqueMessages);
|
||||
state = AsyncValue.data(uniqueMessages);
|
||||
await _updateStateSafely(uniqueMessages);
|
||||
talker.log(
|
||||
'Updated state with ${uniqueMessages.length} total messages',
|
||||
);
|
||||
}
|
||||
|
||||
// Wait a bit for the UI to rebuild with new messages
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final finalIndex = (state.value ?? []).indexWhere(
|
||||
(m) => m.id == messageId,
|
||||
);
|
||||
talker.log('Final index for message $messageId is $finalIndex');
|
||||
|
||||
// Verify the message is actually in the list before returning
|
||||
if (finalIndex == -1) {
|
||||
talker.log(
|
||||
'Message $messageId still not found after loading, trying direct fetch',
|
||||
);
|
||||
// Try to fetch and add the specific message if it's still not found
|
||||
final directMessage = await fetchMessageById(messageId);
|
||||
if (directMessage != null) {
|
||||
final currentList = state.value ?? [];
|
||||
final updatedList = [...currentList, directMessage];
|
||||
await _updateStateSafely(updatedList);
|
||||
final newIndex = updatedList.indexWhere((m) => m.id == messageId);
|
||||
talker.log('Added message directly, new index: $newIndex');
|
||||
return newIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return finalIndex;
|
||||
} finally {
|
||||
_isJumping = false;
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$messagesNotifierHash() => r'70acac63c720987d8b1688500e3735f1c2d16fdc';
|
||||
String _$messagesNotifierHash() => r'6adefd9152cdd686c2a863964993f24c42d405b5';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
||||
@@ -34,6 +34,7 @@ const kAppDefaultPoolId = 'app_default_pool_id';
|
||||
const kAppMessageDisplayStyle = 'app_message_display_style';
|
||||
const kAppThemeMode = 'app_theme_mode';
|
||||
const kMaterialYouToggleStoreKey = 'app_theme_material_you';
|
||||
const kAppDisableAnimation = 'app_disable_animation';
|
||||
const kFeaturedPostsCollapsedId =
|
||||
'featured_posts_collapsed_id'; // Key for storing the ID of the collapsed featured post
|
||||
|
||||
@@ -96,6 +97,7 @@ sealed class AppSettings with _$AppSettings {
|
||||
required String messageDisplayStyle,
|
||||
required String? themeMode,
|
||||
required bool useMaterial3,
|
||||
required bool disableAnimation,
|
||||
}) = _AppSettings;
|
||||
}
|
||||
|
||||
@@ -122,6 +124,7 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
|
||||
messageDisplayStyle: prefs.getString(kAppMessageDisplayStyle) ?? 'bubble',
|
||||
themeMode: prefs.getString(kAppThemeMode) ?? 'system',
|
||||
useMaterial3: prefs.getBool(kMaterialYouToggleStoreKey) ?? true,
|
||||
disableAnimation: prefs.getBool(kAppDisableAnimation) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,6 +276,12 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
|
||||
}
|
||||
state = state.copyWith(customColors: value);
|
||||
}
|
||||
|
||||
void setDisableAnimation(bool value) {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
prefs.setBool(kAppDisableAnimation, value);
|
||||
state = state.copyWith(disableAnimation: value);
|
||||
}
|
||||
}
|
||||
|
||||
final updateInfoProvider =
|
||||
|
||||
@@ -290,7 +290,7 @@ mixin _$AppSettings {
|
||||
ThemeColors? get customColors; Size? get windowSize;// The window size for desktop platforms
|
||||
double get windowOpacity;// The window opacity for desktop platforms
|
||||
double get cardTransparency;// The card background opacity
|
||||
String? get defaultPoolId; String get messageDisplayStyle; String? get themeMode; bool get useMaterial3;
|
||||
String? get defaultPoolId; String get messageDisplayStyle; String? get themeMode; bool get useMaterial3; bool get disableAnimation;
|
||||
/// Create a copy of AppSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -301,16 +301,16 @@ $AppSettingsCopyWith<AppSettings> get copyWith => _$AppSettingsCopyWithImpl<AppS
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.customColors, customColors) || other.customColors == customColors)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.windowOpacity, windowOpacity) || other.windowOpacity == windowOpacity)&&(identical(other.cardTransparency, cardTransparency) || other.cardTransparency == cardTransparency)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode)&&(identical(other.useMaterial3, useMaterial3) || other.useMaterial3 == useMaterial3));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.customColors, customColors) || other.customColors == customColors)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.windowOpacity, windowOpacity) || other.windowOpacity == windowOpacity)&&(identical(other.cardTransparency, cardTransparency) || other.cardTransparency == cardTransparency)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode)&&(identical(other.useMaterial3, useMaterial3) || other.useMaterial3 == useMaterial3)&&(identical(other.disableAnimation, disableAnimation) || other.disableAnimation == disableAnimation));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,customColors,windowSize,windowOpacity,cardTransparency,defaultPoolId,messageDisplayStyle,themeMode,useMaterial3);
|
||||
int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,customColors,windowSize,windowOpacity,cardTransparency,defaultPoolId,messageDisplayStyle,themeMode,useMaterial3,disableAnimation);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, customColors: $customColors, windowSize: $windowSize, windowOpacity: $windowOpacity, cardTransparency: $cardTransparency, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle, themeMode: $themeMode, useMaterial3: $useMaterial3)';
|
||||
return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, customColors: $customColors, windowSize: $windowSize, windowOpacity: $windowOpacity, cardTransparency: $cardTransparency, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle, themeMode: $themeMode, useMaterial3: $useMaterial3, disableAnimation: $disableAnimation)';
|
||||
}
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ abstract mixin class $AppSettingsCopyWith<$Res> {
|
||||
factory $AppSettingsCopyWith(AppSettings value, $Res Function(AppSettings) _then) = _$AppSettingsCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3
|
||||
bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3, bool disableAnimation
|
||||
});
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ class _$AppSettingsCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of AppSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? customColors = freezed,Object? windowSize = freezed,Object? windowOpacity = null,Object? cardTransparency = null,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,Object? themeMode = freezed,Object? useMaterial3 = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? customColors = freezed,Object? windowSize = freezed,Object? windowOpacity = null,Object? cardTransparency = null,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,Object? themeMode = freezed,Object? useMaterial3 = null,Object? disableAnimation = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable
|
||||
as bool,dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable
|
||||
@@ -357,6 +357,7 @@ as double,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaul
|
||||
as String?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable
|
||||
as String,themeMode: freezed == themeMode ? _self.themeMode : themeMode // ignore: cast_nullable_to_non_nullable
|
||||
as String?,useMaterial3: null == useMaterial3 ? _self.useMaterial3 : useMaterial3 // ignore: cast_nullable_to_non_nullable
|
||||
as bool,disableAnimation: null == disableAnimation ? _self.disableAnimation : disableAnimation // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
@@ -451,10 +452,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3, bool disableAnimation)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AppSettings() when $default != null:
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.useMaterial3);case _:
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.useMaterial3,_that.disableAnimation);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -472,10 +473,10 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3, bool disableAnimation) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AppSettings():
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.useMaterial3);}
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.useMaterial3,_that.disableAnimation);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
@@ -489,10 +490,10 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3, bool disableAnimation)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AppSettings() when $default != null:
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.useMaterial3);case _:
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.customColors,_that.windowSize,_that.windowOpacity,_that.cardTransparency,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode,_that.useMaterial3,_that.disableAnimation);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -504,7 +505,7 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha
|
||||
|
||||
|
||||
class _AppSettings implements AppSettings {
|
||||
const _AppSettings({required this.autoTranslate, required this.dataSavingMode, required this.soundEffects, required this.aprilFoolFeatures, required this.enterToSend, required this.appBarTransparent, required this.showBackgroundImage, required this.customFonts, required this.appColorScheme, required this.customColors, required this.windowSize, required this.windowOpacity, required this.cardTransparency, required this.defaultPoolId, required this.messageDisplayStyle, required this.themeMode, required this.useMaterial3});
|
||||
const _AppSettings({required this.autoTranslate, required this.dataSavingMode, required this.soundEffects, required this.aprilFoolFeatures, required this.enterToSend, required this.appBarTransparent, required this.showBackgroundImage, required this.customFonts, required this.appColorScheme, required this.customColors, required this.windowSize, required this.windowOpacity, required this.cardTransparency, required this.defaultPoolId, required this.messageDisplayStyle, required this.themeMode, required this.useMaterial3, required this.disableAnimation});
|
||||
|
||||
|
||||
@override final bool autoTranslate;
|
||||
@@ -528,6 +529,7 @@ class _AppSettings implements AppSettings {
|
||||
@override final String messageDisplayStyle;
|
||||
@override final String? themeMode;
|
||||
@override final bool useMaterial3;
|
||||
@override final bool disableAnimation;
|
||||
|
||||
/// Create a copy of AppSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -539,16 +541,16 @@ _$AppSettingsCopyWith<_AppSettings> get copyWith => __$AppSettingsCopyWithImpl<_
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.customColors, customColors) || other.customColors == customColors)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.windowOpacity, windowOpacity) || other.windowOpacity == windowOpacity)&&(identical(other.cardTransparency, cardTransparency) || other.cardTransparency == cardTransparency)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode)&&(identical(other.useMaterial3, useMaterial3) || other.useMaterial3 == useMaterial3));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.customColors, customColors) || other.customColors == customColors)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.windowOpacity, windowOpacity) || other.windowOpacity == windowOpacity)&&(identical(other.cardTransparency, cardTransparency) || other.cardTransparency == cardTransparency)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode)&&(identical(other.useMaterial3, useMaterial3) || other.useMaterial3 == useMaterial3)&&(identical(other.disableAnimation, disableAnimation) || other.disableAnimation == disableAnimation));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,customColors,windowSize,windowOpacity,cardTransparency,defaultPoolId,messageDisplayStyle,themeMode,useMaterial3);
|
||||
int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,customColors,windowSize,windowOpacity,cardTransparency,defaultPoolId,messageDisplayStyle,themeMode,useMaterial3,disableAnimation);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, customColors: $customColors, windowSize: $windowSize, windowOpacity: $windowOpacity, cardTransparency: $cardTransparency, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle, themeMode: $themeMode, useMaterial3: $useMaterial3)';
|
||||
return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, customColors: $customColors, windowSize: $windowSize, windowOpacity: $windowOpacity, cardTransparency: $cardTransparency, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle, themeMode: $themeMode, useMaterial3: $useMaterial3, disableAnimation: $disableAnimation)';
|
||||
}
|
||||
|
||||
|
||||
@@ -559,7 +561,7 @@ abstract mixin class _$AppSettingsCopyWith<$Res> implements $AppSettingsCopyWith
|
||||
factory _$AppSettingsCopyWith(_AppSettings value, $Res Function(_AppSettings) _then) = __$AppSettingsCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3
|
||||
bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, ThemeColors? customColors, Size? windowSize, double windowOpacity, double cardTransparency, String? defaultPoolId, String messageDisplayStyle, String? themeMode, bool useMaterial3, bool disableAnimation
|
||||
});
|
||||
|
||||
|
||||
@@ -576,7 +578,7 @@ class __$AppSettingsCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of AppSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? customColors = freezed,Object? windowSize = freezed,Object? windowOpacity = null,Object? cardTransparency = null,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,Object? themeMode = freezed,Object? useMaterial3 = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? customColors = freezed,Object? windowSize = freezed,Object? windowOpacity = null,Object? cardTransparency = null,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,Object? themeMode = freezed,Object? useMaterial3 = null,Object? disableAnimation = null,}) {
|
||||
return _then(_AppSettings(
|
||||
autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable
|
||||
as bool,dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable
|
||||
@@ -595,6 +597,7 @@ as double,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaul
|
||||
as String?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable
|
||||
as String,themeMode: freezed == themeMode ? _self.themeMode : themeMode // ignore: cast_nullable_to_non_nullable
|
||||
as String?,useMaterial3: null == useMaterial3 ? _self.useMaterial3 : useMaterial3 // ignore: cast_nullable_to_non_nullable
|
||||
as bool,disableAnimation: null == disableAnimation ? _self.disableAnimation : disableAnimation // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ Map<String, dynamic> _$ThemeColorsToJson(_ThemeColors instance) =>
|
||||
// **************************************************************************
|
||||
|
||||
String _$appSettingsNotifierHash() =>
|
||||
r'3ba2cdce76f3c4fed84f4108341c88a0a971bf3a';
|
||||
r'10ebd893c39d24ae588a4c0bf4144ee4656465a4';
|
||||
|
||||
/// See also [AppSettingsNotifier].
|
||||
@ProviderFor(AppSettingsNotifier)
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio_smart_retry/dio_smart_retry.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -109,6 +110,16 @@ final apiClientProvider = Provider<Dio>((ref) {
|
||||
printResponseData: false,
|
||||
),
|
||||
),
|
||||
RetryInterceptor(
|
||||
dio: dio,
|
||||
retries: 3,
|
||||
retryDelays: const [
|
||||
Duration(milliseconds: 300),
|
||||
Duration(milliseconds: 500),
|
||||
Duration(milliseconds: 1000),
|
||||
],
|
||||
retryEvaluator: (err, _) => err.requestOptions.method == 'GET',
|
||||
),
|
||||
]);
|
||||
|
||||
return dio;
|
||||
|
||||
@@ -88,6 +88,7 @@ ThemeData createAppTheme(Brightness brightness, AppSettings settings) {
|
||||
color: colorScheme.surfaceContainer.withOpacity(
|
||||
settings.cardTransparency,
|
||||
),
|
||||
elevation: settings.cardTransparency <= 1 ? 0 : null,
|
||||
),
|
||||
pageTransitionsTheme: PageTransitionsTheme(
|
||||
builders: {
|
||||
|
||||
450
lib/route.dart
450
lib/route.dart
@@ -10,13 +10,11 @@ import 'package:island/screens/developers/app_detail.dart';
|
||||
import 'package:island/screens/developers/bot_detail.dart';
|
||||
import 'package:island/screens/developers/edit_app.dart';
|
||||
import 'package:island/screens/developers/edit_bot.dart';
|
||||
import 'package:island/screens/developers/new_app.dart';
|
||||
import 'package:island/screens/developers/hub.dart';
|
||||
import 'package:island/screens/developers/new_app.dart';
|
||||
import 'package:island/screens/developers/new_bot.dart';
|
||||
import 'package:island/screens/developers/projects.dart';
|
||||
import 'package:island/screens/developers/edit_project.dart';
|
||||
import 'package:island/screens/developers/new_project.dart';
|
||||
import 'package:island/screens/developers/project_detail.dart';
|
||||
import 'package:island/screens/discovery/articles.dart';
|
||||
import 'package:island/screens/files/file_list.dart';
|
||||
import 'package:island/screens/posts/post_categories_list.dart';
|
||||
@@ -43,7 +41,6 @@ import 'package:island/screens/chat/search_messages.dart';
|
||||
import 'package:island/screens/creators/hub.dart';
|
||||
import 'package:island/screens/creators/posts/post_manage_list.dart';
|
||||
import 'package:island/screens/creators/stickers/stickers.dart';
|
||||
import 'package:island/screens/creators/stickers/pack_detail.dart';
|
||||
import 'package:island/screens/stickers/sticker_marketplace.dart';
|
||||
import 'package:island/screens/stickers/pack_detail.dart';
|
||||
import 'package:island/screens/discovery/feeds/feed_marketplace.dart';
|
||||
@@ -51,7 +48,6 @@ import 'package:island/screens/discovery/feeds/feed_detail.dart';
|
||||
import 'package:island/screens/creators/poll/poll_list.dart';
|
||||
import 'package:island/screens/creators/publishers_form.dart';
|
||||
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
|
||||
import 'package:island/screens/creators/webfeed/webfeed_edit.dart';
|
||||
import 'package:island/screens/poll/poll_editor.dart';
|
||||
import 'package:island/screens/posts/compose.dart';
|
||||
import 'package:island/screens/posts/post_detail.dart';
|
||||
@@ -62,7 +58,6 @@ import 'package:island/screens/settings.dart';
|
||||
import 'package:island/screens/realm/realms.dart';
|
||||
import 'package:island/screens/realm/realm_form.dart';
|
||||
import 'package:island/screens/realm/realm_detail.dart';
|
||||
import 'package:island/screens/account/event_calendar.dart';
|
||||
import 'package:island/screens/discovery/realms.dart';
|
||||
import 'package:island/screens/reports/report_detail.dart';
|
||||
import 'package:island/screens/reports/report_list.dart';
|
||||
@@ -142,264 +137,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
path: '/logs',
|
||||
builder: (context, state) => TalkerScreen(talker: talker),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'accountCalendar',
|
||||
path: '/account/:name/calendar',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return EventCalanderScreen(name: name);
|
||||
},
|
||||
),
|
||||
ShellRoute(
|
||||
builder:
|
||||
(context, state, child) => CreatorHubShellScreen(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: 'creatorHub',
|
||||
path: '/creators',
|
||||
builder: (context, state) => const CreatorHubScreen(),
|
||||
),
|
||||
// Web Feed Routes
|
||||
GoRoute(
|
||||
name: 'creatorFeeds',
|
||||
path: '/creators/:name/feeds',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return WebFeedListScreen(pubName: name);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: 'creatorFeedNew',
|
||||
path: 'new',
|
||||
builder: (context, state) {
|
||||
return WebFeedNewScreen(
|
||||
pubName: state.pathParameters['name']!,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorFeedEdit',
|
||||
path: ':feedId',
|
||||
builder: (context, state) {
|
||||
return WebFeedEditScreen(
|
||||
pubName: state.pathParameters['name']!,
|
||||
feedId: state.pathParameters['feedId'],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorPosts',
|
||||
path: '/creators/:name/posts',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return CreatorPostListScreen(pubName: name);
|
||||
},
|
||||
),
|
||||
// Poll list route
|
||||
GoRoute(
|
||||
name: 'creatorPolls',
|
||||
path: '/creators/:name/polls',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return CreatorPollListScreen(pubName: name);
|
||||
},
|
||||
),
|
||||
// Poll routes
|
||||
GoRoute(
|
||||
name: 'creatorPollNew',
|
||||
path: '/creators/:name/polls/new',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
// initialPollId left null for create; initialPublisher prefilled
|
||||
return PollEditorScreen(initialPublisher: name);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorPollEdit',
|
||||
path: '/creators/:name/polls/:id/edit',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
final id = state.pathParameters['id']!;
|
||||
return PollEditorScreen(
|
||||
initialPollId: id,
|
||||
initialPublisher: name,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorStickers',
|
||||
path: '/creators/:name/stickers',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return StickersScreen(pubName: name);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorStickerPackNew',
|
||||
path: '/creators/:name/stickers/new',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return NewStickerPacksScreen(pubName: name);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorStickerPackEdit',
|
||||
path: '/creators/:name/stickers/:packId/edit',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
final packId = state.pathParameters['packId']!;
|
||||
return EditStickerPacksScreen(pubName: name, packId: packId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorStickerPackDetail',
|
||||
path: '/creators/:name/stickers/:packId',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
final packId = state.pathParameters['packId']!;
|
||||
return StickerPackDetailScreen(pubName: name, id: packId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorStickerNew',
|
||||
path: '/creators/:name/stickers/:packId/new',
|
||||
builder: (context, state) {
|
||||
final packId = state.pathParameters['packId']!;
|
||||
return NewStickersScreen(packId: packId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorStickerEdit',
|
||||
path: '/creators/:name/stickers/:packId/:id/edit',
|
||||
builder: (context, state) {
|
||||
final packId = state.pathParameters['packId']!;
|
||||
final id = state.pathParameters['id']!;
|
||||
return EditStickersScreen(id: id, packId: packId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorNew',
|
||||
path: '/creators/new',
|
||||
builder: (context, state) => const NewPublisherScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorEdit',
|
||||
path: '/creators/:name/edit',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return EditPublisherScreen(name: name);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
ShellRoute(
|
||||
builder:
|
||||
(context, state, child) =>
|
||||
DeveloperHubShellScreen(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: 'developerHub',
|
||||
path: '/developers',
|
||||
builder: (context, state) => const DeveloperHubScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerProjects',
|
||||
path: '/developers/:name/projects',
|
||||
builder:
|
||||
(context, state) => DevProjectsScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerProjectNew',
|
||||
path: '/developers/:name/projects/new',
|
||||
builder:
|
||||
(context, state) => NewProjectScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerProjectEdit',
|
||||
path: '/developers/:name/projects/:id/edit',
|
||||
builder:
|
||||
(context, state) => EditProjectScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
id: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerProjectDetail',
|
||||
path: '/developers/:name/projects/:projectId',
|
||||
builder:
|
||||
(context, state) => ProjectDetailScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: 'developerAppNew',
|
||||
path: 'apps/new',
|
||||
builder:
|
||||
(context, state) => NewCustomAppScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerAppEdit',
|
||||
path: 'apps/:id/edit',
|
||||
builder:
|
||||
(context, state) => EditAppScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
id: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerAppDetail',
|
||||
path: 'apps/:appId',
|
||||
builder:
|
||||
(context, state) => AppDetailScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
appId: state.pathParameters['appId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerBotNew',
|
||||
path: 'bots/new',
|
||||
builder:
|
||||
(context, state) => NewBotScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerBotDetail',
|
||||
path: 'bots/:botId',
|
||||
builder:
|
||||
(context, state) => BotDetailScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
botId: state.pathParameters['botId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerBotEdit',
|
||||
path: 'bots/:id/edit',
|
||||
builder:
|
||||
(context, state) => EditBotScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
id: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Web articles
|
||||
GoRoute(
|
||||
@@ -708,12 +445,195 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
|
||||
GoRoute(
|
||||
name: 'accountProfile',
|
||||
path: '/account/:name',
|
||||
path: '/accounts/:name',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return AccountProfileScreen(name: name);
|
||||
},
|
||||
),
|
||||
// Creator hub tab
|
||||
GoRoute(
|
||||
name: 'creatorHub',
|
||||
path: '/creators',
|
||||
builder: (context, state) => const CreatorHubScreen(),
|
||||
routes: [
|
||||
// Web Feed Routes
|
||||
GoRoute(
|
||||
name: 'creatorFeeds',
|
||||
path: ':name/feeds',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return WebFeedListScreen(pubName: name);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorPosts',
|
||||
path: ':name/posts',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return CreatorPostListScreen(pubName: name);
|
||||
},
|
||||
),
|
||||
// Poll list route
|
||||
GoRoute(
|
||||
name: 'creatorPolls',
|
||||
path: ':name/polls',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return CreatorPollListScreen(pubName: name);
|
||||
},
|
||||
),
|
||||
// Poll routes
|
||||
GoRoute(
|
||||
name: 'creatorPollNew',
|
||||
path: ':name/polls/new',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
// initialPollId left null for create; initialPublisher prefilled
|
||||
return PollEditorScreen(initialPublisher: name);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorPollEdit',
|
||||
path: ':name/polls/:id/edit',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
final id = state.pathParameters['id']!;
|
||||
return PollEditorScreen(
|
||||
initialPollId: id,
|
||||
initialPublisher: name,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorStickers',
|
||||
path: ':name/stickers',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return StickersScreen(pubName: name);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorNew',
|
||||
path: 'new',
|
||||
builder: (context, state) => const NewPublisherScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'creatorEdit',
|
||||
path: ':name/edit',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
return EditPublisherScreen(name: name);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Developer hub tab
|
||||
GoRoute(
|
||||
name: 'developerHub',
|
||||
path: '/developers',
|
||||
builder:
|
||||
(context, state) => DeveloperHubScreen(
|
||||
initialPublisherName:
|
||||
state.uri.queryParameters['publisher'],
|
||||
initialProjectId: state.uri.queryParameters['project'],
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: 'developerProjectNew',
|
||||
path: ':name/projects/new',
|
||||
builder:
|
||||
(context, state) => NewProjectScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerProjectEdit',
|
||||
path: ':name/projects/:id/edit',
|
||||
builder:
|
||||
(context, state) => EditProjectScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
id: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerProjectDetail',
|
||||
path: ':name/projects/:projectId',
|
||||
builder: (context, state) {
|
||||
final name = state.pathParameters['name']!;
|
||||
final projectId = state.pathParameters['projectId']!;
|
||||
// Redirect to hub with project selected
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.go(
|
||||
'/developers?publisher=$name&project=$projectId',
|
||||
);
|
||||
});
|
||||
return const SizedBox.shrink(); // Temporary placeholder
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
name: 'developerAppNew',
|
||||
path: 'apps/new',
|
||||
builder:
|
||||
(context, state) => NewCustomAppScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerAppEdit',
|
||||
path: 'apps/:id/edit',
|
||||
builder:
|
||||
(context, state) => EditAppScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
id: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerAppDetail',
|
||||
path: 'apps/:appId',
|
||||
builder:
|
||||
(context, state) => AppDetailScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
appId: state.pathParameters['appId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerBotNew',
|
||||
path: 'bots/new',
|
||||
builder:
|
||||
(context, state) => NewBotScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerBotDetail',
|
||||
path: 'bots/:botId',
|
||||
builder:
|
||||
(context, state) => BotDetailScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
botId: state.pathParameters['botId']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'developerBotEdit',
|
||||
path: 'bots/:id/edit',
|
||||
builder:
|
||||
(context, state) => EditBotScreen(
|
||||
publisherName: state.pathParameters['name']!,
|
||||
projectId: state.pathParameters['projectId']!,
|
||||
id: state.pathParameters['id']!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -149,132 +149,124 @@ class AccountScreen extends HookConsumerWidget {
|
||||
context.pushNamed('leveling');
|
||||
},
|
||||
).padding(horizontal: 12),
|
||||
if (!isWideScreen(context)) const SizedBox.shrink(),
|
||||
if (!isWideScreen(context))
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Symbols.draw, size: 28).padding(bottom: 8),
|
||||
Text(
|
||||
'creatorHub',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).tr().fontSize(16).bold(),
|
||||
Text(
|
||||
'creatorHubDescription',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).tr(),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
onTap: () {
|
||||
context.goNamed('creatorHub');
|
||||
},
|
||||
),
|
||||
).height(140),
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Symbols.code, size: 28).padding(bottom: 8),
|
||||
Text(
|
||||
'developerPortal',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).tr().fontSize(16).bold(),
|
||||
Text(
|
||||
'developerPortalDescription',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).tr(),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
onTap: () {
|
||||
context.pushNamed('developerHub');
|
||||
},
|
||||
),
|
||||
).height(140),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12),
|
||||
const SizedBox.shrink(),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(Symbols.draw, size: 28).padding(bottom: 8),
|
||||
Text(
|
||||
'creatorHub',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).tr().fontSize(16).bold(),
|
||||
Text(
|
||||
'creatorHubDescription',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).tr(),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
onTap: () {
|
||||
context.pushNamed('creatorHub');
|
||||
},
|
||||
),
|
||||
).height(140),
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Symbols.code, size: 28).padding(bottom: 8),
|
||||
Text(
|
||||
'developerPortal',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).tr().fontSize(16).bold(),
|
||||
Text(
|
||||
'developerPortalDescription',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).tr(),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
onTap: () {
|
||||
context.pushNamed('developerHub');
|
||||
},
|
||||
),
|
||||
).height(140),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12),
|
||||
const SizedBox.shrink(),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Symbols.settings, size: 28).padding(bottom: 8),
|
||||
Text('appSettings').tr().fontSize(16).bold(),
|
||||
Icon(Symbols.settings, size: 20),
|
||||
Text('appSettings').tr().fontSize(13).bold(),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
onTap: () {
|
||||
context.pushNamed('settings');
|
||||
},
|
||||
),
|
||||
).height(120),
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.person_edit,
|
||||
size: 28,
|
||||
).padding(bottom: 8),
|
||||
Text('updateYourProfile').tr().fontSize(16).bold(),
|
||||
Icon(Symbols.person_edit, size: 20),
|
||||
Text('updateYourProfile').tr().fontSize(13).bold(),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
onTap: () {
|
||||
context.pushNamed('profileUpdate');
|
||||
},
|
||||
),
|
||||
).height(120),
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.manage_accounts,
|
||||
size: 28,
|
||||
).padding(bottom: 8),
|
||||
Text('accountSettings').tr().fontSize(16).bold(),
|
||||
Icon(Symbols.manage_accounts, size: 20),
|
||||
Text('accountSettings').tr().fontSize(13).bold(),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
onTap: () {
|
||||
context.pushNamed('accountSettings');
|
||||
},
|
||||
),
|
||||
).height(120),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12),
|
||||
).height(48),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.notifications),
|
||||
@@ -290,7 +282,11 @@ class AccountScreen extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed('notifications');
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const NotificationScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/services/timezone.dart';
|
||||
import 'package:island/widgets/account/account_name.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
@@ -24,6 +25,17 @@ const kServerSupportedLanguages = {'en-US': 'en-us', 'zh-CN': 'zh-hans'};
|
||||
const kServerSupportedRegions = ['US', 'JP', 'CN'];
|
||||
|
||||
class UpdateProfileScreen extends HookConsumerWidget {
|
||||
bool _isValidHexColor(String color) {
|
||||
if (!color.startsWith('#')) return false;
|
||||
if (color.length != 7) return false; // #RRGGBB format
|
||||
try {
|
||||
int.parse(color.substring(1), radix: 16);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const UpdateProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
@@ -148,12 +160,39 @@ class UpdateProfileScreen extends HookConsumerWidget {
|
||||
text: user.value!.profile.timeZone,
|
||||
);
|
||||
|
||||
// Username color state
|
||||
final usernameColorType = useState<String>(
|
||||
user.value!.profile.usernameColor?.type ?? 'plain',
|
||||
);
|
||||
final usernameColorValue = useTextEditingController(
|
||||
text: user.value!.profile.usernameColor?.value,
|
||||
);
|
||||
final usernameColorDirection = useTextEditingController(
|
||||
text: user.value!.profile.usernameColor?.direction,
|
||||
);
|
||||
final usernameColorColors = useState<List<String>>(
|
||||
user.value!.profile.usernameColor?.colors ?? [],
|
||||
);
|
||||
|
||||
void updateProfile() async {
|
||||
if (!formKeyProfile.currentState!.validate()) return;
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final usernameColorData = {
|
||||
'type': usernameColorType.value,
|
||||
if (usernameColorType.value == 'plain' &&
|
||||
usernameColorValue.text.isNotEmpty)
|
||||
'value': usernameColorValue.text,
|
||||
if (usernameColorType.value == 'gradient') ...{
|
||||
if (usernameColorDirection.text.isNotEmpty)
|
||||
'direction': usernameColorDirection.text,
|
||||
'colors':
|
||||
usernameColorColors.value.where((c) => c.isNotEmpty).toList(),
|
||||
},
|
||||
};
|
||||
|
||||
await client.patch(
|
||||
'/id/accounts/me/profile',
|
||||
data: {
|
||||
@@ -166,6 +205,7 @@ class UpdateProfileScreen extends HookConsumerWidget {
|
||||
'location': locationController.text,
|
||||
'time_zone': timeZoneController.text,
|
||||
'birthday': birthday.value?.toUtc().toIso8601String(),
|
||||
'username_color': usernameColorData,
|
||||
'links':
|
||||
links.value
|
||||
.where((e) => e.name.isNotEmpty && e.url.isNotEmpty)
|
||||
@@ -593,6 +633,320 @@ class UpdateProfileScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'usernameColor',
|
||||
).tr().bold().fontSize(18).padding(top: 16),
|
||||
Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Preview section
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('preview').tr().bold().fontSize(14),
|
||||
const Gap(8),
|
||||
// Create a preview account with the current settings
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final previewAccount = user.value!.copyWith(
|
||||
profile: user.value!.profile.copyWith(
|
||||
usernameColor: UsernameColor(
|
||||
type: usernameColorType.value,
|
||||
value:
|
||||
usernameColorType.value == 'plain' &&
|
||||
usernameColorValue
|
||||
.text
|
||||
.isNotEmpty
|
||||
? usernameColorValue.text
|
||||
: null,
|
||||
direction:
|
||||
usernameColorType.value ==
|
||||
'gradient' &&
|
||||
usernameColorDirection
|
||||
.text
|
||||
.isNotEmpty
|
||||
? usernameColorDirection.text
|
||||
: null,
|
||||
colors:
|
||||
usernameColorType.value == 'gradient'
|
||||
? usernameColorColors.value
|
||||
.where((c) => c.isNotEmpty)
|
||||
.toList()
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Check if user can use this color
|
||||
final tier =
|
||||
user.value!.perkSubscription?.identifier;
|
||||
final canUseColor = switch (tier) {
|
||||
'solian.stellar.primary' =>
|
||||
usernameColorType.value == 'plain' &&
|
||||
(kUsernamePlainColors.containsKey(
|
||||
usernameColorValue.text,
|
||||
) ||
|
||||
(usernameColorValue.text.startsWith(
|
||||
'#',
|
||||
) &&
|
||||
_isValidHexColor(
|
||||
usernameColorValue.text,
|
||||
))),
|
||||
'solian.stellar.nova' =>
|
||||
usernameColorType.value == 'plain',
|
||||
'solian.stellar.supernova' => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountName(
|
||||
account: previewAccount,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
ignorePermissions: true,
|
||||
),
|
||||
const Gap(4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
canUseColor
|
||||
? Symbols.check_circle
|
||||
: Symbols.error,
|
||||
size: 16,
|
||||
color:
|
||||
canUseColor
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
canUseColor
|
||||
? 'availableWithYourPlan'.tr()
|
||||
: 'upgradeRequired'.tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color:
|
||||
canUseColor
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DropdownButtonFormField2<String>(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'colorType'.tr(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 'plain',
|
||||
child: Text('plain'.tr()),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'gradient',
|
||||
child: Text('gradient'.tr()),
|
||||
),
|
||||
],
|
||||
value: usernameColorType.value,
|
||||
onChanged: (value) {
|
||||
usernameColorType.value = value ?? 'plain';
|
||||
},
|
||||
customButton: Row(
|
||||
children: [
|
||||
Expanded(child: Text(usernameColorType.value).tr()),
|
||||
Icon(Symbols.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (usernameColorType.value == 'plain')
|
||||
Autocomplete<String>(
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
final options = kUsernamePlainColors.keys.toList();
|
||||
if (textEditingValue.text == '') {
|
||||
return options;
|
||||
}
|
||||
return options.where(
|
||||
(option) => option.toLowerCase().contains(
|
||||
textEditingValue.text.toLowerCase(),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSelected: (String selection) {
|
||||
usernameColorValue.text = selection;
|
||||
},
|
||||
fieldViewBuilder: (
|
||||
context,
|
||||
controller,
|
||||
focusNode,
|
||||
onFieldSubmitted,
|
||||
) {
|
||||
// Initialize the controller with the current value
|
||||
if (controller.text.isEmpty &&
|
||||
usernameColorValue.text.isNotEmpty) {
|
||||
controller.text = usernameColorValue.text;
|
||||
}
|
||||
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'colorValue'.tr(),
|
||||
hintText: 'e.g. red or #ff6600',
|
||||
),
|
||||
onChanged: (value) {
|
||||
usernameColorValue.text = value;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (usernameColorType.value == 'gradient') ...[
|
||||
DropdownButtonFormField2<String>(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'gradientDirection'.tr(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 'to right',
|
||||
child: Text('gradientDirectionToRight'.tr()),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'to left',
|
||||
child: Text('gradientDirectionToLeft'.tr()),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'to bottom',
|
||||
child: Text('gradientDirectionToBottom'.tr()),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'to top',
|
||||
child: Text('gradientDirectionToTop'.tr()),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'to bottom right',
|
||||
child: Text(
|
||||
'gradientDirectionToBottomRight'.tr(),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'to bottom left',
|
||||
child: Text('gradientDirectionToBottomLeft'.tr()),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'to top right',
|
||||
child: Text('gradientDirectionToTopRight'.tr()),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'to top left',
|
||||
child: Text('gradientDirectionToTopLeft'.tr()),
|
||||
),
|
||||
],
|
||||
value:
|
||||
usernameColorDirection.text.isNotEmpty
|
||||
? usernameColorDirection.text
|
||||
: 'to right',
|
||||
onChanged: (value) {
|
||||
usernameColorDirection.text = value ?? 'to right';
|
||||
},
|
||||
customButton: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
usernameColorDirection.text.isNotEmpty
|
||||
? usernameColorDirection.text
|
||||
: 'to right',
|
||||
),
|
||||
),
|
||||
Icon(Symbols.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'gradientColors',
|
||||
).tr().bold().fontSize(14).padding(top: 8),
|
||||
Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (
|
||||
var i = 0;
|
||||
i < usernameColorColors.value.length;
|
||||
i++
|
||||
)
|
||||
Row(
|
||||
key: ValueKey(
|
||||
usernameColorColors.value[i].hashCode,
|
||||
),
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue:
|
||||
usernameColorColors.value[i],
|
||||
decoration: InputDecoration(
|
||||
labelText: 'color'.tr(),
|
||||
hintText: 'e.g. #ff0000',
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
usernameColorColors.value[i] = value;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.delete),
|
||||
onPressed: () {
|
||||
usernameColorColors.value =
|
||||
usernameColorColors.value
|
||||
.whereIndexed(
|
||||
(idx, _) => idx != i,
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () {
|
||||
usernameColorColors.value = List.from(
|
||||
usernameColorColors.value,
|
||||
)..add('');
|
||||
},
|
||||
label: Text('addColor').tr(),
|
||||
icon: const Icon(Symbols.add),
|
||||
).padding(top: 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text('links').tr().bold().fontSize(18).padding(top: 16),
|
||||
Column(
|
||||
spacing: 8,
|
||||
|
||||
@@ -97,9 +97,7 @@ class _AccountBasicInfo extends StatelessWidget {
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('https://solian.app/@${data.name}'),
|
||||
),
|
||||
ShareParams(uri: Uri.parse('https://solian.app/@${data.name}')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.share),
|
||||
@@ -879,7 +877,7 @@ class AccountProfileScreen extends HookConsumerWidget {
|
||||
child: Card(
|
||||
child: FortuneGraphWidget(
|
||||
events: accountEvents,
|
||||
eventCalanderUser: data.name,
|
||||
eventCalandarUser: data.name,
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
@@ -1004,7 +1002,7 @@ class AccountProfileScreen extends HookConsumerWidget {
|
||||
child: Card(
|
||||
child: FortuneGraphWidget(
|
||||
events: accountEvents,
|
||||
eventCalanderUser: data.name,
|
||||
eventCalandarUser: data.name,
|
||||
),
|
||||
).padding(horizontal: 4),
|
||||
),
|
||||
|
||||
@@ -14,6 +14,8 @@ import "package:island/pods/chat/chat_subscribe.dart";
|
||||
import "package:island/pods/chat/messages_notifier.dart";
|
||||
import "package:island/pods/network.dart";
|
||||
import "package:island/pods/chat/chat_online_count.dart";
|
||||
import "package:island/pods/config.dart";
|
||||
import "package:island/screens/chat/search_messages.dart";
|
||||
import "package:island/services/file_uploader.dart";
|
||||
import "package:island/screens/chat/chat.dart";
|
||||
import "package:island/services/responsive.dart";
|
||||
@@ -30,6 +32,7 @@ import "package:super_sliver_list/super_sliver_list.dart";
|
||||
import "package:material_symbols_icons/symbols.dart";
|
||||
import "package:island/widgets/chat/call_button.dart";
|
||||
import "package:island/widgets/chat/chat_input.dart";
|
||||
import "package:island/widgets/chat/chat_link_attachments.dart";
|
||||
import "package:island/widgets/chat/public_room_preview.dart";
|
||||
|
||||
class ChatRoomScreen extends HookConsumerWidget {
|
||||
@@ -42,6 +45,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
final chatIdentity = ref.watch(chatroomIdentityProvider(id));
|
||||
final isSyncing = ref.watch(isSyncingProvider);
|
||||
final onlineCount = ref.watch(chatOnlineCountNotifierProvider(id));
|
||||
final settings = ref.watch(appSettingsNotifierProvider);
|
||||
|
||||
final hasOnlineCount = onlineCount.hasValue;
|
||||
|
||||
@@ -142,6 +146,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
final attachmentProgress = useState<Map<String, Map<int, double>>>({});
|
||||
|
||||
var isLoading = false;
|
||||
var isScrollingToMessage = false; // Flag to prevent scroll conflicts
|
||||
|
||||
final listController = useMemoized(() => ListController(), []);
|
||||
|
||||
@@ -190,6 +195,59 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> pickAudioMedia() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.audio,
|
||||
allowMultiple: true,
|
||||
allowCompression: false,
|
||||
);
|
||||
if (result == null || result.count == 0) return;
|
||||
attachments.value = [
|
||||
...attachments.value,
|
||||
...result.files.map(
|
||||
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.audio),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> pickGeneralFile() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: true,
|
||||
allowCompression: false,
|
||||
);
|
||||
if (result == null || result.count == 0) return;
|
||||
attachments.value = [
|
||||
...attachments.value,
|
||||
...result.files.map(
|
||||
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.file),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void linkAttachment() async {
|
||||
final cloudFile = await showModalBottomSheet<SnCloudFile?>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const ChatLinkAttachment(),
|
||||
);
|
||||
if (cloudFile == null) return;
|
||||
|
||||
attachments.value = [
|
||||
...attachments.value,
|
||||
UniversalFile(
|
||||
data: cloudFile,
|
||||
type: switch (cloudFile.mimeType?.split('/').firstOrNull) {
|
||||
'image' => UniversalFileType.image,
|
||||
'video' => UniversalFileType.video,
|
||||
'audio' => UniversalFileType.audio,
|
||||
_ => UniversalFileType.file,
|
||||
},
|
||||
isLink: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void sendMessage() {
|
||||
if (messageController.text.trim().isNotEmpty ||
|
||||
attachments.value.isNotEmpty) {
|
||||
@@ -330,6 +388,94 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
|
||||
const messageKeyPrefix = 'message-';
|
||||
|
||||
// Helper function for scroll animation
|
||||
void performScrollAnimation({
|
||||
required int index,
|
||||
required ListController listController,
|
||||
required ScrollController scrollController,
|
||||
required String messageId,
|
||||
required WidgetRef ref,
|
||||
}) {
|
||||
// Update flashing message first
|
||||
ref
|
||||
.read(flashingMessagesProvider.notifier)
|
||||
.update((set) => set.union({messageId}));
|
||||
|
||||
// Use multiple post-frame callbacks to ensure stability
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
listController.animateToItem(
|
||||
index: index,
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration:
|
||||
(estimatedDistance) => Duration(
|
||||
milliseconds:
|
||||
(estimatedDistance * 0.5).clamp(200, 800).toInt(),
|
||||
),
|
||||
curve: (estimatedDistance) => Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
// Reset the scroll flag after animation completes
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
isScrollingToMessage = false;
|
||||
});
|
||||
} catch (e) {
|
||||
// If animation fails, reset the flag
|
||||
isScrollingToMessage = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Robust scroll-to-message function to prevent jumping back
|
||||
void scrollToMessage({
|
||||
required String messageId,
|
||||
required List<LocalChatMessage> messageList,
|
||||
required MessagesNotifier messagesNotifier,
|
||||
required ListController listController,
|
||||
required ScrollController scrollController,
|
||||
required WidgetRef ref,
|
||||
}) {
|
||||
// Prevent concurrent scroll operations
|
||||
if (isScrollingToMessage) return;
|
||||
isScrollingToMessage = true;
|
||||
|
||||
final messageIndex = messageList.indexWhere((m) => m.id == messageId);
|
||||
|
||||
if (messageIndex == -1) {
|
||||
// Message not in current list, need to load it first
|
||||
messagesNotifier.jumpToMessage(messageId).then((index) {
|
||||
if (index != -1) {
|
||||
// Wait for UI to rebuild before animating
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
performScrollAnimation(
|
||||
index: index,
|
||||
listController: listController,
|
||||
scrollController: scrollController,
|
||||
messageId: messageId,
|
||||
ref: ref,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
isScrollingToMessage = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Message is already in list, scroll directly with slight delay
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
performScrollAnimation(
|
||||
index: messageIndex,
|
||||
listController: listController,
|
||||
scrollController: scrollController,
|
||||
messageId: messageId,
|
||||
ref: ref,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> uploadAttachment(int index) async {
|
||||
final attachment = attachments.value[index];
|
||||
if (attachment.isOnCloud) return;
|
||||
@@ -385,121 +531,117 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
|
||||
SuperListView.builder(
|
||||
listController: listController,
|
||||
padding: EdgeInsets.only(
|
||||
top: 16,
|
||||
bottom: 80 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
controller: scrollController,
|
||||
reverse: true, // Show newest messages at the bottom
|
||||
itemCount: messageList.length,
|
||||
findChildIndexCallback: (key) {
|
||||
final valueKey = key as ValueKey;
|
||||
final messageId = (valueKey.value as String).substring(
|
||||
messageKeyPrefix.length,
|
||||
);
|
||||
return messageList.indexWhere((m) => m.id == messageId);
|
||||
},
|
||||
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;
|
||||
|
||||
final key = ValueKey('$messageKeyPrefix${message.id}');
|
||||
|
||||
return chatIdentity.when(
|
||||
skipError: true,
|
||||
data:
|
||||
(identity) => MessageItem(
|
||||
key: key,
|
||||
message: message,
|
||||
isCurrentUser: identity?.id == message.senderId,
|
||||
onAction: (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();
|
||||
}
|
||||
},
|
||||
onJump: (messageId) {
|
||||
final messageIndex = messageList.indexWhere(
|
||||
(m) => m.id == messageId,
|
||||
);
|
||||
if (messageIndex == -1) {
|
||||
messagesNotifier.jumpToMessage(messageId).then((index) {
|
||||
if (index != -1) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
listController.animateToItem(
|
||||
index: index,
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration:
|
||||
(estimatedDistance) =>
|
||||
Duration(milliseconds: 250),
|
||||
curve: (estimatedDistance) => Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
ref
|
||||
.read(flashingMessagesProvider.notifier)
|
||||
.update((set) => set.union({messageId}));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
listController.animateToItem(
|
||||
index: messageIndex,
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration:
|
||||
(estimatedDistance) =>
|
||||
Duration(milliseconds: 250),
|
||||
curve: (estimatedDistance) => Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
ref
|
||||
.read(flashingMessagesProvider.notifier)
|
||||
.update((set) => set.union({messageId}));
|
||||
},
|
||||
progress: attachmentProgress.value[message.id],
|
||||
showAvatar: isLastInGroup,
|
||||
),
|
||||
loading:
|
||||
() => MessageItem(
|
||||
key: key,
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
onAction: null,
|
||||
progress: null,
|
||||
showAvatar: false,
|
||||
onJump: (_) {},
|
||||
),
|
||||
error: (_, _) => SizedBox.shrink(key: key),
|
||||
);
|
||||
},
|
||||
Widget chatMessageListWidget(
|
||||
List<LocalChatMessage> messageList,
|
||||
) => SuperListView.builder(
|
||||
listController: listController,
|
||||
padding: EdgeInsets.only(
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
),
|
||||
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) => MessageItem(
|
||||
key: settings.disableAnimation ? key : null,
|
||||
message: message,
|
||||
isCurrentUser: identity?.id == message.senderId,
|
||||
onAction: (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,
|
||||
),
|
||||
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(
|
||||
appBar: AppBar(
|
||||
@@ -528,46 +670,40 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
'chatDetail',
|
||||
pathParameters: {'id': id},
|
||||
);
|
||||
if (result is String && messages.valueOrNull != null) {
|
||||
// Jump to the message that was selected in search
|
||||
final messageList = messages.valueOrNull!;
|
||||
final messageIndex = messageList.indexWhere(
|
||||
(m) => m.id == result,
|
||||
);
|
||||
if (messageIndex == -1) {
|
||||
messagesNotifier.jumpToMessage(result).then((index) {
|
||||
if (index != -1) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (result is SearchMessagesResult &&
|
||||
messages.valueOrNull != null) {
|
||||
final messageId = result.messageId;
|
||||
|
||||
// Jump to the message and trigger flash effect
|
||||
messagesNotifier.jumpToMessage(messageId).then((index) {
|
||||
if (index != -1 && context.mounted) {
|
||||
// Update flashing message
|
||||
ref
|
||||
.read(flashingMessagesProvider.notifier)
|
||||
.update((set) => set.union({messageId}));
|
||||
|
||||
// Scroll to the message with animation
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
listController.animateToItem(
|
||||
index: index,
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration:
|
||||
(estimatedDistance) =>
|
||||
Duration(milliseconds: 250),
|
||||
curve: (estimatedDistance) => Curves.easeInOut,
|
||||
(estimatedDistance) => Duration(
|
||||
milliseconds:
|
||||
(estimatedDistance * 0.5)
|
||||
.clamp(200, 800)
|
||||
.toInt(),
|
||||
),
|
||||
curve: (estimatedDistance) => Curves.easeOutCubic,
|
||||
);
|
||||
});
|
||||
ref
|
||||
.read(flashingMessagesProvider.notifier)
|
||||
.update((set) => set.union({result}));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
listController.animateToItem(
|
||||
index: messageIndex,
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration:
|
||||
(estimatedDistance) => Duration(milliseconds: 250),
|
||||
curve: (estimatedDistance) => Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
ref
|
||||
.read(flashingMessagesProvider.notifier)
|
||||
.update((set) => set.union({result}));
|
||||
}
|
||||
} catch (e) {
|
||||
// If animation fails, just update flashing state
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -576,88 +712,116 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
// Messages
|
||||
// Messages and Input in Column
|
||||
Positioned.fill(
|
||||
child: messages.when(
|
||||
data:
|
||||
(messageList) =>
|
||||
messageList.isEmpty
|
||||
? Center(child: Text('No messages yet'.tr()))
|
||||
: chatMessageListWidget(messageList),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, _) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () => messagesNotifier.loadInitial(),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (
|
||||
Widget child,
|
||||
Animation<double> animation,
|
||||
) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.05),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
},
|
||||
child: messages.when(
|
||||
data:
|
||||
(messageList) =>
|
||||
messageList.isEmpty
|
||||
? Center(
|
||||
key: const ValueKey('empty-messages'),
|
||||
child: Text('No messages yet'.tr()),
|
||||
)
|
||||
: chatMessageListWidget(messageList),
|
||||
loading:
|
||||
() => const Center(
|
||||
key: ValueKey('loading-messages'),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error:
|
||||
(error, _) => ResponseErrorWidget(
|
||||
key: const ValueKey('error-messages'),
|
||||
error: error,
|
||||
onRetry: () => messagesNotifier.loadInitial(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Input
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: chatRoom.when(
|
||||
data:
|
||||
(room) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ChatInput(
|
||||
messageController: messageController,
|
||||
chatRoom: room!,
|
||||
onSend: sendMessage,
|
||||
onClear: () {
|
||||
if (messageEditingTo.value != null) {
|
||||
attachments.value.clear();
|
||||
messageController.clear();
|
||||
}
|
||||
messageEditingTo.value = null;
|
||||
messageReplyingTo.value = null;
|
||||
messageForwardingTo.value = null;
|
||||
},
|
||||
messageEditingTo: messageEditingTo.value,
|
||||
messageReplyingTo: messageReplyingTo.value,
|
||||
messageForwardingTo: messageForwardingTo.value,
|
||||
onPickFile: (bool isPhoto) {
|
||||
if (isPhoto) {
|
||||
pickPhotoMedia();
|
||||
} else {
|
||||
pickVideoMedia();
|
||||
}
|
||||
},
|
||||
attachments: attachments.value,
|
||||
onUploadAttachment: uploadAttachment,
|
||||
onDeleteAttachment: (index) async {
|
||||
final attachment = attachments.value[index];
|
||||
if (attachment.isOnCloud) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.delete(
|
||||
'/drive/files/${attachment.data.id}',
|
||||
);
|
||||
}
|
||||
final clone = List.of(attachments.value);
|
||||
clone.removeAt(index);
|
||||
attachments.value = clone;
|
||||
},
|
||||
onMoveAttachment: (idx, delta) {
|
||||
if (idx + delta < 0 ||
|
||||
idx + delta >= attachments.value.length) {
|
||||
return;
|
||||
}
|
||||
final clone = List.of(attachments.value);
|
||||
clone.insert(idx + delta, clone.removeAt(idx));
|
||||
attachments.value = clone;
|
||||
},
|
||||
onAttachmentsChanged: (newAttachments) {
|
||||
attachments.value = newAttachments;
|
||||
},
|
||||
attachmentProgress: attachmentProgress.value,
|
||||
),
|
||||
chatRoom.when(
|
||||
data:
|
||||
(room) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ChatInput(
|
||||
messageController: messageController,
|
||||
chatRoom: room!,
|
||||
onSend: sendMessage,
|
||||
onClear: () {
|
||||
if (messageEditingTo.value != null) {
|
||||
attachments.value.clear();
|
||||
messageController.clear();
|
||||
}
|
||||
messageEditingTo.value = null;
|
||||
messageReplyingTo.value = null;
|
||||
messageForwardingTo.value = null;
|
||||
},
|
||||
messageEditingTo: messageEditingTo.value,
|
||||
messageReplyingTo: messageReplyingTo.value,
|
||||
messageForwardingTo: messageForwardingTo.value,
|
||||
onPickFile: (bool isPhoto) {
|
||||
if (isPhoto) {
|
||||
pickPhotoMedia();
|
||||
} else {
|
||||
pickVideoMedia();
|
||||
}
|
||||
},
|
||||
onPickAudio: pickAudioMedia,
|
||||
onPickGeneralFile: pickGeneralFile,
|
||||
onLinkAttachment: linkAttachment,
|
||||
attachments: attachments.value,
|
||||
onUploadAttachment: uploadAttachment,
|
||||
onDeleteAttachment: (index) async {
|
||||
final attachment = attachments.value[index];
|
||||
if (attachment.isOnCloud && !attachment.isLink) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.delete(
|
||||
'/drive/files/${attachment.data.id}',
|
||||
);
|
||||
}
|
||||
final clone = List.of(attachments.value);
|
||||
clone.removeAt(index);
|
||||
attachments.value = clone;
|
||||
},
|
||||
onMoveAttachment: (idx, delta) {
|
||||
if (idx + delta < 0 ||
|
||||
idx + delta >= attachments.value.length) {
|
||||
return;
|
||||
}
|
||||
final clone = List.of(attachments.value);
|
||||
clone.insert(idx + delta, clone.removeAt(idx));
|
||||
attachments.value = clone;
|
||||
},
|
||||
onAttachmentsChanged: (newAttachments) {
|
||||
attachments.value = newAttachments;
|
||||
},
|
||||
attachmentProgress: attachmentProgress.value,
|
||||
),
|
||||
Gap(MediaQuery.of(context).padding.bottom),
|
||||
],
|
||||
),
|
||||
Gap(MediaQuery.of(context).padding.bottom),
|
||||
],
|
||||
),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
|
||||
@@ -410,7 +410,7 @@ class ChatDetailScreen extends HookConsumerWidget {
|
||||
if (result is SearchMessagesResult) {
|
||||
// Navigate back to room screen with message to jump to
|
||||
if (context.mounted) {
|
||||
context.pop(result.messageId);
|
||||
context.pop(result);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,10 +4,13 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/chat/messages_notifier.dart';
|
||||
import 'package:island/pods/chat/chat_rooms.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/chat/message_list_tile.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'dart:async';
|
||||
|
||||
// Class to represent the result when popping from search messages
|
||||
class SearchMessagesResult {
|
||||
@@ -15,6 +18,93 @@ class SearchMessagesResult {
|
||||
const SearchMessagesResult(this.messageId);
|
||||
}
|
||||
|
||||
// Search states for better UX
|
||||
enum SearchState { idle, searching, results, noResults, error }
|
||||
|
||||
class _SearchFilters extends StatelessWidget {
|
||||
final ValueNotifier<bool> withLinks;
|
||||
final ValueNotifier<bool> withAttachments;
|
||||
final void Function(String) performSearch;
|
||||
final TextEditingController searchController;
|
||||
final bool isLarge;
|
||||
|
||||
const _SearchFilters({
|
||||
required this.withLinks,
|
||||
required this.withAttachments,
|
||||
required this.performSearch,
|
||||
required this.searchController,
|
||||
required this.isLarge,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLarge) {
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Symbols.link,
|
||||
color:
|
||||
withLinks.value
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
onPressed: () {
|
||||
withLinks.value = !withLinks.value;
|
||||
performSearch(searchController.text);
|
||||
},
|
||||
tooltip: 'searchLinks'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Symbols.file_copy,
|
||||
color:
|
||||
withAttachments.value
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
onPressed: () {
|
||||
withAttachments.value = !withAttachments.value;
|
||||
performSearch(searchController.text);
|
||||
},
|
||||
tooltip: 'searchAttachments'.tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
avatar: const Icon(Symbols.link, size: 16),
|
||||
label: const Text('searchLinks').tr(),
|
||||
selected: withLinks.value,
|
||||
onSelected: (bool? value) {
|
||||
withLinks.value = value!;
|
||||
performSearch(searchController.text);
|
||||
},
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
avatar: const Icon(Symbols.file_copy, size: 16),
|
||||
label: const Text('searchAttachments').tr(),
|
||||
selected: withAttachments.value,
|
||||
onSelected: (bool? value) {
|
||||
withAttachments.value = value!;
|
||||
performSearch(searchController.text);
|
||||
},
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SearchMessagesScreen extends HookConsumerWidget {
|
||||
final String roomId;
|
||||
|
||||
@@ -25,118 +115,390 @@ class SearchMessagesScreen extends HookConsumerWidget {
|
||||
final searchController = useTextEditingController();
|
||||
final withLinks = useState(false);
|
||||
final withAttachments = useState(false);
|
||||
final searchState = useState(SearchState.idle);
|
||||
final searchResultCount = useState<int?>(null);
|
||||
final searchResults = useState<AsyncValue<List<dynamic>>>(
|
||||
const AsyncValue.data([]),
|
||||
);
|
||||
|
||||
// Debounce timer for search optimization
|
||||
final debounceTimer = useRef<Timer?>(null);
|
||||
|
||||
final messagesNotifier = ref.read(
|
||||
messagesNotifierProvider(roomId).notifier,
|
||||
);
|
||||
final messages = ref.watch(messagesNotifierProvider(roomId));
|
||||
|
||||
// Optimized search function with debouncing
|
||||
void performSearch(String query) async {
|
||||
final trimmedQuery = query.trim();
|
||||
final hasFilters = withLinks.value || withAttachments.value;
|
||||
|
||||
if (trimmedQuery.isEmpty && !hasFilters) {
|
||||
searchState.value = SearchState.idle;
|
||||
searchResultCount.value = null;
|
||||
searchResults.value = const AsyncValue.data([]);
|
||||
return;
|
||||
}
|
||||
|
||||
searchState.value = SearchState.searching;
|
||||
searchResults.value = const AsyncValue.loading();
|
||||
|
||||
// Cancel previous search if still active
|
||||
debounceTimer.value?.cancel();
|
||||
|
||||
// Debounce search to avoid excessive API calls
|
||||
debounceTimer.value = Timer(const Duration(milliseconds: 300), () async {
|
||||
try {
|
||||
final results = await messagesNotifier.getSearchResults(
|
||||
query.trim(),
|
||||
withLinks: withLinks.value,
|
||||
withAttachments: withAttachments.value,
|
||||
);
|
||||
searchResults.value = AsyncValue.data(results);
|
||||
searchState.value =
|
||||
results.isEmpty ? SearchState.noResults : SearchState.results;
|
||||
searchResultCount.value = results.length;
|
||||
} catch (error, stackTrace) {
|
||||
searchResults.value = AsyncValue.error(error, stackTrace);
|
||||
searchState.value = SearchState.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Search state is now managed locally in performSearch
|
||||
|
||||
useEffect(() {
|
||||
// Clear search when screen is disposed
|
||||
return () {
|
||||
messagesNotifier.clearSearch();
|
||||
debounceTimer.value?.cancel();
|
||||
// Note: Don't access ref here as widget may be disposed
|
||||
// Flashing messages will be cleared by the next screen or jump operation
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Clear flashing messages when screen initializes (safer than in dispose)
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Clear flashing messages when entering search screen
|
||||
ref.read(flashingMessagesProvider.notifier).state = {};
|
||||
});
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
final isLarge = isWideScreen(context);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: const Text('searchMessages').tr()),
|
||||
appBar: AppBar(
|
||||
title: const Text('searchMessages').tr(),
|
||||
bottom:
|
||||
searchState.value == SearchState.searching
|
||||
? const PreferredSize(
|
||||
preferredSize: Size.fromHeight(2),
|
||||
child: LinearProgressIndicator(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'searchMessagesHint'.tr(),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 12,
|
||||
bottom: 16,
|
||||
),
|
||||
suffix: IconButton(
|
||||
iconSize: 18,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
messagesNotifier.clearSearch();
|
||||
},
|
||||
),
|
||||
),
|
||||
onChanged: (query) {
|
||||
messagesNotifier.searchMessages(
|
||||
query,
|
||||
withLinks: withLinks.value,
|
||||
withAttachments: withAttachments.value,
|
||||
);
|
||||
},
|
||||
// Search input section
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
child:
|
||||
isLarge
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'searchMessagesHint'.tr(),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (searchResultCount.value != null &&
|
||||
searchState.value ==
|
||||
SearchState.results)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
right: 8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(
|
||||
12,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${searchResultCount.value}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (searchController.text.isNotEmpty)
|
||||
IconButton(
|
||||
iconSize: 18,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
performSearch('');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: performSearch,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: _SearchFilters(
|
||||
withLinks: withLinks,
|
||||
withAttachments: withAttachments,
|
||||
performSearch: performSearch,
|
||||
searchController: searchController,
|
||||
isLarge: isLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'searchMessagesHint'.tr(),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (searchResultCount.value != null &&
|
||||
searchState.value ==
|
||||
SearchState.results)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(
|
||||
12,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${searchResultCount.value}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (searchController.text.isNotEmpty)
|
||||
IconButton(
|
||||
iconSize: 18,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
performSearch('');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: performSearch,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
_SearchFilters(
|
||||
withLinks: withLinks,
|
||||
withAttachments: withAttachments,
|
||||
performSearch: performSearch,
|
||||
searchController: searchController,
|
||||
isLarge: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CheckboxListTile(
|
||||
secondary: const Icon(Symbols.link),
|
||||
title: const Text('searchLinks').tr(),
|
||||
value: withLinks.value,
|
||||
onChanged: (bool? value) {
|
||||
withLinks.value = value!;
|
||||
messagesNotifier.searchMessages(
|
||||
searchController.text,
|
||||
withLinks: withLinks.value,
|
||||
withAttachments: withAttachments.value,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CheckboxListTile(
|
||||
secondary: const Icon(Symbols.file_copy),
|
||||
title: const Text('searchAttachments').tr(),
|
||||
value: withAttachments.value,
|
||||
onChanged: (bool? value) {
|
||||
withAttachments.value = value!;
|
||||
messagesNotifier.searchMessages(
|
||||
searchController.text,
|
||||
withLinks: withLinks.value,
|
||||
withAttachments: withAttachments.value,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// Search results section
|
||||
Expanded(
|
||||
child: messages.when(
|
||||
data:
|
||||
(messageList) =>
|
||||
messageList.isEmpty
|
||||
? Center(child: Text('noMessagesFound'.tr()))
|
||||
: SuperListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
reverse: false, // Show newest messages at the top
|
||||
itemCount: messageList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
return MessageListTile(
|
||||
message: message,
|
||||
onJump: (messageId) {
|
||||
// Return the search result and pop back to room detail
|
||||
context.pop(SearchMessagesResult(messageId));
|
||||
},
|
||||
);
|
||||
},
|
||||
child: searchResults.value.when(
|
||||
data: (messageList) {
|
||||
switch (searchState.value) {
|
||||
case SearchState.idle:
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, _) => Center(
|
||||
child: Text('errorGeneric'.tr(args: [error.toString()])),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'searchMessagesHint'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
case SearchState.noResults:
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'noMessagesFound'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'tryDifferentKeywords'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
case SearchState.results:
|
||||
return SuperListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
reverse: false, // Show newest messages at the top
|
||||
itemCount: messageList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
return MessageListTile(
|
||||
message: message,
|
||||
onJump: (messageId) {
|
||||
// Return the search result and pop back to room detail
|
||||
context.pop(SearchMessagesResult(messageId));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
loading: () {
|
||||
if (searchState.value == SearchState.searching) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Searching...'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
error: (error, _) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'searchError'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => performSearch(searchController.text),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('retry').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/models/publisher.dart';
|
||||
import 'package:island/models/heatmap.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/screens/creators/publishers_form.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
@@ -18,6 +19,7 @@ import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:island/widgets/activity_heatmap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||
@@ -33,6 +35,14 @@ Future<SnPublisherStats?> publisherStats(Ref ref, String? uname) async {
|
||||
return SnPublisherStats.fromJson(resp.data);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<SnHeatmap?> publisherHeatmap(Ref ref, String? uname) async {
|
||||
if (uname == null) return null;
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final resp = await apiClient.get('/sphere/publishers/$uname/heatmap');
|
||||
return SnHeatmap.fromJson(resp.data);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<SnPublisherMember?> publisherIdentity(Ref ref, String uname) async {
|
||||
try {
|
||||
@@ -102,42 +112,168 @@ class PublisherMemberListNotifier extends _$PublisherMemberListNotifier
|
||||
}
|
||||
}
|
||||
|
||||
class CreatorHubShellScreen extends StatelessWidget {
|
||||
final Widget child;
|
||||
const CreatorHubShellScreen({super.key, required this.child});
|
||||
class PublisherSelector extends StatelessWidget {
|
||||
final SnPublisher? currentPublisher;
|
||||
final List<DropdownMenuItem<SnPublisher>> publishersMenu;
|
||||
final ValueChanged<SnPublisher?>? onChanged;
|
||||
final bool isReadOnly;
|
||||
|
||||
const PublisherSelector({
|
||||
super.key,
|
||||
required this.currentPublisher,
|
||||
required this.publishersMenu,
|
||||
this.onChanged,
|
||||
this.isReadOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWide = isWideScreen(context);
|
||||
if (isWide) {
|
||||
return AppBackground(
|
||||
isRoot: true,
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(flex: 2, child: const CreatorHubScreen(isAside: true)),
|
||||
const VerticalDivider(width: 1),
|
||||
Flexible(flex: 3, child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (isReadOnly || currentPublisher == null) {
|
||||
return ProfilePictureWidget(
|
||||
radius: 16,
|
||||
fileId: currentPublisher?.picture?.id,
|
||||
).center().padding(right: 8);
|
||||
}
|
||||
return AppBackground(isRoot: true, child: child);
|
||||
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<SnPublisher>(
|
||||
value: currentPublisher,
|
||||
hint: CircleAvatar(
|
||||
radius: 16,
|
||||
child: Icon(
|
||||
Symbols.person,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSecondaryContainer.withOpacity(0.9),
|
||||
fill: 1,
|
||||
),
|
||||
).center().padding(right: 8),
|
||||
items: publishersMenu,
|
||||
onChanged: onChanged,
|
||||
selectedItemBuilder: (context) {
|
||||
return publishersMenu
|
||||
.map(
|
||||
(e) => ProfilePictureWidget(
|
||||
radius: 16,
|
||||
fileId: e.value?.picture?.id,
|
||||
).center().padding(right: 8),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
buttonStyleData: ButtonStyleData(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.only(left: 14, right: 8),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
|
||||
),
|
||||
dropdownStyleData: DropdownStyleData(
|
||||
width: 320,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)),
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 64,
|
||||
padding: EdgeInsets.only(left: 14, right: 14),
|
||||
),
|
||||
iconStyleData: IconStyleData(
|
||||
icon: Icon(Icons.arrow_drop_down),
|
||||
iconSize: 19,
|
||||
iconEnabledColor: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
iconDisabledColor: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PublisherUnselectedWidget extends HookConsumerWidget {
|
||||
final ValueChanged<SnPublisher> onPublisherSelected;
|
||||
|
||||
const _PublisherUnselectedWidget({required this.onPublisherSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final publishers = ref.watch(publishersManagedProvider);
|
||||
final publisherInvites = ref.watch(publisherInvitesProvider);
|
||||
|
||||
final hasPublishers = publishers.value?.isNotEmpty ?? false;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
if (!hasPublishers) ...[
|
||||
const Icon(
|
||||
Symbols.info,
|
||||
fill: 1,
|
||||
size: 32,
|
||||
).padding(bottom: 6, top: 24),
|
||||
Text(
|
||||
'creatorHubUnselectedHint',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
).tr(),
|
||||
const Gap(24),
|
||||
],
|
||||
if (hasPublishers)
|
||||
...(publishers.value?.map(
|
||||
(publisher) => ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
leading: ProfilePictureWidget(file: publisher.picture),
|
||||
title: Text(publisher.nick),
|
||||
subtitle: Text('@${publisher.name}'),
|
||||
onTap: () => onPublisherSelected(publisher),
|
||||
),
|
||||
) ??
|
||||
[]),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
leading: const CircleAvatar(child: Icon(Symbols.mail)),
|
||||
title: Text('publisherCollabInvitation').tr(),
|
||||
subtitle: Text(
|
||||
'publisherCollabInvitationCount',
|
||||
).plural(publisherInvites.value?.length ?? 0),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const _PublisherInviteSheet(),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
leading: const CircleAvatar(child: Icon(Symbols.add)),
|
||||
title: Text('createPublisher').tr(),
|
||||
subtitle: Text('createPublisherHint').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
context.pushNamed('creatorNew').then((value) {
|
||||
if (value != null) {
|
||||
ref.invalidate(publishersManagedProvider);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CreatorHubScreen extends HookConsumerWidget {
|
||||
final bool isAside;
|
||||
const CreatorHubScreen({super.key, this.isAside = false});
|
||||
const CreatorHubScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isWide = isWideScreen(context);
|
||||
if (isWide && !isAside) {
|
||||
return Container(color: Theme.of(context).colorScheme.surface);
|
||||
}
|
||||
|
||||
final publishers = ref.watch(publishersManagedProvider);
|
||||
final publisherInvites = ref.watch(publisherInvitesProvider);
|
||||
final currentPublisher = useState<SnPublisher?>(
|
||||
publishers.value?.firstOrNull,
|
||||
);
|
||||
@@ -203,303 +339,261 @@ class CreatorHubScreen extends HookConsumerWidget {
|
||||
publisherStatsProvider(currentPublisher.value?.name),
|
||||
);
|
||||
|
||||
final publisherHeatmap = ref.watch(
|
||||
publisherHeatmapProvider(currentPublisher.value?.name),
|
||||
);
|
||||
|
||||
final publisherFeatures = ref.watch(
|
||||
publisherFeaturesProvider(currentPublisher.value?.name),
|
||||
);
|
||||
|
||||
Widget buildNavigationWidget(bool isWide) {
|
||||
final leftItems = [
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
minTileHeight: 48,
|
||||
title: Text('stickers').tr(),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.ar_stickers),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'creatorStickers',
|
||||
pathParameters: {'name': currentPublisher.value!.name},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
minTileHeight: 48,
|
||||
title: Text('posts').tr(),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.sticky_note_2),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'creatorPosts',
|
||||
pathParameters: {'name': currentPublisher.value!.name},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
minTileHeight: 48,
|
||||
title: Text('polls').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.poll),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'creatorPolls',
|
||||
pathParameters: {'name': currentPublisher.value!.name},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
minTileHeight: 48,
|
||||
title: const Text('webFeeds').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.rss_feed),
|
||||
onTap: () {
|
||||
context.push('/creators/${currentPublisher.value!.name}/feeds');
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
final rightItems = [
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
minTileHeight: 48,
|
||||
title: Text('publisherMembers').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.group),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder:
|
||||
(context) => _PublisherMemberListSheet(
|
||||
publisherUname: currentPublisher.value!.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ExpansionTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
title: Text('publisherFeatures').tr(),
|
||||
leading: const Icon(Symbols.flag),
|
||||
tilePadding: const EdgeInsets.only(left: 16, right: 24),
|
||||
minTileHeight: 48,
|
||||
children: [
|
||||
...publisherFeatures.when(
|
||||
data: (data) {
|
||||
return data.entries.map((entry) {
|
||||
final keyPrefix =
|
||||
'publisherFeature${entry.key.capitalizeEachWord()}';
|
||||
return ListTile(
|
||||
minTileHeight: 48,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: Icon(
|
||||
Symbols.circle,
|
||||
color: entry.value ? Colors.green : Colors.red,
|
||||
fill: 1,
|
||||
size: 16,
|
||||
).padding(left: 2, top: 4),
|
||||
title: Text(keyPrefix).tr(),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${keyPrefix}Description').tr(),
|
||||
if (!entry.value) Text('${keyPrefix}Hint').tr().bold(),
|
||||
],
|
||||
),
|
||||
isThreeLine: true,
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
error: (_, _) => [],
|
||||
loading: () => [],
|
||||
),
|
||||
],
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
minTileHeight: 48,
|
||||
title: Text('editPublisher').tr(),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.edit),
|
||||
onTap: updatePublisher,
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
minTileHeight: 48,
|
||||
title: Text('deletePublisher').tr(),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.delete),
|
||||
onTap: deletePublisher,
|
||||
),
|
||||
];
|
||||
|
||||
if (isWide) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(children: leftItems),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(children: rightItems),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12);
|
||||
} else {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [...leftItems, const Divider(height: 8), ...rightItems],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
leading: !isWide ? const PageBackButton() : null,
|
||||
leading: const PageBackButton(),
|
||||
title: Text('creatorHub').tr(),
|
||||
actions: [
|
||||
DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<SnPublisher>(
|
||||
alignment: Alignment.centerRight,
|
||||
value: currentPublisher.value,
|
||||
hint: CircleAvatar(
|
||||
radius: 16,
|
||||
child: Icon(
|
||||
Symbols.person,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSecondaryContainer.withOpacity(0.9),
|
||||
fill: 1,
|
||||
),
|
||||
).center().padding(right: 8),
|
||||
items: [...publishersMenu],
|
||||
if (!isWideScreen(context))
|
||||
PublisherSelector(
|
||||
currentPublisher: currentPublisher.value,
|
||||
publishersMenu: publishersMenu,
|
||||
onChanged: (value) {
|
||||
currentPublisher.value = value;
|
||||
},
|
||||
selectedItemBuilder: (context) {
|
||||
return [
|
||||
...publishersMenu.map(
|
||||
(e) => ProfilePictureWidget(
|
||||
radius: 16,
|
||||
fileId: e.value?.picture?.id,
|
||||
).center().padding(right: 8),
|
||||
),
|
||||
];
|
||||
},
|
||||
buttonStyleData: ButtonStyleData(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.only(left: 14, right: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
dropdownStyleData: DropdownStyleData(
|
||||
width: 320,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 64,
|
||||
padding: EdgeInsets.only(left: 14, right: 14),
|
||||
),
|
||||
iconStyleData: IconStyleData(
|
||||
icon: Icon(Icons.arrow_drop_down),
|
||||
iconSize: 19,
|
||||
iconEnabledColor:
|
||||
Theme.of(context).appBarTheme.foregroundColor!,
|
||||
iconDisabledColor:
|
||||
Theme.of(context).appBarTheme.foregroundColor!,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: publisherStats.when(
|
||||
data:
|
||||
(stats) => SingleChildScrollView(
|
||||
child:
|
||||
currentPublisher.value == null
|
||||
? Column(
|
||||
children: [
|
||||
const Gap(24),
|
||||
const Icon(Symbols.info, size: 32).padding(bottom: 4),
|
||||
Text(
|
||||
'creatorHubUnselectedHint',
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
const Gap(24),
|
||||
const Divider(height: 1),
|
||||
...(publishers.value?.map(
|
||||
(publisher) => ListTile(
|
||||
leading: ProfilePictureWidget(
|
||||
file: publisher.picture,
|
||||
),
|
||||
title: Text(publisher.nick),
|
||||
subtitle: Text('@${publisher.name}'),
|
||||
onTap: () {
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = isWideScreen(context);
|
||||
final maxWidth = isWide ? 800.0 : double.infinity;
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: publisherStats.when(
|
||||
data:
|
||||
(stats) => SingleChildScrollView(
|
||||
child:
|
||||
currentPublisher.value == null
|
||||
? ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: _PublisherUnselectedWidget(
|
||||
onPublisherSelected: (publisher) {
|
||||
currentPublisher.value = publisher;
|
||||
},
|
||||
),
|
||||
) ??
|
||||
[]),
|
||||
ListTile(
|
||||
leading: const CircleAvatar(
|
||||
child: Icon(Symbols.mail),
|
||||
),
|
||||
title: Text('publisherCollabInvitation').tr(),
|
||||
subtitle: Text(
|
||||
'publisherCollabInvitationCount',
|
||||
).plural(publisherInvites.value?.length ?? 0),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => const _PublisherInviteSheet(),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const CircleAvatar(
|
||||
child: Icon(Symbols.add),
|
||||
),
|
||||
title: Text('createPublisher').tr(),
|
||||
subtitle: Text('createPublisherHint').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
context.pushNamed('creatorNew').then((value) {
|
||||
if (value != null) {
|
||||
ref.invalidate(publishersManagedProvider);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
if (stats != null)
|
||||
_PublisherStatsWidget(
|
||||
stats: stats,
|
||||
).padding(vertical: 12, horizontal: 12),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
title: Text('stickers').tr(),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.ar_stickers),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'creatorStickers',
|
||||
pathParameters: {
|
||||
'name': currentPublisher.value!.name,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
title: Text('posts').tr(),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.sticky_note_2),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'creatorPosts',
|
||||
pathParameters: {
|
||||
'name': currentPublisher.value!.name,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
title: Text('polls').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.poll),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'creatorPolls',
|
||||
pathParameters: {
|
||||
'name': currentPublisher.value!.name,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
title: Text('publisherMembers').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.group),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder:
|
||||
(context) => _PublisherMemberListSheet(
|
||||
publisherUname:
|
||||
currentPublisher.value!.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
title: const Text('webFeeds').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.rss_feed),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
onTap: () {
|
||||
context.push(
|
||||
'/creators/${currentPublisher.value!.name}/feeds',
|
||||
);
|
||||
},
|
||||
),
|
||||
ExpansionTile(
|
||||
title: Text('publisherFeatures').tr(),
|
||||
leading: const Icon(Symbols.flag),
|
||||
tilePadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
minTileHeight: 48,
|
||||
children: [
|
||||
...publisherFeatures.when(
|
||||
data: (data) {
|
||||
return data.entries.map((entry) {
|
||||
final keyPrefix =
|
||||
'publisherFeature${entry.key.capitalizeEachWord()}';
|
||||
return ListTile(
|
||||
minTileHeight: 48,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
leading: Icon(
|
||||
Symbols.circle,
|
||||
color:
|
||||
entry.value
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
fill: 1,
|
||||
size: 16,
|
||||
).padding(left: 2, top: 4),
|
||||
title: Text(keyPrefix).tr(),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${keyPrefix}Description').tr(),
|
||||
if (!entry.value)
|
||||
Text(
|
||||
'${keyPrefix}Hint',
|
||||
).tr().bold(),
|
||||
],
|
||||
),
|
||||
isThreeLine: true,
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
error: (_, _) => [],
|
||||
loading: () => [],
|
||||
).center()
|
||||
: isWide
|
||||
? Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
PublisherSelector(
|
||||
currentPublisher: currentPublisher.value,
|
||||
publishersMenu: publishersMenu,
|
||||
onChanged: (value) {
|
||||
currentPublisher.value = value;
|
||||
},
|
||||
),
|
||||
if (stats != null)
|
||||
_PublisherStatsWidget(
|
||||
stats: stats,
|
||||
heatmap: publisherHeatmap.value,
|
||||
).padding(horizontal: 12),
|
||||
buildNavigationWidget(true),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (stats != null)
|
||||
_PublisherStatsWidget(
|
||||
stats: stats,
|
||||
heatmap: publisherHeatmap.value,
|
||||
).padding(horizontal: 16),
|
||||
buildNavigationWidget(false),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Divider(height: 1).padding(vertical: 8),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
title: Text('editPublisher').tr(),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.edit),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
onTap: () {
|
||||
updatePublisher();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
title: Text('deletePublisher').tr(),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
leading: const Icon(Symbols.delete),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
onTap: () {
|
||||
deletePublisher();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -507,7 +601,8 @@ class CreatorHubScreen extends HookConsumerWidget {
|
||||
|
||||
class _PublisherStatsWidget extends StatelessWidget {
|
||||
final SnPublisherStats stats;
|
||||
const _PublisherStatsWidget({required this.stats});
|
||||
final SnHeatmap? heatmap;
|
||||
const _PublisherStatsWidget({required this.stats, this.heatmap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -560,6 +655,7 @@ class _PublisherStatsWidget extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (heatmap != null) ActivityHeatmapWidget(heatmap: heatmap!),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -576,21 +672,36 @@ class _PublisherStatsWidget extends StatelessWidget {
|
||||
height: 100,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
statValue,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
statLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).tr(),
|
||||
],
|
||||
child: Tooltip(
|
||||
richMessage: TextSpan(
|
||||
text: statLabel.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
children: [
|
||||
TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
text: statValue,
|
||||
style: TextStyle(fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
statValue,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
statLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -149,6 +149,127 @@ class _PublisherStatsProviderElement
|
||||
String? get uname => (origin as PublisherStatsProvider).uname;
|
||||
}
|
||||
|
||||
String _$publisherHeatmapHash() => r'5f70c55e14629ec8628445a317888e02fccd9af2';
|
||||
|
||||
/// See also [publisherHeatmap].
|
||||
@ProviderFor(publisherHeatmap)
|
||||
const publisherHeatmapProvider = PublisherHeatmapFamily();
|
||||
|
||||
/// See also [publisherHeatmap].
|
||||
class PublisherHeatmapFamily extends Family<AsyncValue<SnHeatmap?>> {
|
||||
/// See also [publisherHeatmap].
|
||||
const PublisherHeatmapFamily();
|
||||
|
||||
/// See also [publisherHeatmap].
|
||||
PublisherHeatmapProvider call(String? uname) {
|
||||
return PublisherHeatmapProvider(uname);
|
||||
}
|
||||
|
||||
@override
|
||||
PublisherHeatmapProvider getProviderOverride(
|
||||
covariant PublisherHeatmapProvider provider,
|
||||
) {
|
||||
return call(provider.uname);
|
||||
}
|
||||
|
||||
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'publisherHeatmapProvider';
|
||||
}
|
||||
|
||||
/// See also [publisherHeatmap].
|
||||
class PublisherHeatmapProvider extends AutoDisposeFutureProvider<SnHeatmap?> {
|
||||
/// See also [publisherHeatmap].
|
||||
PublisherHeatmapProvider(String? uname)
|
||||
: this._internal(
|
||||
(ref) => publisherHeatmap(ref as PublisherHeatmapRef, uname),
|
||||
from: publisherHeatmapProvider,
|
||||
name: r'publisherHeatmapProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$publisherHeatmapHash,
|
||||
dependencies: PublisherHeatmapFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
PublisherHeatmapFamily._allTransitiveDependencies,
|
||||
uname: uname,
|
||||
);
|
||||
|
||||
PublisherHeatmapProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.uname,
|
||||
}) : super.internal();
|
||||
|
||||
final String? uname;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<SnHeatmap?> Function(PublisherHeatmapRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: PublisherHeatmapProvider._internal(
|
||||
(ref) => create(ref as PublisherHeatmapRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
uname: uname,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<SnHeatmap?> createElement() {
|
||||
return _PublisherHeatmapProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is PublisherHeatmapProvider && other.uname == uname;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, uname.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin PublisherHeatmapRef on AutoDisposeFutureProviderRef<SnHeatmap?> {
|
||||
/// The parameter `uname` of this provider.
|
||||
String? get uname;
|
||||
}
|
||||
|
||||
class _PublisherHeatmapProviderElement
|
||||
extends AutoDisposeFutureProviderElement<SnHeatmap?>
|
||||
with PublisherHeatmapRef {
|
||||
_PublisherHeatmapProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String? get uname => (origin as PublisherHeatmapProvider).uname;
|
||||
}
|
||||
|
||||
String _$publisherIdentityHash() => r'299372f25fa4b2bf8e11a8ba2d645100fc38e76f';
|
||||
|
||||
/// See also [publisherIdentity].
|
||||
|
||||
@@ -84,6 +84,7 @@ class CreatorPollListScreen extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(title: const Text('Polls')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _createPoll(context),
|
||||
@@ -105,10 +106,13 @@ class CreatorPollListScreen extends HookConsumerWidget {
|
||||
return endItemView;
|
||||
}
|
||||
final pollWithStats = data.items[index];
|
||||
return _CreatorPollItem(
|
||||
pollWithStats: pollWithStats,
|
||||
pubName: pubName,
|
||||
);
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: _CreatorPollItem(
|
||||
pollWithStats: pollWithStats,
|
||||
pubName: pubName,
|
||||
),
|
||||
).center();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/post/post_list.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class CreatorPostListScreen extends HookConsumerWidget {
|
||||
final String pubName;
|
||||
@@ -16,62 +13,20 @@ class CreatorPostListScreen extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final refreshKey = useState(0);
|
||||
|
||||
void showCreatePostSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'create'.tr(),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.edit),
|
||||
title: Text('Post'),
|
||||
subtitle: Text('Create a regular post'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final result = await context.pushNamed(
|
||||
'postCompose',
|
||||
queryParameters: {'type': '0'},
|
||||
);
|
||||
if (result == true) {
|
||||
refreshKey.value++;
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.article),
|
||||
title: Text('Article'),
|
||||
subtitle: Text('Create a detailed article'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final result = await context.pushNamed(
|
||||
'postCompose',
|
||||
queryParameters: {'type': '1'},
|
||||
);
|
||||
if (result == true) {
|
||||
refreshKey.value++;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(title: Text('posts').tr()),
|
||||
body: CustomScrollView(
|
||||
key: ValueKey(refreshKey.value),
|
||||
slivers: [
|
||||
SliverPostList(pubName: pubName, itemType: PostItemType.creator),
|
||||
SliverPostList(
|
||||
pubName: pubName,
|
||||
itemType: PostItemType.creator,
|
||||
maxWidth: 640,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: showCreatePostSheet,
|
||||
child: const Icon(Symbols.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ class EditPublisherScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
title: Text(name == null ? 'createPublisher' : 'editPublisher').tr(),
|
||||
leading: const PageBackButton(),
|
||||
|
||||
@@ -8,13 +8,14 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/sticker.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/screens/creators/stickers/stickers.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_file_picker.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
@@ -33,13 +34,13 @@ Future<List<SnSticker>> stickerPackContent(Ref ref, String packId) async {
|
||||
.toList();
|
||||
}
|
||||
|
||||
class StickerPackDetailScreen extends HookConsumerWidget {
|
||||
class StickerPackDetailContent extends HookConsumerWidget {
|
||||
final String id;
|
||||
final String pubName;
|
||||
const StickerPackDetailScreen({
|
||||
const StickerPackDetailContent({
|
||||
super.key,
|
||||
required this.pubName,
|
||||
required this.id,
|
||||
required this.pubName,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -67,193 +68,170 @@ class StickerPackDetailScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(pack.value?.name ?? 'loading'.tr()),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.add_circle),
|
||||
onPressed: () {
|
||||
context
|
||||
.pushNamed(
|
||||
'creatorStickerNew',
|
||||
pathParameters: {'name': pubName, 'packId': id},
|
||||
)
|
||||
.then((value) {
|
||||
if (value != null) {
|
||||
ref.invalidate(stickerPackContentProvider(id));
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
_StickerPackActionMenu(
|
||||
pubName: pubName,
|
||||
packId: id,
|
||||
iconShadow: Shadow(),
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: pack.when(
|
||||
data:
|
||||
(pack) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(pack!.description),
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
const Icon(Symbols.folder, size: 16),
|
||||
Text(
|
||||
'${packContent.value?.length ?? 0}/24',
|
||||
style: GoogleFonts.robotoMono(),
|
||||
),
|
||||
],
|
||||
).opacity(0.85),
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
const Icon(Symbols.sell, size: 16),
|
||||
Text(pack.prefix, style: GoogleFonts.robotoMono()),
|
||||
],
|
||||
).opacity(0.85),
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
const Icon(Symbols.tag, size: 16),
|
||||
SelectableText(
|
||||
return pack.when(
|
||||
data:
|
||||
(pack) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(pack!.description),
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
const Icon(Symbols.folder, size: 16),
|
||||
Text(
|
||||
'${packContent.value?.length ?? 0}/24',
|
||||
style: GoogleFonts.robotoMono(),
|
||||
),
|
||||
],
|
||||
).opacity(0.85),
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
const Icon(Symbols.sell, size: 16),
|
||||
Text(pack.prefix, style: GoogleFonts.robotoMono()),
|
||||
],
|
||||
).opacity(0.85),
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
const Icon(Symbols.tag, size: 16),
|
||||
Flexible(
|
||||
child: SelectableText(
|
||||
pack.id,
|
||||
maxLines: 1,
|
||||
style: GoogleFonts.robotoMono(),
|
||||
),
|
||||
],
|
||||
).opacity(0.85),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 24),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: packContent.when(
|
||||
data:
|
||||
(stickers) => RefreshIndicator(
|
||||
onRefresh:
|
||||
() => ref.refresh(
|
||||
stickerPackContentProvider(id).future,
|
||||
),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 20,
|
||||
),
|
||||
],
|
||||
).opacity(0.85),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 24),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: packContent.when(
|
||||
data:
|
||||
(stickers) => RefreshIndicator(
|
||||
onRefresh:
|
||||
() => ref.refresh(
|
||||
stickerPackContentProvider(id).future,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 48,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
itemCount: stickers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sticker = stickers[index];
|
||||
return ContextMenuWidget(
|
||||
menuProvider: (_) {
|
||||
return Menu(
|
||||
children: [
|
||||
MenuAction(
|
||||
title: 'stickerCopyPlaceholder'.tr(),
|
||||
image: MenuImage.icon(Symbols.copy_all),
|
||||
callback: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text:
|
||||
':${pack.prefix}${sticker.slug}:',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuSeparator(),
|
||||
MenuAction(
|
||||
title: 'edit'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () {
|
||||
context
|
||||
.pushNamed(
|
||||
'creatorStickerEdit',
|
||||
pathParameters: {
|
||||
'name': pubName,
|
||||
'packId': id,
|
||||
'id': sticker.id,
|
||||
},
|
||||
)
|
||||
.then((value) {
|
||||
if (value != null) {
|
||||
ref.invalidate(
|
||||
stickerPackContentProvider(
|
||||
id,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
title: 'delete'.tr(),
|
||||
image: MenuImage.icon(Symbols.delete),
|
||||
callback: () {
|
||||
deleteSticker(sticker);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: CloudImageWidget(
|
||||
fileId: sticker.imageId,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 20,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 80,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
itemCount: stickers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sticker = stickers[index];
|
||||
return ContextMenuWidget(
|
||||
menuProvider: (_) {
|
||||
return Menu(
|
||||
children: [
|
||||
MenuAction(
|
||||
title: 'stickerCopyPlaceholder'.tr(),
|
||||
image: MenuImage.icon(Symbols.copy_all),
|
||||
callback: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text:
|
||||
':${pack.prefix}+${sticker.slug}:',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuSeparator(),
|
||||
MenuAction(
|
||||
title: 'edit'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'editSticker'.tr(),
|
||||
child: StickerForm(
|
||||
packId: id,
|
||||
id: sticker.id,
|
||||
),
|
||||
),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
ref.invalidate(
|
||||
stickerPackContentProvider(id),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
title: 'delete'.tr(),
|
||||
image: MenuImage.icon(Symbols.delete),
|
||||
callback: () {
|
||||
deleteSticker(sticker);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: CloudImageWidget(
|
||||
fileId: sticker.imageId,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
error:
|
||||
(err, _) =>
|
||||
Text(
|
||||
'Error: $err',
|
||||
).textAlignment(TextAlign.center).center(),
|
||||
loading: () => const CircularProgressIndicator().center(),
|
||||
),
|
||||
),
|
||||
error:
|
||||
(err, _) =>
|
||||
Text(
|
||||
'Error: $err',
|
||||
).textAlignment(TextAlign.center).center(),
|
||||
loading: () => const CircularProgressIndicator().center(),
|
||||
),
|
||||
],
|
||||
),
|
||||
error:
|
||||
(err, _) =>
|
||||
Text('Error: $err').textAlignment(TextAlign.center).center(),
|
||||
loading: () => const CircularProgressIndicator().center(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
error:
|
||||
(err, _) =>
|
||||
Text('Error: $err').textAlignment(TextAlign.center).center(),
|
||||
loading: () => const CircularProgressIndicator().center(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StickerPackActionMenu extends HookConsumerWidget {
|
||||
class StickerPackActionMenu extends HookConsumerWidget {
|
||||
final String pubName;
|
||||
final String packId;
|
||||
final Shadow iconShadow;
|
||||
|
||||
const _StickerPackActionMenu({
|
||||
const StickerPackActionMenu({
|
||||
super.key,
|
||||
required this.pubName,
|
||||
required this.packId,
|
||||
required this.iconShadow,
|
||||
@@ -267,7 +245,22 @@ class _StickerPackActionMenu extends HookConsumerWidget {
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
onTap: () {
|
||||
context.push('/creators/$pubName/stickers/$packId/edit');
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'editStickerPack'.tr(),
|
||||
child: StickerPackForm(
|
||||
pubName: pubName,
|
||||
packId: packId,
|
||||
),
|
||||
),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
ref.invalidate(stickerPackProvider(packId));
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -310,42 +303,10 @@ class _StickerPackActionMenu extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class StickerWithPackQuery with _$StickerWithPackQuery {
|
||||
const factory StickerWithPackQuery({
|
||||
required String packId,
|
||||
required String id,
|
||||
}) = _StickerWithPackQuery;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<SnSticker?> stickerPackSticker(
|
||||
Ref ref,
|
||||
StickerWithPackQuery? query,
|
||||
) async {
|
||||
if (query == null) return null;
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final resp = await apiClient.get(
|
||||
'/sphere/stickers/${query.packId}/content/${query.id}',
|
||||
);
|
||||
if (resp.data == null) return null;
|
||||
return SnSticker.fromJson(resp.data);
|
||||
}
|
||||
|
||||
class NewStickersScreen extends StatelessWidget {
|
||||
final String packId;
|
||||
const NewStickersScreen({super.key, required this.packId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return EditStickersScreen(packId: packId, id: null);
|
||||
}
|
||||
}
|
||||
|
||||
class EditStickersScreen extends HookConsumerWidget {
|
||||
class StickerForm extends HookConsumerWidget {
|
||||
final String packId;
|
||||
final String? id;
|
||||
const EditStickersScreen({super.key, required this.packId, required this.id});
|
||||
const StickerForm({super.key, required this.packId, this.id});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -395,87 +356,102 @@ class EditStickersScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(id == null ? 'createSticker' : 'editSticker').tr(),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 96,
|
||||
width: 96,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 80,
|
||||
width: 80,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child:
|
||||
(image.value?.isEmpty ?? true)
|
||||
? const SizedBox.shrink()
|
||||
: CloudImageWidget(fileId: image.value!),
|
||||
),
|
||||
child:
|
||||
(image.value?.isEmpty ?? true)
|
||||
? const SizedBox.shrink()
|
||||
: CloudImageWidget(fileId: image.value!),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: imageController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'stickerImage'.tr(),
|
||||
border: const UnderlineInputBorder(),
|
||||
suffix: InkWell(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => CloudFilePicker(),
|
||||
).then((value) {
|
||||
if (value == null) return;
|
||||
image.value = value[0].id;
|
||||
imageController.text = image.value!;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
child: const Icon(
|
||||
Symbols.cloud_upload,
|
||||
).padding(horizontal: 4),
|
||||
),
|
||||
),
|
||||
readOnly: true,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
TextFormField(
|
||||
controller: slugController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'stickerSlug'.tr(),
|
||||
helperText: 'stickerSlugHint'.tr(),
|
||||
border: const UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
],
|
||||
IconButton.filledTonal(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => CloudFilePicker(
|
||||
allowedTypes: {UniversalFileType.image},
|
||||
),
|
||||
).then((value) {
|
||||
if (value == null) return;
|
||||
image.value = value[0].id;
|
||||
imageController.text = image.value!;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Symbols.cloud_upload),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(16),
|
||||
Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: slugController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'stickerSlug'.tr(),
|
||||
helperText: 'stickerSlugHint'.tr(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
),
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: submitting.value ? null : submit,
|
||||
icon: const Icon(Symbols.save),
|
||||
label: Text(id == null ? 'create' : 'saveChanges').tr(),
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: submitting.value ? null : submit,
|
||||
icon: const Icon(Symbols.save),
|
||||
label: Text(id == null ? 'create' : 'saveChanges').tr(),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 24),
|
||||
);
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class StickerWithPackQuery with _$StickerWithPackQuery {
|
||||
const factory StickerWithPackQuery({
|
||||
required String packId,
|
||||
required String id,
|
||||
}) = _StickerWithPackQuery;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<SnSticker?> stickerPackSticker(
|
||||
Ref ref,
|
||||
StickerWithPackQuery? query,
|
||||
) async {
|
||||
if (query == null) return null;
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final resp = await apiClient.get(
|
||||
'/sphere/stickers/${query.packId}/content/${query.id}',
|
||||
);
|
||||
if (resp.data == null) return null;
|
||||
return SnSticker.fromJson(resp.data);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/sticker.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/screens/creators/stickers/pack_detail.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
@@ -22,29 +24,50 @@ class StickersScreen extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final content = SliverStickerPacksList(pubName: pubName);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
title: const Text('stickers').tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
context
|
||||
.pushNamed(
|
||||
'creatorStickerPackNew',
|
||||
pathParameters: {'name': pubName},
|
||||
)
|
||||
.then((value) {
|
||||
if (value != null) {
|
||||
ref.invalidate(stickerPacksNotifierProvider(pubName));
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: const Icon(Symbols.add_circle),
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
actions: [const Gap(8)],
|
||||
),
|
||||
body: SliverStickerPacksList(pubName: pubName),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'createStickerPack'.tr(),
|
||||
child: StickerPackForm(pubName: pubName),
|
||||
),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
ref.invalidate(stickerPacksNotifierProvider(pubName));
|
||||
}
|
||||
});
|
||||
},
|
||||
child: const Icon(Symbols.add),
|
||||
),
|
||||
body:
|
||||
isWideScreen(context)
|
||||
? Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
topRight: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
margin: const EdgeInsets.only(top: 16),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
)
|
||||
: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -70,13 +93,52 @@ class SliverStickerPacksList extends HookConsumerWidget {
|
||||
|
||||
final sticker = data.items[index];
|
||||
return ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
title: Text(sticker.name),
|
||||
subtitle: Text(sticker.description),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'creatorStickerPackDetail',
|
||||
pathParameters: {'name': pubName, 'packId': sticker.id},
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: sticker.name,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.add_circle),
|
||||
onPressed: () {
|
||||
final id = sticker.id;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'createSticker'.tr(),
|
||||
child: StickerForm(packId: id),
|
||||
),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
ref.invalidate(
|
||||
stickerPackContentProvider(id),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
StickerPackActionMenu(
|
||||
pubName: pubName,
|
||||
packId: sticker.id,
|
||||
iconShadow: Shadow(),
|
||||
),
|
||||
],
|
||||
child: StickerPackDetailContent(
|
||||
id: sticker.id,
|
||||
pubName: pubName,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -135,20 +197,10 @@ Future<SnStickerPack?> stickerPack(Ref ref, String? packId) async {
|
||||
return SnStickerPack.fromJson(resp.data);
|
||||
}
|
||||
|
||||
class NewStickerPacksScreen extends HookConsumerWidget {
|
||||
final String pubName;
|
||||
const NewStickerPacksScreen({super.key, required this.pubName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return EditStickerPacksScreen(pubName: pubName);
|
||||
}
|
||||
}
|
||||
|
||||
class EditStickerPacksScreen extends HookConsumerWidget {
|
||||
class StickerPackForm extends HookConsumerWidget {
|
||||
final String pubName;
|
||||
final String? packId;
|
||||
const EditStickerPacksScreen({super.key, required this.pubName, this.packId});
|
||||
const StickerPackForm({super.key, required this.pubName, this.packId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -187,7 +239,7 @@ class EditStickerPacksScreen extends HookConsumerWidget {
|
||||
options: Options(method: packId == null ? 'POST' : 'PATCH'),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
context.pop(SnStickerPack.fromJson(resp.data));
|
||||
Navigator.of(context).pop(SnStickerPack.fromJson(resp.data));
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
@@ -195,76 +247,76 @@ class EditStickerPacksScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title:
|
||||
Text(packId == null ? 'createStickerPack' : 'editStickerPack').tr(),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'name'.tr(),
|
||||
border: const UnderlineInputBorder(),
|
||||
return Column(
|
||||
children: [
|
||||
Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 16,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'name'.tr(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'fieldCannotBeEmpty'.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
TextFormField(
|
||||
controller: descriptionController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'description'.tr(),
|
||||
border: const UnderlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'fieldCannotBeEmpty'.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
TextFormField(
|
||||
controller: descriptionController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'description'.tr(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
minLines: 3,
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
TextFormField(
|
||||
controller: prefixController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'stickerPackPrefix'.tr(),
|
||||
border: const UnderlineInputBorder(),
|
||||
helperText: 'deleteStickerHint'.tr(),
|
||||
minLines: 3,
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
TextFormField(
|
||||
controller: prefixController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'stickerPackPrefix'.tr(),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'fieldCannotBeEmpty'.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
helperText: 'stickerPackPrefixHint'.tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'fieldCannotBeEmpty'.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: submitting.value ? null : submit,
|
||||
icon: const Icon(Symbols.save),
|
||||
label: Text(packId == null ? 'create'.tr() : 'saveChanges'.tr()),
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: submitting.value ? null : submit,
|
||||
icon: const Icon(Symbols.save),
|
||||
label: Text(packId == null ? 'create'.tr() : 'saveChanges'.tr()),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
);
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,25 +6,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/webfeed.dart';
|
||||
import 'package:island/pods/webfeed.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class WebFeedNewScreen extends StatelessWidget {
|
||||
final String pubName;
|
||||
const WebFeedNewScreen({super.key, required this.pubName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WebFeedEditScreen(pubName: pubName, feedId: null);
|
||||
}
|
||||
}
|
||||
|
||||
class WebFeedEditScreen extends HookConsumerWidget {
|
||||
class WebfeedForm extends HookConsumerWidget {
|
||||
final String pubName;
|
||||
final String? feedId;
|
||||
|
||||
const WebFeedEditScreen({super.key, required this.pubName, this.feedId});
|
||||
const WebfeedForm({super.key, required this.pubName, this.feedId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -113,14 +103,14 @@ class WebFeedEditScreen extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
return feedAsync.when(
|
||||
loading:
|
||||
() => const AppScaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, stack) => AppScaffold(
|
||||
appBar: AppBar(title: const Text('Error')),
|
||||
body: Center(child: Text('Error: $error')),
|
||||
(error, _) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry:
|
||||
() => ref.invalidate(
|
||||
webFeedNotifierProvider((pubName: pubName, feedId: feedId)),
|
||||
),
|
||||
),
|
||||
data: (feed) {
|
||||
// Initialize form fields if they're empty and we have a feed
|
||||
@@ -131,159 +121,142 @@ class WebFeedEditScreen extends HookConsumerWidget {
|
||||
isScrapEnabled.value = feed.config.scrapPage;
|
||||
}
|
||||
|
||||
return _buildForm(
|
||||
context,
|
||||
formKey: formKey,
|
||||
titleController: titleController,
|
||||
urlController: urlController,
|
||||
descriptionController: descriptionController,
|
||||
isScrapEnabled: isScrapEnabled.value,
|
||||
onScrapEnabledChanged: (value) => isScrapEnabled.value = value,
|
||||
onSave: saveFeed,
|
||||
onDelete: deleteFeed,
|
||||
isLoading: isLoading.value,
|
||||
ref: ref,
|
||||
hasFeedId: feedId != null,
|
||||
final scrapNow = useCallback(() async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await ref
|
||||
.read(
|
||||
webFeedNotifierProvider((
|
||||
pubName: pubName,
|
||||
feedId: feedId!,
|
||||
)).notifier,
|
||||
)
|
||||
.scrapFeed();
|
||||
|
||||
if (context.mounted) {
|
||||
showSnackBar('Feed scraping successfully.');
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}, [pubName, feedId, ref, context, isLoading]);
|
||||
|
||||
final formFields = Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a title';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: urlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'URL',
|
||||
hintText: 'https://example.com/feed',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a URL';
|
||||
}
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri == null || !uri.hasAbsolutePath) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
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: 24),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('Scrape web page for content'),
|
||||
subtitle: const Text(
|
||||
'When enabled, the system will attempt to extract full content from the web page',
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
value: isScrapEnabled.value,
|
||||
onChanged: (value) => isScrapEnabled.value = value,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (feedId != null) ...[
|
||||
TextButton.icon(
|
||||
onPressed: isLoading.value ? null : scrapNow,
|
||||
icon: const Icon(Symbols.refresh),
|
||||
label: const Text('Scrape Now'),
|
||||
).alignment(Alignment.centerRight),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
).padding(all: 20);
|
||||
|
||||
final formWidget = Form(
|
||||
key: formKey,
|
||||
child: SingleChildScrollView(child: formFields),
|
||||
);
|
||||
|
||||
final buttonsRow = Row(
|
||||
children: [
|
||||
if (feedId != null)
|
||||
TextButton.icon(
|
||||
onPressed: isLoading.value ? null : deleteFeed,
|
||||
icon: const Icon(Symbols.delete_forever),
|
||||
label: const Text('Delete Web Feed'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: isLoading.value ? null : saveFeed,
|
||||
icon: const Icon(Symbols.save),
|
||||
label: Text('saveChanges').tr(),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 12);
|
||||
|
||||
return Column(children: [Expanded(child: formWidget), buttonsRow]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm(
|
||||
BuildContext context, {
|
||||
required WidgetRef ref,
|
||||
required GlobalKey<FormState> formKey,
|
||||
required TextEditingController titleController,
|
||||
required TextEditingController urlController,
|
||||
required TextEditingController descriptionController,
|
||||
required bool isScrapEnabled,
|
||||
required ValueChanged<bool> onScrapEnabledChanged,
|
||||
required VoidCallback onSave,
|
||||
required VoidCallback onDelete,
|
||||
required bool isLoading,
|
||||
required bool hasFeedId,
|
||||
}) {
|
||||
final scrapNow = useCallback(() async {
|
||||
showLoadingModal(context);
|
||||
try {
|
||||
await ref
|
||||
.read(
|
||||
webFeedNotifierProvider((
|
||||
pubName: pubName,
|
||||
feedId: feedId!,
|
||||
)).notifier,
|
||||
)
|
||||
.scrapFeed();
|
||||
|
||||
if (context.mounted) {
|
||||
showSnackBar('Feed scraping successfully.');
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}, [pubName, feedId, ref, context]);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(hasFeedId ? 'Edit Web Feed' : 'New Web Feed'),
|
||||
actions: [
|
||||
if (hasFeedId)
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.delete_forever),
|
||||
onPressed: isLoading ? null : onDelete,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: titleController,
|
||||
decoration: const InputDecoration(labelText: 'Title'),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a title';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: urlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'URL',
|
||||
hintText: 'https://example.com/feed',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a URL';
|
||||
}
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri == null || !uri.hasAbsolutePath) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('Scrape web page for content'),
|
||||
subtitle: const Text(
|
||||
'When enabled, the system will attempt to extract full content from the web page',
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
value: isScrapEnabled,
|
||||
onChanged: onScrapEnabledChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (hasFeedId) ...[
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: isLoading ? null : scrapNow,
|
||||
icon: const Icon(Symbols.refresh),
|
||||
label: const Text('Scrape Now'),
|
||||
).alignment(Alignment.centerRight),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
FilledButton.icon(
|
||||
onPressed: isLoading ? null : onSave,
|
||||
icon: const Icon(Symbols.save),
|
||||
label: Text('saveChanges').tr(),
|
||||
).alignment(Alignment.centerRight),
|
||||
],
|
||||
).padding(all: 20),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:island/pods/webfeed.dart';
|
||||
import 'package:island/screens/creators/webfeed/webfeed_edit.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/empty_state.dart';
|
||||
import 'package:island/widgets/extended_refresh_indicator.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class WebFeedListScreen extends ConsumerWidget {
|
||||
final String pubName;
|
||||
@@ -17,13 +19,19 @@ class WebFeedListScreen extends ConsumerWidget {
|
||||
final feedsAsync = ref.watch(webFeedListProvider(pubName));
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(title: const Text('Web Feeds')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Symbols.add),
|
||||
onPressed: () {
|
||||
context.pushNamed(
|
||||
'creatorFeedNew',
|
||||
pathParameters: {'name': pubName},
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'New Web Feed',
|
||||
child: WebfeedForm(pubName: pubName, feedId: null),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -43,36 +51,47 @@ class WebFeedListScreen extends ConsumerWidget {
|
||||
itemCount: feeds.length,
|
||||
itemBuilder: (context, index) {
|
||||
final feed = feeds[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Symbols.rss_feed, size: 32),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
title: Text(
|
||||
feed.title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
feed.url,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'Edit Web Feed',
|
||||
child: WebfeedForm(
|
||||
pubName: pubName,
|
||||
feedId: feed.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Symbols.rss_feed, size: 32),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
title: Text(
|
||||
feed.title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
feed.url,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'creatorFeedEdit',
|
||||
pathParameters: {'name': pubName, 'feedId': feed.id},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
).center();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/custom_app.dart';
|
||||
@@ -32,6 +33,7 @@ class AppDetailScreen extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
title: Text(appData.value?.name ?? 'appDetails'.tr()),
|
||||
actions: [
|
||||
@@ -51,6 +53,7 @@ class AppDetailScreen extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: tabController,
|
||||
|
||||
@@ -30,6 +30,7 @@ class BotDetailScreen extends HookConsumerWidget {
|
||||
final botData = ref.watch(botProvider(publisherName, projectId, botId));
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
title: Text(botData.value?.account.nick ?? 'botDetails'.tr()),
|
||||
actions: [
|
||||
|
||||
@@ -317,6 +317,7 @@ class EditAppScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
title: Text(isNew ? 'createCustomApp'.tr() : 'editCustomApp'.tr()),
|
||||
),
|
||||
|
||||
@@ -192,6 +192,7 @@ class EditBotScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(title: Text(isNew ? 'createBot'.tr() : 'editBot'.tr())),
|
||||
body:
|
||||
botData == null && !isNew
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/dev_project.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/screens/developers/projects.dart';
|
||||
import 'package:island/screens/developers/hub.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -168,5 +168,125 @@ final developersProvider =
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef DevelopersRef = AutoDisposeFutureProviderRef<List<SnDeveloper>>;
|
||||
String _$devProjectsHash() => r'715b395bebda785d38691ffee3b88e50b498c91a';
|
||||
|
||||
/// See also [devProjects].
|
||||
@ProviderFor(devProjects)
|
||||
const devProjectsProvider = DevProjectsFamily();
|
||||
|
||||
/// See also [devProjects].
|
||||
class DevProjectsFamily extends Family<AsyncValue<List<DevProject>>> {
|
||||
/// See also [devProjects].
|
||||
const DevProjectsFamily();
|
||||
|
||||
/// See also [devProjects].
|
||||
DevProjectsProvider call(String pubName) {
|
||||
return DevProjectsProvider(pubName);
|
||||
}
|
||||
|
||||
@override
|
||||
DevProjectsProvider getProviderOverride(
|
||||
covariant DevProjectsProvider provider,
|
||||
) {
|
||||
return call(provider.pubName);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'devProjectsProvider';
|
||||
}
|
||||
|
||||
/// See also [devProjects].
|
||||
class DevProjectsProvider extends AutoDisposeFutureProvider<List<DevProject>> {
|
||||
/// See also [devProjects].
|
||||
DevProjectsProvider(String pubName)
|
||||
: this._internal(
|
||||
(ref) => devProjects(ref as DevProjectsRef, pubName),
|
||||
from: devProjectsProvider,
|
||||
name: r'devProjectsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$devProjectsHash,
|
||||
dependencies: DevProjectsFamily._dependencies,
|
||||
allTransitiveDependencies: DevProjectsFamily._allTransitiveDependencies,
|
||||
pubName: pubName,
|
||||
);
|
||||
|
||||
DevProjectsProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.pubName,
|
||||
}) : super.internal();
|
||||
|
||||
final String pubName;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<List<DevProject>> Function(DevProjectsRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: DevProjectsProvider._internal(
|
||||
(ref) => create(ref as DevProjectsRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
pubName: pubName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<List<DevProject>> createElement() {
|
||||
return _DevProjectsProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is DevProjectsProvider && other.pubName == pubName;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, pubName.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin DevProjectsRef on AutoDisposeFutureProviderRef<List<DevProject>> {
|
||||
/// The parameter `pubName` of this provider.
|
||||
String get pubName;
|
||||
}
|
||||
|
||||
class _DevProjectsProviderElement
|
||||
extends AutoDisposeFutureProviderElement<List<DevProject>>
|
||||
with DevProjectsRef {
|
||||
_DevProjectsProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get pubName => (origin as DevProjectsProvider).pubName;
|
||||
}
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/screens/developers/apps.dart';
|
||||
import 'package:island/screens/developers/bots.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class ProjectDetailScreen extends HookConsumerWidget {
|
||||
final String publisherName;
|
||||
final String projectId;
|
||||
|
||||
const ProjectDetailScreen({
|
||||
super.key,
|
||||
required this.publisherName,
|
||||
required this.projectId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabController = useTabController(initialLength: 2);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('projectDetails').tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.add),
|
||||
onPressed: () {
|
||||
// Get current tab index
|
||||
final index = tabController.index;
|
||||
switch (index) {
|
||||
case 0:
|
||||
context.pushNamed(
|
||||
'developerAppNew',
|
||||
pathParameters: {
|
||||
'name': publisherName,
|
||||
'projectId': projectId,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
context.pushNamed(
|
||||
'developerBotNew',
|
||||
pathParameters: {
|
||||
'name': publisherName,
|
||||
'projectId': projectId,
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: tabController,
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Text(
|
||||
'customApps'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
'bots'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
CustomAppsScreen(publisherName: publisherName, projectId: projectId),
|
||||
BotsScreen(publisherName: publisherName, projectId: projectId),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
106
lib/screens/developers/project_detail_view.dart
Normal file
106
lib/screens/developers/project_detail_view.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/dev_project.dart';
|
||||
import 'package:island/screens/developers/apps.dart';
|
||||
import 'package:island/screens/developers/bots.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
|
||||
class ProjectDetailView extends HookConsumerWidget {
|
||||
final String publisherName;
|
||||
final DevProject project;
|
||||
final VoidCallback onBackToHub;
|
||||
|
||||
const ProjectDetailView({
|
||||
super.key,
|
||||
required this.publisherName,
|
||||
required this.project,
|
||||
required this.onBackToHub,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabController = useTabController(initialLength: 2);
|
||||
|
||||
final isWide = isWideScreen(context);
|
||||
|
||||
if (isWide) {
|
||||
return Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Card(
|
||||
margin: const EdgeInsets.only(left: 16, bottom: 16, top: 12),
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, -56),
|
||||
child: NavigationRail(
|
||||
extended: isWiderScreen(context),
|
||||
scrollable: true,
|
||||
labelType:
|
||||
isWiderScreen(context)
|
||||
? null
|
||||
: NavigationRailLabelType.selected,
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedIndex: tabController.index,
|
||||
onDestinationSelected:
|
||||
(index) => tabController.animateTo(index),
|
||||
destinations: [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.apps),
|
||||
label: Text('customApps'.tr()),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.smart_toy),
|
||||
label: Text('bots'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
CustomAppsScreen(
|
||||
publisherName: publisherName,
|
||||
projectId: project.id,
|
||||
),
|
||||
BotsScreen(publisherName: publisherName, projectId: project.id),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: TabBar(
|
||||
dividerColor: Colors.transparent,
|
||||
controller: tabController,
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Text('customApps'.tr(), textAlign: TextAlign.center),
|
||||
),
|
||||
Tab(child: Text('bots'.tr(), textAlign: TextAlign.center)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
CustomAppsScreen(
|
||||
publisherName: publisherName,
|
||||
projectId: project.id,
|
||||
),
|
||||
BotsScreen(publisherName: publisherName, projectId: project.id),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/dev_project.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'projects.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<DevProject>> devProjects(Ref ref, String pubName) async {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final resp = await client.get('/develop/developers/$pubName/projects');
|
||||
return (resp.data as List)
|
||||
.map((e) => DevProject.fromJson(e))
|
||||
.cast<DevProject>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
class DevProjectsScreen extends HookConsumerWidget {
|
||||
final String publisherName;
|
||||
const DevProjectsScreen({super.key, required this.publisherName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final projects = ref.watch(devProjectsProvider(publisherName));
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('projects').tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.add),
|
||||
onPressed: () {
|
||||
context.pushNamed(
|
||||
'developerProjectNew',
|
||||
pathParameters: {'name': publisherName},
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: projects.when(
|
||||
data: (data) {
|
||||
if (data.isEmpty) {
|
||||
return Center(child: Text('noProjects').tr());
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh:
|
||||
() => ref.refresh(devProjectsProvider(publisherName).future),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
itemCount: data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final project = data[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(8.0),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
contentPadding: EdgeInsets.only(left: 20, right: 12),
|
||||
title: Text(project.name),
|
||||
subtitle: Text(project.description ?? ''),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.edit),
|
||||
const SizedBox(width: 12),
|
||||
Text('edit').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.delete, color: Colors.red),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'delete',
|
||||
style: TextStyle(color: Colors.red),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
context.pushNamed(
|
||||
'developerProjectEdit',
|
||||
pathParameters: {
|
||||
'name': publisherName,
|
||||
'id': project.id,
|
||||
},
|
||||
);
|
||||
} else if (value == 'delete') {
|
||||
showConfirmAlert(
|
||||
'deleteProjectHint'.tr(),
|
||||
'deleteProject'.tr(),
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.read(apiClientProvider);
|
||||
client.delete(
|
||||
'/develop/developers/$publisherName/projects/${project.id}',
|
||||
);
|
||||
ref.invalidate(
|
||||
devProjectsProvider(publisherName),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'developerProjectDetail',
|
||||
pathParameters: {
|
||||
'name': publisherName,
|
||||
'projectId': project.id,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(err, stack) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => ref.invalidate(devProjectsProvider(publisherName)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'projects.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$devProjectsHash() => r'87fdcab47cd7d79ab019a5625617abeb1ffa1f39';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
/// See also [devProjects].
|
||||
@ProviderFor(devProjects)
|
||||
const devProjectsProvider = DevProjectsFamily();
|
||||
|
||||
/// See also [devProjects].
|
||||
class DevProjectsFamily extends Family<AsyncValue<List<DevProject>>> {
|
||||
/// See also [devProjects].
|
||||
const DevProjectsFamily();
|
||||
|
||||
/// See also [devProjects].
|
||||
DevProjectsProvider call(String pubName) {
|
||||
return DevProjectsProvider(pubName);
|
||||
}
|
||||
|
||||
@override
|
||||
DevProjectsProvider getProviderOverride(
|
||||
covariant DevProjectsProvider provider,
|
||||
) {
|
||||
return call(provider.pubName);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'devProjectsProvider';
|
||||
}
|
||||
|
||||
/// See also [devProjects].
|
||||
class DevProjectsProvider extends AutoDisposeFutureProvider<List<DevProject>> {
|
||||
/// See also [devProjects].
|
||||
DevProjectsProvider(String pubName)
|
||||
: this._internal(
|
||||
(ref) => devProjects(ref as DevProjectsRef, pubName),
|
||||
from: devProjectsProvider,
|
||||
name: r'devProjectsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$devProjectsHash,
|
||||
dependencies: DevProjectsFamily._dependencies,
|
||||
allTransitiveDependencies: DevProjectsFamily._allTransitiveDependencies,
|
||||
pubName: pubName,
|
||||
);
|
||||
|
||||
DevProjectsProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.pubName,
|
||||
}) : super.internal();
|
||||
|
||||
final String pubName;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<List<DevProject>> Function(DevProjectsRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: DevProjectsProvider._internal(
|
||||
(ref) => create(ref as DevProjectsRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
pubName: pubName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<List<DevProject>> createElement() {
|
||||
return _DevProjectsProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is DevProjectsProvider && other.pubName == pubName;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, pubName.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin DevProjectsRef on AutoDisposeFutureProviderRef<List<DevProject>> {
|
||||
/// The parameter `pubName` of this provider.
|
||||
String get pubName;
|
||||
}
|
||||
|
||||
class _DevProjectsProviderElement
|
||||
extends AutoDisposeFutureProviderElement<List<DevProject>>
|
||||
with DevProjectsRef {
|
||||
_DevProjectsProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get pubName => (origin as DevProjectsProvider).pubName;
|
||||
}
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
@@ -20,6 +20,7 @@ class ArticleDetailScreen extends ConsumerWidget {
|
||||
final articleAsync = ref.watch(articleDetailProvider(articleId));
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
body: articleAsync.when(
|
||||
data:
|
||||
(article) => AppScaffold(
|
||||
|
||||
@@ -54,7 +54,11 @@ Widget notificationIndicatorWidget(
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.only(left: 16, right: 15),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('notifications');
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const NotificationScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -330,7 +334,7 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
),
|
||||
PostFeaturedList(),
|
||||
PostComposeCard(
|
||||
onSubmit: (post) {
|
||||
onSubmit: () {
|
||||
activitiesNotifier.forceRefresh();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
@@ -11,8 +10,8 @@ import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
@@ -128,19 +127,15 @@ class NotificationScreen extends HookConsumerWidget {
|
||||
ref.watch(notificationUnreadCountNotifierProvider.notifier).clear();
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: const Text('notifications').tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: markAllRead,
|
||||
icon: const Icon(Symbols.mark_as_unread),
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: PagingHelperView(
|
||||
return SheetScaffold(
|
||||
titleText: 'notifications'.tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: markAllRead,
|
||||
icon: const Icon(Symbols.mark_as_unread),
|
||||
),
|
||||
],
|
||||
child: PagingHelperView(
|
||||
provider: notificationListNotifierProvider,
|
||||
futureRefreshable: notificationListNotifierProvider.future,
|
||||
notifierRefreshable: notificationListNotifierProvider.notifier,
|
||||
|
||||
@@ -416,6 +416,7 @@ class PollEditorScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
title: Text(model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr()),
|
||||
actions: [
|
||||
@@ -433,177 +434,198 @@ class PollEditorScreen extends ConsumerWidget {
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Form(
|
||||
key: ValueKey(model.id),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
initialValue: model.title ?? '',
|
||||
decoration: InputDecoration(
|
||||
labelText: 'title'.tr(),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
maxLength: 256,
|
||||
onChanged: notifier.setTitle,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return 'pollTitleRequired'.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
TextFormField(
|
||||
initialValue: model.description ?? '',
|
||||
decoration: InputDecoration(
|
||||
labelText: 'description'.tr(),
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
maxLength: 4096,
|
||||
onChanged: notifier.setDescription,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
_EndDatePicker(
|
||||
value: model.endedAt,
|
||||
onChanged: notifier.setEndedAt,
|
||||
),
|
||||
const Gap(24),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'questions'.tr(),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
MenuAnchor(
|
||||
builder: (context, controller, child) {
|
||||
return FilledButton.icon(
|
||||
onPressed: () {
|
||||
controller.isOpen
|
||||
? controller.close()
|
||||
: controller.open();
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text('pollAddQuestion'.tr()),
|
||||
);
|
||||
},
|
||||
menuChildren:
|
||||
SnPollQuestionType.values
|
||||
.map(
|
||||
(t) => MenuItemButton(
|
||||
leadingIcon: Icon(_iconForType(t)),
|
||||
onPressed: () => notifier.addQuestion(t),
|
||||
child: Text(_labelForType(t)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
if (model.questions.isEmpty)
|
||||
_EmptyState(
|
||||
title: 'pollNoQuestionsYet'.tr(),
|
||||
subtitle: 'pollNoQuestionsHint'.tr(),
|
||||
)
|
||||
else
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: model.questions.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
// Convert to stepwise moves using provided functions
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final steps = newIndex - oldIndex;
|
||||
if (steps == 0) return;
|
||||
if (steps > 0) {
|
||||
for (int i = 0; i < steps; i++) {
|
||||
notifier.moveQuestionDown(oldIndex + i);
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i > steps; i--) {
|
||||
notifier.moveQuestionUp(oldIndex + i);
|
||||
}
|
||||
}
|
||||
},
|
||||
buildDefaultDragHandles: false,
|
||||
itemBuilder: (context, index) {
|
||||
final q = model.questions[index];
|
||||
return Card(
|
||||
key: ValueKey('q_$index'),
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
_QuestionHeader(
|
||||
index: index,
|
||||
question: q,
|
||||
onMoveUp:
|
||||
index > 0
|
||||
? () => notifier.moveQuestionUp(index)
|
||||
: null,
|
||||
onMoveDown:
|
||||
index < model.questions.length - 1
|
||||
? () => notifier.moveQuestionDown(index)
|
||||
: null,
|
||||
onDelete: () => notifier.removeQuestion(index),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child:
|
||||
Form(
|
||||
key: ValueKey(model.id),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
initialValue: model.title ?? '',
|
||||
decoration: InputDecoration(
|
||||
labelText: 'postTitle'.tr(),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(16),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _QuestionEditor(
|
||||
index: index,
|
||||
question: q,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
maxLength: 256,
|
||||
onChanged: notifier.setTitle,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return 'pollTitleRequired'.tr();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
TextFormField(
|
||||
initialValue: model.description ?? '',
|
||||
decoration: InputDecoration(
|
||||
labelText: 'description'.tr(),
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
maxLength: 4096,
|
||||
onChanged: notifier.setDescription,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
_EndDatePicker(
|
||||
value: model.endedAt,
|
||||
onChanged: notifier.setEndedAt,
|
||||
),
|
||||
const Gap(24),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'questions'.tr(),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
MenuAnchor(
|
||||
builder: (context, controller, child) {
|
||||
return FilledButton.icon(
|
||||
onPressed: () {
|
||||
controller.isOpen
|
||||
? controller.close()
|
||||
: controller.open();
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text('pollAddQuestion'.tr()),
|
||||
);
|
||||
},
|
||||
menuChildren:
|
||||
SnPollQuestionType.values
|
||||
.map(
|
||||
(t) => MenuItemButton(
|
||||
leadingIcon: Icon(_iconForType(t)),
|
||||
onPressed:
|
||||
() => notifier.addQuestion(t),
|
||||
child: Text(_labelForType(t)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
if (model.questions.isEmpty)
|
||||
_EmptyState(
|
||||
title: 'pollNoQuestionsYet'.tr(),
|
||||
subtitle: 'pollNoQuestionsHint'.tr(),
|
||||
)
|
||||
else
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: model.questions.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
// Convert to stepwise moves using provided functions
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final steps = newIndex - oldIndex;
|
||||
if (steps == 0) return;
|
||||
if (steps > 0) {
|
||||
for (int i = 0; i < steps; i++) {
|
||||
notifier.moveQuestionDown(oldIndex + i);
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i > steps; i--) {
|
||||
notifier.moveQuestionUp(oldIndex + i);
|
||||
}
|
||||
}
|
||||
},
|
||||
buildDefaultDragHandles: false,
|
||||
itemBuilder: (context, index) {
|
||||
final q = model.questions[index];
|
||||
return Card(
|
||||
key: ValueKey('q_$index'),
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
_QuestionHeader(
|
||||
index: index,
|
||||
question: q,
|
||||
onMoveUp:
|
||||
index > 0
|
||||
? () =>
|
||||
notifier.moveQuestionUp(index)
|
||||
: null,
|
||||
onMoveDown:
|
||||
index < model.questions.length - 1
|
||||
? () => notifier.moveQuestionDown(
|
||||
index,
|
||||
)
|
||||
: null,
|
||||
onDelete:
|
||||
() => notifier.removeQuestion(index),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _QuestionEditor(
|
||||
index: index,
|
||||
question: q,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(96),
|
||||
],
|
||||
),
|
||||
const Gap(96),
|
||||
],
|
||||
),
|
||||
).center(),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
elevation: 2,
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
label: Text('cancel'.tr()),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
_submitPoll(context, ref);
|
||||
},
|
||||
icon: const Icon(Icons.cloud_upload_outlined),
|
||||
label: Text(model.id == null ? 'create'.tr() : 'update'.tr()),
|
||||
),
|
||||
],
|
||||
).padding(
|
||||
horizontal: 24,
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
),
|
||||
child:
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
label: Text('cancel'.tr()),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
_submitPoll(context, ref);
|
||||
},
|
||||
icon: const Icon(Icons.cloud_upload_outlined),
|
||||
label: Text(
|
||||
model.id == null ? 'create'.tr() : 'update'.tr(),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(
|
||||
horizontal: 24,
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
),
|
||||
).center(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -8,19 +8,18 @@ import 'package:island/models/file.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/screens/creators/publishers_form.dart';
|
||||
import 'package:island/screens/posts/compose_article.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/screens/posts/post_detail.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/attachment_uploader.dart';
|
||||
import 'package:island/widgets/content/attachment_preview.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/post/compose_attachments.dart';
|
||||
import 'package:island/widgets/post/compose_form_fields.dart';
|
||||
import 'package:island/widgets/post/compose_info_banner.dart';
|
||||
import 'package:island/widgets/post/compose_settings_sheet.dart';
|
||||
import 'package:island/widgets/post/compose_shared.dart';
|
||||
import 'package:island/widgets/post/compose_toolbar.dart';
|
||||
import 'package:island/widgets/post/post_item.dart';
|
||||
import 'package:island/widgets/post/publishers_modal.dart';
|
||||
import 'package:island/screens/posts/post_detail.dart';
|
||||
import 'package:island/widgets/post/compose_settings_sheet.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
// DraftManagerSheet is now imported through compose_toolbar.dart
|
||||
import 'package:island/widgets/post/compose_toolbar.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
@@ -92,9 +91,6 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
return ArticleComposeScreen(originalPost: originalPost);
|
||||
}
|
||||
|
||||
// Otherwise, continue with regular post compose
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// When editing, preserve the original replied/forwarded post references
|
||||
final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost;
|
||||
final effectiveForwardedPost = forwardedPost ?? originalPost?.forwardedPost;
|
||||
@@ -210,109 +206,6 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildWideAttachmentGrid() {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemCount: state.attachments.value.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final progressMap = state.attachmentProgress.value;
|
||||
return AttachmentPreview(
|
||||
item: state.attachments.value[idx],
|
||||
progress: progressMap[idx],
|
||||
onRequestUpload: () async {
|
||||
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
state: state,
|
||||
index: idx,
|
||||
),
|
||||
);
|
||||
if (config != null) {
|
||||
await ComposeLogic.uploadAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
poolId: config.poolId,
|
||||
);
|
||||
}
|
||||
},
|
||||
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||
onUpdate:
|
||||
(value) => ComposeLogic.updateAttachment(state, value, idx),
|
||||
onMove: (delta) {
|
||||
state.attachments.value = ComposeLogic.moveAttachment(
|
||||
state.attachments.value,
|
||||
idx,
|
||||
delta,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildNarrowAttachmentList() {
|
||||
return Column(
|
||||
children: [
|
||||
for (var idx = 0; idx < state.attachments.value.length; idx++)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: () {
|
||||
final progressMap = state.attachmentProgress.value;
|
||||
return AttachmentPreview(
|
||||
item: state.attachments.value[idx],
|
||||
progress: progressMap[idx],
|
||||
onRequestUpload: () async {
|
||||
final config =
|
||||
await showModalBottomSheet<AttachmentUploadConfig>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
state: state,
|
||||
index: idx,
|
||||
),
|
||||
);
|
||||
if (config != null) {
|
||||
await ComposeLogic.uploadAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
poolId: config.poolId,
|
||||
);
|
||||
}
|
||||
},
|
||||
onDelete:
|
||||
() => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||
onUpdate:
|
||||
(value) =>
|
||||
ComposeLogic.updateAttachment(state, value, idx),
|
||||
onMove: (delta) {
|
||||
state.attachments.value = ComposeLogic.moveAttachment(
|
||||
state.attachments.value,
|
||||
idx,
|
||||
delta,
|
||||
);
|
||||
},
|
||||
);
|
||||
}(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Build UI
|
||||
return PopScope(
|
||||
onPopInvoked: (_) {
|
||||
if (originalPost == null) {
|
||||
@@ -362,7 +255,57 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Reply/Forward info section
|
||||
_buildInfoBanner(context),
|
||||
ComposeInfoBanner(
|
||||
originalPost: originalPost,
|
||||
replyingTo: repliedPost,
|
||||
forwardingTo: forwardedPost,
|
||||
onReferencePostTap: (context, post) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder:
|
||||
(context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
maxChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
builder:
|
||||
(context, scrollController) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(context).scaffoldBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(context).colorScheme.outline,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: PostItem(item: post),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Main content area
|
||||
Expanded(
|
||||
@@ -414,78 +357,27 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: state.titleController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'postTitle'.tr(),
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
style: theme.textTheme.titleMedium,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
ComposeFormFields(
|
||||
state: state,
|
||||
showPublisherAvatar: false,
|
||||
onPublisherTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder:
|
||||
(context) => const PublisherModal(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
state.currentPublisher.value = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: state.descriptionController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'postDescription'.tr(),
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.fromLTRB(
|
||||
8,
|
||||
4,
|
||||
8,
|
||||
12,
|
||||
),
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
// Content field with borderless design
|
||||
TextField(
|
||||
controller: state.contentController,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'postContent'.tr(),
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
|
||||
const Gap(8),
|
||||
|
||||
// Attachments preview
|
||||
if (state.attachments.value.isNotEmpty)
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = isWideScreen(context);
|
||||
return isWide
|
||||
? buildWideAttachmentGrid()
|
||||
: buildNarrowAttachmentList();
|
||||
},
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
ComposeAttachments(
|
||||
state: state,
|
||||
isCompact: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -503,262 +395,4 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoBanner(BuildContext context) {
|
||||
// When editing, preserve the original replied/forwarded post references
|
||||
final effectiveRepliedPost =
|
||||
initialState?.replyingTo ?? originalPost?.repliedPost;
|
||||
final effectiveForwardedPost =
|
||||
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
||||
|
||||
// Show editing banner when editing a post
|
||||
if (originalPost != null) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.edit,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'postEditing'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
),
|
||||
// Show reply/forward banners below editing banner if they exist
|
||||
if (effectiveRepliedPost != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.reply, size: 16),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postReplyingTo'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
_buildCompactReferencePost(context, effectiveRepliedPost),
|
||||
],
|
||||
).padding(all: 16),
|
||||
),
|
||||
if (effectiveForwardedPost != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.forward, size: 16),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postForwardingTo'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
_buildCompactReferencePost(context, effectiveForwardedPost),
|
||||
],
|
||||
).padding(all: 16),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Show banner for replies (including when editing a reply)
|
||||
if (effectiveRepliedPost != null) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.reply, size: 16),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postReplyingTo'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
_buildCompactReferencePost(context, effectiveRepliedPost),
|
||||
],
|
||||
).padding(all: 16),
|
||||
);
|
||||
}
|
||||
|
||||
// Show banner for forwards (including when editing a forward)
|
||||
if (effectiveForwardedPost != null) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.forward, size: 16),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postForwardingTo'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
_buildCompactReferencePost(context, effectiveForwardedPost),
|
||||
],
|
||||
).padding(all: 16),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildCompactReferencePost(BuildContext context, SnPost post) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder:
|
||||
(context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
maxChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
builder:
|
||||
(context, scrollController) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: PostItem(item: post),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ProfilePictureWidget(
|
||||
fileId: post.publisher.picture?.id,
|
||||
radius: 16,
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.publisher.nick,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (post.title?.isNotEmpty ?? false)
|
||||
Text(
|
||||
post.title!,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (post.content?.isNotEmpty ?? false)
|
||||
Text(
|
||||
post.content!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (post.attachments.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.attach_file,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postHasAttachments'.plural(post.attachments.length),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Symbols.open_in_full,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,20 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
|
||||
import 'package:island/screens/creators/publishers_form.dart';
|
||||
import 'package:island/screens/posts/post_detail.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/attachment_uploader.dart';
|
||||
import 'package:island/screens/posts/post_detail.dart';
|
||||
import 'package:island/widgets/content/attachment_preview.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:island/widgets/post/compose_form_fields.dart';
|
||||
import 'package:island/widgets/post/compose_shared.dart';
|
||||
import 'package:island/widgets/post/compose_settings_sheet.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:island/widgets/post/compose_toolbar.dart';
|
||||
import 'package:island/widgets/post/publishers_modal.dart';
|
||||
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
@@ -233,64 +232,20 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: state.titleController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'postTitle'.tr(),
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
style: theme.textTheme.titleMedium,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
TextField(
|
||||
controller: state.descriptionController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'postDescription'.tr(),
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
Expanded(
|
||||
child: KeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKeyEvent:
|
||||
(event) => ComposeLogic.handleKeyPress(
|
||||
event,
|
||||
state,
|
||||
ref,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
),
|
||||
child: TextField(
|
||||
controller: state.contentController,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'postContent'.tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
ComposeFormFields(
|
||||
state: state,
|
||||
showPublisherAvatar: false,
|
||||
onPublisherTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const PublisherModal(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
state.currentPublisher.value = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
// Attachments preview
|
||||
|
||||
@@ -490,14 +490,13 @@ class PostDetailScreen extends HookConsumerWidget {
|
||||
),
|
||||
if (user.value != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: postState
|
||||
.when(
|
||||
bottom: 16 + MediaQuery.of(context).padding.bottom,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child:
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 660),
|
||||
child: postState.when(
|
||||
data:
|
||||
(post) => PostQuickReply(
|
||||
parent: post!,
|
||||
@@ -509,13 +508,8 @@ class PostDetailScreen extends HookConsumerWidget {
|
||||
),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
)
|
||||
.padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
top: 8,
|
||||
horizontal: 16,
|
||||
),
|
||||
),
|
||||
).center(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/models/publisher.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/models/heatmap.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/color.dart';
|
||||
@@ -20,6 +21,7 @@ import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:island/widgets/post/post_list.dart';
|
||||
import 'package:island/widgets/activity_heatmap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:island/services/color_extraction.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
@@ -222,6 +224,32 @@ class _PublisherBioWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PublisherHeatmapWidget extends StatelessWidget {
|
||||
final AsyncValue<SnHeatmap?> heatmap;
|
||||
final bool forceDense;
|
||||
|
||||
const _PublisherHeatmapWidget({
|
||||
required this.heatmap,
|
||||
this.forceDense = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return heatmap.when(
|
||||
data:
|
||||
(data) =>
|
||||
data != null
|
||||
? ActivityHeatmapWidget(
|
||||
heatmap: data,
|
||||
forceDense: forceDense,
|
||||
).padding(horizontal: 8)
|
||||
: const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PublisherCategoryTabWidget extends StatelessWidget {
|
||||
final TabController categoryTabController;
|
||||
|
||||
@@ -292,6 +320,13 @@ Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async {
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<SnHeatmap?> publisherHeatmap(Ref ref, String uname) async {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final resp = await apiClient.get('/sphere/publishers/$uname/heatmap');
|
||||
return SnHeatmap.fromJson(resp.data);
|
||||
}
|
||||
|
||||
class PublisherProfileScreen extends HookConsumerWidget {
|
||||
final String name;
|
||||
const PublisherProfileScreen({super.key, required this.name});
|
||||
@@ -301,6 +336,7 @@ class PublisherProfileScreen extends HookConsumerWidget {
|
||||
final publisher = ref.watch(publisherProvider(name));
|
||||
final badges = ref.watch(publisherBadgesProvider(name));
|
||||
final subStatus = ref.watch(publisherSubscriptionStatusProvider(name));
|
||||
final heatmap = ref.watch(publisherHeatmapProvider(name));
|
||||
final appbarColor = ref.watch(
|
||||
publisherAppbarForcegroundColorProvider(name),
|
||||
);
|
||||
@@ -446,6 +482,10 @@ class PublisherProfileScreen extends HookConsumerWidget {
|
||||
),
|
||||
_PublisherVerificationWidget(data: data),
|
||||
_PublisherBioWidget(data: data),
|
||||
_PublisherHeatmapWidget(
|
||||
heatmap: heatmap,
|
||||
forceDense: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -517,6 +557,9 @@ class PublisherProfileScreen extends HookConsumerWidget {
|
||||
SliverToBoxAdapter(
|
||||
child: _PublisherBioWidget(data: data),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _PublisherHeatmapWidget(heatmap: heatmap),
|
||||
),
|
||||
SliverPostList(pubName: name, pinned: true),
|
||||
SliverToBoxAdapter(
|
||||
child: _PublisherCategoryTabWidget(
|
||||
|
||||
@@ -530,5 +530,126 @@ class _PublisherAppbarForcegroundColorProviderElement
|
||||
(origin as PublisherAppbarForcegroundColorProvider).pubName;
|
||||
}
|
||||
|
||||
String _$publisherHeatmapHash() => r'86db275ce3861a2855b5ec35fbfef85fc47b23a6';
|
||||
|
||||
/// See also [publisherHeatmap].
|
||||
@ProviderFor(publisherHeatmap)
|
||||
const publisherHeatmapProvider = PublisherHeatmapFamily();
|
||||
|
||||
/// See also [publisherHeatmap].
|
||||
class PublisherHeatmapFamily extends Family<AsyncValue<SnHeatmap?>> {
|
||||
/// See also [publisherHeatmap].
|
||||
const PublisherHeatmapFamily();
|
||||
|
||||
/// See also [publisherHeatmap].
|
||||
PublisherHeatmapProvider call(String uname) {
|
||||
return PublisherHeatmapProvider(uname);
|
||||
}
|
||||
|
||||
@override
|
||||
PublisherHeatmapProvider getProviderOverride(
|
||||
covariant PublisherHeatmapProvider provider,
|
||||
) {
|
||||
return call(provider.uname);
|
||||
}
|
||||
|
||||
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'publisherHeatmapProvider';
|
||||
}
|
||||
|
||||
/// See also [publisherHeatmap].
|
||||
class PublisherHeatmapProvider extends AutoDisposeFutureProvider<SnHeatmap?> {
|
||||
/// See also [publisherHeatmap].
|
||||
PublisherHeatmapProvider(String uname)
|
||||
: this._internal(
|
||||
(ref) => publisherHeatmap(ref as PublisherHeatmapRef, uname),
|
||||
from: publisherHeatmapProvider,
|
||||
name: r'publisherHeatmapProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$publisherHeatmapHash,
|
||||
dependencies: PublisherHeatmapFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
PublisherHeatmapFamily._allTransitiveDependencies,
|
||||
uname: uname,
|
||||
);
|
||||
|
||||
PublisherHeatmapProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.uname,
|
||||
}) : super.internal();
|
||||
|
||||
final String uname;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<SnHeatmap?> Function(PublisherHeatmapRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: PublisherHeatmapProvider._internal(
|
||||
(ref) => create(ref as PublisherHeatmapRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
uname: uname,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<SnHeatmap?> createElement() {
|
||||
return _PublisherHeatmapProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is PublisherHeatmapProvider && other.uname == uname;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, uname.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin PublisherHeatmapRef on AutoDisposeFutureProviderRef<SnHeatmap?> {
|
||||
/// The parameter `uname` of this provider.
|
||||
String get uname;
|
||||
}
|
||||
|
||||
class _PublisherHeatmapProviderElement
|
||||
extends AutoDisposeFutureProviderElement<SnHeatmap?>
|
||||
with PublisherHeatmapRef {
|
||||
_PublisherHeatmapProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get uname => (origin as PublisherHeatmapProvider).uname;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -784,6 +784,22 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Disable animation settings
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('settingsDisableAnimation').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
leading: const Icon(Symbols.animation),
|
||||
trailing: Switch(
|
||||
value: settings.disableAnimation,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(appSettingsNotifierProvider.notifier)
|
||||
.setDisableAnimation(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
// Desktop-specific settings
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -11,6 +12,16 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
final currentRouteProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
const kWideScreenRouteStart = 4;
|
||||
const kTabRoutes = [
|
||||
'/',
|
||||
'/chat',
|
||||
'/realms',
|
||||
'/account',
|
||||
'/creators',
|
||||
'/developers',
|
||||
];
|
||||
|
||||
class TabsScreen extends HookConsumerWidget {
|
||||
final Widget? child;
|
||||
const TabsScreen({super.key, this.child});
|
||||
@@ -32,6 +43,8 @@ class TabsScreen extends HookConsumerWidget {
|
||||
notificationUnreadCountNotifierProvider,
|
||||
);
|
||||
|
||||
final wideScreen = isWideScreen(context);
|
||||
|
||||
final destinations = [
|
||||
NavigationDestination(
|
||||
label: 'explore'.tr(),
|
||||
@@ -50,19 +63,30 @@ class TabsScreen extends HookConsumerWidget {
|
||||
child: const Icon(Symbols.account_circle),
|
||||
),
|
||||
),
|
||||
if (wideScreen)
|
||||
NavigationDestination(
|
||||
label: 'creatorHub'.tr(),
|
||||
icon: const Icon(Symbols.ink_pen),
|
||||
),
|
||||
if (wideScreen)
|
||||
NavigationDestination(
|
||||
label: 'developerHub'.tr(),
|
||||
icon: const Icon(Symbols.data_object),
|
||||
),
|
||||
];
|
||||
|
||||
final routes = ['/', '/chat', '/realms', '/account'];
|
||||
|
||||
int getCurrentIndex() {
|
||||
if (currentLocation.startsWith('/chat')) return 1;
|
||||
if (currentLocation.startsWith('/realms')) return 2;
|
||||
if (currentLocation.startsWith('/account')) return 3;
|
||||
return 0; // Default to explore
|
||||
if (currentLocation == '/') return 0;
|
||||
final idx = kTabRoutes.indexWhere(
|
||||
(p) => currentLocation.startsWith(p),
|
||||
1,
|
||||
);
|
||||
final value = math.max(idx, 0);
|
||||
return math.min(value, destinations.length - 1);
|
||||
}
|
||||
|
||||
void onDestinationSelected(int index) {
|
||||
context.go(routes[index]);
|
||||
context.go(kTabRoutes[index]);
|
||||
}
|
||||
|
||||
final currentIndex = getCurrentIndex();
|
||||
@@ -102,6 +126,7 @@ class TabsScreen extends HookConsumerWidget {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBody: true,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
|
||||
40
lib/services/autocomplete_service.dart
Normal file
40
lib/services/autocomplete_service.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:island/models/autocomplete_response.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
|
||||
final autocompleteServiceProvider = Provider<AutocompleteService>((ref) {
|
||||
final dio = ref.watch(apiClientProvider);
|
||||
return AutocompleteService(dio);
|
||||
});
|
||||
|
||||
class AutocompleteService {
|
||||
final Dio _client;
|
||||
|
||||
AutocompleteService(this._client);
|
||||
|
||||
Future<List<AutocompleteSuggestion>> getSuggestions(
|
||||
String roomId,
|
||||
String content,
|
||||
) async {
|
||||
final response = await _client.post(
|
||||
'/sphere/chat/$roomId/autocomplete',
|
||||
data: {'content': content},
|
||||
);
|
||||
|
||||
final data = response.data as List<dynamic>;
|
||||
return data.map((json) => AutocompleteSuggestion.fromJson(json)).toList();
|
||||
}
|
||||
|
||||
Future<List<AutocompleteSuggestion>> getGeneralSuggestions(
|
||||
String content,
|
||||
) async {
|
||||
final response = await _client.post(
|
||||
'/sphere/autocomplete',
|
||||
data: {'content': content},
|
||||
);
|
||||
|
||||
final data = response.data as List<dynamic>;
|
||||
return data.map((json) => AutocompleteSuggestion.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:native_exif/native_exif.dart';
|
||||
import 'package:path/path.dart' show extension;
|
||||
|
||||
class FileUploader {
|
||||
final Dio _client;
|
||||
@@ -276,7 +277,7 @@ class FileUploader {
|
||||
}
|
||||
|
||||
/// Gets the MIME type of a UniversalFile.
|
||||
static String getMimeType(UniversalFile file) {
|
||||
static String getMimeType(UniversalFile file, {bool useFallback = true}) {
|
||||
final data = file.data;
|
||||
if (data is XFile) {
|
||||
final mime = data.mimeType;
|
||||
@@ -293,6 +294,11 @@ class FileUploader {
|
||||
_ => 'application/unknown',
|
||||
};
|
||||
}
|
||||
if (useFallback) {
|
||||
final ext = extension(data.path).substring(1);
|
||||
if (ext.isNotEmpty) return 'application/$ext';
|
||||
return 'application/unknown';
|
||||
}
|
||||
throw Exception('Cannot detect mime type for file: $filename');
|
||||
} else if (data is List<int> || data is Uint8List) {
|
||||
return 'application/octet-stream';
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -11,6 +12,10 @@ import 'notify.universal.dart' as universal_notify;
|
||||
|
||||
// Platform-specific delegation
|
||||
Future<void> initializeLocalNotifications() async {
|
||||
if (kIsWeb) {
|
||||
// No local notifications on web
|
||||
return;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return windows_notify.initializeLocalNotifications();
|
||||
} else {
|
||||
@@ -18,10 +23,14 @@ Future<void> initializeLocalNotifications() async {
|
||||
}
|
||||
}
|
||||
|
||||
StreamSubscription setupNotificationListener(
|
||||
StreamSubscription? setupNotificationListener(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
if (kIsWeb) {
|
||||
// No notification listener on web
|
||||
return null;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return windows_notify.setupNotificationListener(context, ref);
|
||||
} else {
|
||||
@@ -33,6 +42,10 @@ Future<void> subscribePushNotification(
|
||||
Dio apiClient, {
|
||||
bool detailedErrors = false,
|
||||
}) async {
|
||||
if (kIsWeb) {
|
||||
// No push notification subscription on web
|
||||
return;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return windows_notify.subscribePushNotification(
|
||||
apiClient,
|
||||
|
||||
@@ -6,6 +6,25 @@ import 'package:island/models/wallet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
const Map<String, Color> kUsernamePlainColors = {
|
||||
'red': Colors.red,
|
||||
'blue': Colors.blue,
|
||||
'green': Colors.green,
|
||||
'yellow': Colors.yellow,
|
||||
'purple': Colors.purple,
|
||||
'orange': Colors.orange,
|
||||
'pink': Colors.pink,
|
||||
'cyan': Colors.cyan,
|
||||
'lime': Colors.lime,
|
||||
'indigo': Colors.indigo,
|
||||
'teal': Colors.teal,
|
||||
'amber': Colors.amber,
|
||||
'brown': Colors.brown,
|
||||
'grey': Colors.grey,
|
||||
'black': Colors.black,
|
||||
'white': Colors.white,
|
||||
};
|
||||
|
||||
const kVerificationMarkColors = [
|
||||
Colors.teal,
|
||||
Colors.blue,
|
||||
@@ -17,12 +36,175 @@ const kVerificationMarkColors = [
|
||||
class AccountName extends StatelessWidget {
|
||||
final SnAccount account;
|
||||
final TextStyle? style;
|
||||
const AccountName({super.key, required this.account, this.style});
|
||||
final bool ignorePermissions;
|
||||
const AccountName({
|
||||
super.key,
|
||||
required this.account,
|
||||
this.style,
|
||||
this.ignorePermissions = false,
|
||||
});
|
||||
|
||||
Alignment _parseGradientDirection(String direction) {
|
||||
switch (direction) {
|
||||
case 'to right':
|
||||
return Alignment.centerLeft;
|
||||
case 'to left':
|
||||
return Alignment.centerRight;
|
||||
case 'to bottom':
|
||||
return Alignment.topCenter;
|
||||
case 'to top':
|
||||
return Alignment.bottomCenter;
|
||||
case 'to bottom right':
|
||||
return Alignment.topLeft;
|
||||
case 'to bottom left':
|
||||
return Alignment.topRight;
|
||||
case 'to top right':
|
||||
return Alignment.bottomLeft;
|
||||
case 'to top left':
|
||||
return Alignment.bottomRight;
|
||||
default:
|
||||
return Alignment.centerLeft;
|
||||
}
|
||||
}
|
||||
|
||||
Alignment _parseGradientEnd(String direction) {
|
||||
switch (direction) {
|
||||
case 'to right':
|
||||
return Alignment.centerRight;
|
||||
case 'to left':
|
||||
return Alignment.centerLeft;
|
||||
case 'to bottom':
|
||||
return Alignment.bottomCenter;
|
||||
case 'to top':
|
||||
return Alignment.topCenter;
|
||||
case 'to bottom right':
|
||||
return Alignment.bottomRight;
|
||||
case 'to bottom left':
|
||||
return Alignment.bottomLeft;
|
||||
case 'to top right':
|
||||
return Alignment.topRight;
|
||||
case 'to top left':
|
||||
return Alignment.topLeft;
|
||||
default:
|
||||
return Alignment.centerRight;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var nameStyle = (style ?? TextStyle());
|
||||
if (account.perkSubscription != null) {
|
||||
|
||||
// Apply username color based on membership tier and custom settings
|
||||
if (account.profile.usernameColor != null) {
|
||||
final usernameColor = account.profile.usernameColor!;
|
||||
final tier = account.perkSubscription?.identifier;
|
||||
|
||||
// Check tier restrictions
|
||||
final canUseCustomColor =
|
||||
ignorePermissions ||
|
||||
switch (tier) {
|
||||
'solian.stellar.primary' =>
|
||||
usernameColor.type == 'plain' &&
|
||||
kUsernamePlainColors.containsKey(usernameColor.value),
|
||||
'solian.stellar.nova' => usernameColor.type == 'plain',
|
||||
'solian.stellar.supernova' => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (canUseCustomColor) {
|
||||
if (usernameColor.type == 'plain') {
|
||||
// Plain color
|
||||
Color? color;
|
||||
if (kUsernamePlainColors.containsKey(usernameColor.value)) {
|
||||
color = kUsernamePlainColors[usernameColor.value];
|
||||
} else if (usernameColor.value != null) {
|
||||
// Try to parse hex color
|
||||
try {
|
||||
color = Color(
|
||||
int.parse(
|
||||
usernameColor.value!.replaceFirst('#', ''),
|
||||
radix: 16,
|
||||
) +
|
||||
0xFF000000,
|
||||
);
|
||||
} catch (_) {
|
||||
// Invalid hex, ignore
|
||||
}
|
||||
}
|
||||
if (color != null) {
|
||||
nameStyle = nameStyle.copyWith(color: color);
|
||||
}
|
||||
} else if (usernameColor.type == 'gradient' &&
|
||||
usernameColor.colors != null &&
|
||||
usernameColor.colors!.isNotEmpty) {
|
||||
// Gradient - use ShaderMask for text gradient
|
||||
final colors = <Color>[];
|
||||
for (final colorStr in usernameColor.colors!) {
|
||||
Color? color;
|
||||
if (kUsernamePlainColors.containsKey(colorStr)) {
|
||||
color = kUsernamePlainColors[colorStr];
|
||||
} else {
|
||||
// Try to parse hex color
|
||||
try {
|
||||
color = Color(
|
||||
int.parse(colorStr.replaceFirst('#', ''), radix: 16) +
|
||||
0xFF000000,
|
||||
);
|
||||
} catch (_) {
|
||||
// Invalid hex, skip
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (color != null) {
|
||||
colors.add(color);
|
||||
}
|
||||
}
|
||||
|
||||
if (colors.isNotEmpty) {
|
||||
final gradient = LinearGradient(
|
||||
colors: colors,
|
||||
begin: _parseGradientDirection(
|
||||
usernameColor.direction ?? 'to right',
|
||||
),
|
||||
end: _parseGradientEnd(usernameColor.direction ?? 'to right'),
|
||||
);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ShaderMask(
|
||||
shaderCallback: (bounds) => gradient.createShader(bounds),
|
||||
child: Text(
|
||||
account.nick,
|
||||
style: nameStyle.copyWith(color: Colors.white),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (account.perkSubscription != null)
|
||||
StellarMembershipMark(membership: account.perkSubscription!),
|
||||
if (account.profile.verification != null)
|
||||
VerificationMark(mark: account.profile.verification!),
|
||||
if (account.automatedId != null)
|
||||
Tooltip(
|
||||
message: 'accountAutomated'.tr(),
|
||||
child: Icon(
|
||||
Symbols.smart_toy,
|
||||
size: 16,
|
||||
color: nameStyle.color,
|
||||
fill: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (account.perkSubscription != null) {
|
||||
// Default membership colors if no custom color is set
|
||||
nameStyle = nameStyle.copyWith(
|
||||
color: (switch (account.perkSubscription!.identifier) {
|
||||
'solian.stellar.primary' => Colors.blueAccent,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
@@ -6,14 +5,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/event_calendar.dart';
|
||||
import 'package:island/screens/account/profile.dart';
|
||||
import 'package:island/widgets/account/account_nameplate.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/account/event_calendar.dart';
|
||||
import 'package:island/widgets/account/fortune_graph.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class EventCalanderScreen extends HookConsumerWidget {
|
||||
/// A reusable content widget for event calendar that can be used in screens or sheets
|
||||
/// This widget manages the calendar state and displays the calendar and fortune graph
|
||||
class EventCalendarContent extends HookConsumerWidget {
|
||||
/// Username to fetch calendar for, null means current user ('me')
|
||||
final String name;
|
||||
const EventCalanderScreen({super.key, required this.name});
|
||||
|
||||
/// Whether this is being displayed in a sheet (affects layout)
|
||||
final bool isSheet;
|
||||
|
||||
const EventCalendarContent({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.isSheet = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -45,13 +54,37 @@ class EventCalanderScreen extends HookConsumerWidget {
|
||||
selectedDay.value = day;
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('eventCalander').tr(),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
if (isSheet) {
|
||||
// Sheet layout - simplified, no app bar, scrollable content
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Use the reusable EventCalendarWidget
|
||||
EventCalendarWidget(
|
||||
events: events,
|
||||
initialDate: now,
|
||||
showEventDetails: true,
|
||||
onMonthChanged: onMonthChanged,
|
||||
onDaySelected: onDaySelected,
|
||||
),
|
||||
|
||||
// Add the fortune graph widget
|
||||
const Divider(height: 1),
|
||||
FortuneGraphWidget(
|
||||
events: events,
|
||||
onPointSelected: onDaySelected,
|
||||
).padding(horizontal: 8, vertical: 4),
|
||||
|
||||
// Show user profile if viewing someone else's calendar
|
||||
if (name != 'me' && user.value != null)
|
||||
AccountNameplate(name: name),
|
||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Screen layout - with responsive design
|
||||
return SingleChildScrollView(
|
||||
child:
|
||||
MediaQuery.of(context).size.width > 480
|
||||
? ConstrainedBox(
|
||||
@@ -111,7 +144,7 @@ class EventCalanderScreen extends HookConsumerWidget {
|
||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,10 +76,17 @@ class EventDetailsWidget extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if ((getActivityTitle(status.label, status.meta) ?? status.label).isNotEmpty)
|
||||
Text(getActivityTitle(status.label, status.meta) ?? status.label),
|
||||
if ((getActivityTitle(status.label, status.meta) ??
|
||||
status.label)
|
||||
.isNotEmpty)
|
||||
Text(
|
||||
getActivityTitle(status.label, status.meta) ??
|
||||
status.label,
|
||||
),
|
||||
if (getActivitySubtitle(status.meta) != null)
|
||||
Text(getActivitySubtitle(status.meta)!).fontSize(11).opacity(0.8),
|
||||
Text(
|
||||
getActivitySubtitle(status.meta)!,
|
||||
).fontSize(11).opacity(0.8),
|
||||
Text(
|
||||
'${status.createdAt.formatSystem()} - ${status.clearedAt?.formatSystem() ?? 'present'.tr()}',
|
||||
).fontSize(11).opacity(0.8),
|
||||
@@ -92,7 +99,7 @@ class EventDetailsWidget extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
if (event?.checkInResult == null && (event?.statuses.isEmpty ?? true))
|
||||
Text('eventCalanderEmpty').tr(),
|
||||
Text('eventCalendarEmpty').tr(),
|
||||
],
|
||||
).padding(vertical: 24, horizontal: 24);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/activity.dart';
|
||||
import 'package:island/widgets/account/event_calendar_content.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
/// A widget that displays a graph of fortune levels over time
|
||||
@@ -24,7 +25,7 @@ class FortuneGraphWidget extends HookConsumerWidget {
|
||||
/// Callback when a point is selected
|
||||
final void Function(DateTime)? onPointSelected;
|
||||
|
||||
final String? eventCalanderUser;
|
||||
final String? eventCalandarUser;
|
||||
|
||||
final EdgeInsets? margin;
|
||||
|
||||
@@ -35,7 +36,7 @@ class FortuneGraphWidget extends HookConsumerWidget {
|
||||
this.maxWidth = double.infinity,
|
||||
this.height = 180,
|
||||
this.onPointSelected,
|
||||
this.eventCalanderUser,
|
||||
this.eventCalandarUser,
|
||||
this.margin,
|
||||
});
|
||||
|
||||
@@ -59,7 +60,7 @@ class FortuneGraphWidget extends HookConsumerWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('fortuneGraph').tr().fontSize(18).bold(),
|
||||
if (eventCalanderUser != null)
|
||||
if (eventCalandarUser != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.calendar_month, size: 20),
|
||||
visualDensity: const VisualDensity(
|
||||
@@ -69,9 +70,17 @@ class FortuneGraphWidget extends HookConsumerWidget {
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
context.pushNamed(
|
||||
'accountCalendar',
|
||||
pathParameters: {'name': eventCalanderUser!},
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'eventCalendar'.tr(),
|
||||
child: EventCalendarContent(
|
||||
name: eventCalandarUser!,
|
||||
isSheet: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
282
lib/widgets/activity_heatmap.dart
Normal file
282
lib/widgets/activity_heatmap.dart
Normal file
@@ -0,0 +1,282 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fl_heatmap/fl_heatmap.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/heatmap.dart';
|
||||
import '../services/responsive.dart';
|
||||
|
||||
/// 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.
|
||||
class ActivityHeatmapWidget extends HookConsumerWidget {
|
||||
final SnHeatmap heatmap;
|
||||
final bool forceDense;
|
||||
|
||||
const ActivityHeatmapWidget({
|
||||
super.key,
|
||||
required this.heatmap,
|
||||
this.forceDense = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedItem = useState<HeatmapItem?>(null);
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
final isWide = isWideScreen(context);
|
||||
final days = (isWide && !forceDense) ? 365 : 90;
|
||||
|
||||
// Start from exactly the selected days ago
|
||||
final startDate = now.subtract(Duration(days: days));
|
||||
// End at current date
|
||||
final endDate = now;
|
||||
|
||||
// Find monday of the week containing start date
|
||||
final startMonday = startDate.subtract(
|
||||
Duration(days: startDate.weekday - 1),
|
||||
);
|
||||
// Find sunday of the week containing end date
|
||||
final endSunday = endDate.add(Duration(days: 7 - endDate.weekday));
|
||||
|
||||
// Generate weeks to cover the selected date range
|
||||
final weeks = <DateTime>[];
|
||||
var current = startMonday;
|
||||
while (current.isBefore(endSunday) || current.isAtSameMomentAs(endSunday)) {
|
||||
weeks.add(current);
|
||||
current = current.add(const Duration(days: 7));
|
||||
}
|
||||
|
||||
// Create data map for all dates in the range
|
||||
final dataMap = <DateTime, double>{};
|
||||
for (final week in weeks) {
|
||||
for (var i = 0; i < 7; i++) {
|
||||
final date = week.add(Duration(days: i));
|
||||
// Only include dates within our selected range
|
||||
if (date.isAfter(startDate.subtract(const Duration(days: 1))) &&
|
||||
date.isBefore(endDate.add(const Duration(days: 1)))) {
|
||||
final item = heatmap.items.firstWhere(
|
||||
(e) =>
|
||||
e.date.year == date.year &&
|
||||
e.date.month == date.month &&
|
||||
e.date.day == date.day,
|
||||
orElse: () => SnHeatmapItem(date: date, count: 0),
|
||||
);
|
||||
dataMap[date] = item.count.toDouble();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate month labels for the top
|
||||
final monthLabels = <String>[];
|
||||
final monthPositions = <int>[];
|
||||
final processedMonths =
|
||||
<String>{}; // Track processed months to avoid duplicates
|
||||
|
||||
for (final week in weeks) {
|
||||
final monthKey = '${week.year}-${week.month.toString().padLeft(2, '0')}';
|
||||
|
||||
// Only process each month once
|
||||
if (!processedMonths.contains(monthKey)) {
|
||||
processedMonths.add(monthKey);
|
||||
|
||||
// Find which week this month starts in
|
||||
final firstDayOfMonth = DateTime(week.year, week.month, 1);
|
||||
final monthStartMonday = firstDayOfMonth.subtract(
|
||||
Duration(days: firstDayOfMonth.weekday - 1),
|
||||
);
|
||||
|
||||
final monthStartWeekIndex = weeks.indexWhere(
|
||||
(w) =>
|
||||
w.year == monthStartMonday.year &&
|
||||
w.month == monthStartMonday.month &&
|
||||
w.day == monthStartMonday.day,
|
||||
);
|
||||
|
||||
if (monthStartWeekIndex != -1) {
|
||||
monthLabels.add(_getMonthAbbreviation(week.month));
|
||||
monthPositions.add(monthStartWeekIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final heatmapData = HeatmapData(
|
||||
rows: [
|
||||
'Mon',
|
||||
'Tue',
|
||||
'Wed',
|
||||
'Thu',
|
||||
'Fri',
|
||||
'Sat',
|
||||
'Sun',
|
||||
], // Days of week vertically
|
||||
columns:
|
||||
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(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'activityHeatmap',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
).tr(),
|
||||
const Gap(8),
|
||||
// Month labels row
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 30), // Space for day labels
|
||||
...monthLabels.asMap().entries.map((entry) {
|
||||
final month = entry.value;
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
month,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
const Gap(4),
|
||||
Heatmap(
|
||||
heatmapData: heatmapData,
|
||||
rowsVisible: 7,
|
||||
showXAxisLabels: false,
|
||||
onItemSelectedListener: (item) {
|
||||
selectedItem.value = item;
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
// Legend
|
||||
Row(
|
||||
children: [
|
||||
if (selectedItem.value != null)
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: selectedItem.value!.value.toInt().toString(),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' activities on ',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
TextSpan(
|
||||
text: _formatDate(
|
||||
selectedItem.value!.xAxisLabel ?? '',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Less',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
// Color indicators (light to dark green)
|
||||
...[
|
||||
Colors.green.withOpacity(0.2),
|
||||
Colors.green.withOpacity(0.4),
|
||||
Colors.green.withOpacity(0.6),
|
||||
Colors.green.withOpacity(0.8),
|
||||
Colors.green,
|
||||
].map(
|
||||
(color) => Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'More',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getMonthAbbreviation(int month) {
|
||||
const monthNames = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
return monthNames[month - 1];
|
||||
}
|
||||
|
||||
String _formatDate(String dateString) {
|
||||
try {
|
||||
final date = DateTime.parse(dateString);
|
||||
final monthAbbrev = _getMonthAbbreviation(date.month);
|
||||
return '$monthAbbrev ${date.day}, ${date.year}';
|
||||
} catch (e) {
|
||||
return dateString; // Fallback to original string if parsing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/screens/tabs.dart';
|
||||
@@ -67,100 +69,154 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
final pageActionsButton = [
|
||||
if (router.canPop())
|
||||
IconButton(
|
||||
icon: Icon(Symbols.close),
|
||||
onPressed: router.canPop() ? () => router.pop() : null,
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(8),
|
||||
constraints: BoxConstraints(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: Icon(Symbols.home),
|
||||
onPressed: () => router.go('/'),
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(8),
|
||||
constraints: BoxConstraints(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
const Gap(8),
|
||||
];
|
||||
|
||||
if (!kIsWeb &&
|
||||
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Column(
|
||||
return Shortcuts(
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
DragToMoveArea(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment:
|
||||
Platform.isMacOS
|
||||
? MainAxisAlignment.center
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child:
|
||||
Platform.isMacOS
|
||||
? Text(
|
||||
'Solar Network',
|
||||
textAlign: TextAlign.center,
|
||||
).padding(horizontal: 12, vertical: 5)
|
||||
: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
Theme.of(context).brightness ==
|
||||
Brightness.dark
|
||||
? 'assets/icons/icon-dark.png'
|
||||
: 'assets/icons/icon.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
Column(
|
||||
children: [
|
||||
DragToMoveArea(
|
||||
child:
|
||||
Platform.isMacOS
|
||||
? Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isWideScreen(context))
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
...pageActionsButton,
|
||||
],
|
||||
)
|
||||
else
|
||||
SizedBox(height: 32),
|
||||
Text(
|
||||
'Solar Network',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Solar Network',
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
Theme.of(context).brightness ==
|
||||
Brightness.dark
|
||||
? 'assets/icons/icon-dark.png'
|
||||
: 'assets/icons/icon.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Solar Network',
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12, vertical: 5),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.minimize),
|
||||
onPressed: () => windowManager.minimize(),
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(8),
|
||||
constraints: BoxConstraints(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isMaximized.value
|
||||
? Symbols.fullscreen_exit
|
||||
: Symbols.fullscreen,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12, vertical: 5),
|
||||
),
|
||||
if (!Platform.isMacOS)
|
||||
...([
|
||||
IconButton(
|
||||
icon: Icon(Symbols.minimize),
|
||||
onPressed: () => windowManager.minimize(),
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(8),
|
||||
constraints: BoxConstraints(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isMaximized.value
|
||||
? Symbols.fullscreen_exit
|
||||
: Symbols.fullscreen,
|
||||
),
|
||||
onPressed: () async {
|
||||
if (await windowManager.isMaximized()) {
|
||||
windowManager.restore();
|
||||
} else {
|
||||
windowManager.maximize();
|
||||
}
|
||||
},
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(8),
|
||||
constraints: BoxConstraints(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.close),
|
||||
onPressed: () => windowManager.hide(),
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(8),
|
||||
constraints: BoxConstraints(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
onPressed: () async {
|
||||
if (await windowManager.isMaximized()) {
|
||||
windowManager.restore();
|
||||
} else {
|
||||
windowManager.maximize();
|
||||
}
|
||||
},
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(8),
|
||||
constraints: BoxConstraints(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.close),
|
||||
onPressed: () => windowManager.hide(),
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(8),
|
||||
constraints: BoxConstraints(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
Expanded(child: child),
|
||||
_WebSocketIndicator(),
|
||||
],
|
||||
),
|
||||
_WebSocketIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [Positioned.fill(child: child), _WebSocketIndicator()],
|
||||
return Shortcuts(
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [Positioned.fill(child: child), _WebSocketIndicator()],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -221,7 +277,7 @@ class AppScaffold extends HookConsumerWidget {
|
||||
final FloatingActionButtonAnimator? floatingActionButtonAnimator;
|
||||
final FloatingActionButtonLocation? floatingActionButtonLocation;
|
||||
final Widget? floatingActionButton;
|
||||
final AppBar? appBar;
|
||||
final PreferredSizeWidget? appBar;
|
||||
final DrawerCallback? onDrawerChanged;
|
||||
final DrawerCallback? onEndDrawerChanged;
|
||||
final bool? isNoBackground;
|
||||
@@ -258,48 +314,38 @@ class AppScaffold extends HookConsumerWidget {
|
||||
|
||||
final noBackground = isNoBackground ?? isWideScreen(context);
|
||||
|
||||
final content = Column(
|
||||
children: [
|
||||
IgnorePointer(
|
||||
child: SizedBox(height: appBar != null ? appBarHeight + safeTop : 0),
|
||||
),
|
||||
if (body != null) Expanded(child: body!),
|
||||
],
|
||||
);
|
||||
|
||||
return Shortcuts(
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{PopIntent: PopAction(context)},
|
||||
child: Focus(
|
||||
focusNode: focusNode,
|
||||
child: Scaffold(
|
||||
extendBody: extendBody ?? true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor:
|
||||
noBackground
|
||||
? Colors.transparent
|
||||
: Theme.of(context).scaffoldBackgroundColor,
|
||||
body:
|
||||
noBackground
|
||||
? content
|
||||
: AppBackground(isRoot: true, child: content),
|
||||
appBar: appBar,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
bottomSheet: bottomSheet,
|
||||
drawer: drawer,
|
||||
endDrawer: endDrawer,
|
||||
floatingActionButton: floatingActionButton,
|
||||
floatingActionButtonAnimator: floatingActionButtonAnimator,
|
||||
floatingActionButtonLocation: TabbedFabLocation(context),
|
||||
onDrawerChanged: onDrawerChanged,
|
||||
onEndDrawerChanged: onEndDrawerChanged,
|
||||
),
|
||||
final builtWidget = Focus(
|
||||
focusNode: focusNode,
|
||||
child: Scaffold(
|
||||
extendBody: extendBody ?? true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Column(
|
||||
children: [
|
||||
IgnorePointer(
|
||||
child: SizedBox(
|
||||
height: appBar != null ? appBarHeight + safeTop : 0,
|
||||
),
|
||||
),
|
||||
if (body != null) Expanded(child: body!),
|
||||
],
|
||||
),
|
||||
appBar: appBar,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
bottomSheet: bottomSheet,
|
||||
drawer: drawer,
|
||||
endDrawer: endDrawer,
|
||||
floatingActionButton: floatingActionButton,
|
||||
floatingActionButtonAnimator: floatingActionButtonAnimator,
|
||||
floatingActionButtonLocation: TabbedFabLocation(context),
|
||||
onDrawerChanged: onDrawerChanged,
|
||||
onEndDrawerChanged: onEndDrawerChanged,
|
||||
),
|
||||
);
|
||||
|
||||
return noBackground
|
||||
? builtWidget
|
||||
: AppBackground(isRoot: true, child: builtWidget);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,14 +354,14 @@ class PopIntent extends Intent {
|
||||
}
|
||||
|
||||
class PopAction extends Action<PopIntent> {
|
||||
final BuildContext context;
|
||||
final WidgetRef ref;
|
||||
|
||||
PopAction(this.context);
|
||||
PopAction(this.ref);
|
||||
|
||||
@override
|
||||
void invoke(PopIntent intent) {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
if (ref.watch(routerProvider).canPop()) {
|
||||
ref.read(routerProvider).pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,6 +374,11 @@ class PageBackButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop =
|
||||
!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows);
|
||||
|
||||
if (isDesktop && isWideScreen(context)) return const SizedBox.shrink();
|
||||
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onWillPop?.call();
|
||||
@@ -339,9 +390,11 @@ class PageBackButton extends StatelessWidget {
|
||||
},
|
||||
icon: Icon(
|
||||
color: color,
|
||||
(!kIsWeb && (Platform.isMacOS || Platform.isIOS))
|
||||
? Symbols.arrow_back_ios_new
|
||||
: Symbols.arrow_back,
|
||||
context.canPop()
|
||||
? (!kIsWeb && (Platform.isMacOS || Platform.isIOS))
|
||||
? Symbols.arrow_back_ios_new
|
||||
: Symbols.arrow_back
|
||||
: Symbols.home,
|
||||
shadows: shadows,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,19 +3,28 @@ import "package:easy_localization/easy_localization.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:flutter_typeahead/flutter_typeahead.dart";
|
||||
import "package:gap/gap.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:image_picker/image_picker.dart";
|
||||
import "package:island/models/account.dart";
|
||||
import "package:island/models/autocomplete_response.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/models/file.dart";
|
||||
import "package:island/models/publisher.dart";
|
||||
import "package:island/models/realm.dart";
|
||||
import "package:island/models/sticker.dart";
|
||||
import "package:island/pods/config.dart";
|
||||
import "package:island/services/autocomplete_service.dart";
|
||||
import "package:island/services/responsive.dart";
|
||||
import "package:island/widgets/content/attachment_preview.dart";
|
||||
import "package:island/widgets/content/cloud_files.dart";
|
||||
import "package:island/widgets/shared/upload_menu.dart";
|
||||
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||
import "package:pasteboard/pasteboard.dart";
|
||||
import "package:styled_widget/styled_widget.dart";
|
||||
import "package:material_symbols_icons/symbols.dart";
|
||||
import "package:island/widgets/stickers/picker.dart";
|
||||
import "package:island/widgets/stickers/sticker_picker.dart";
|
||||
import "package:island/pods/chat/chat_subscribe.dart";
|
||||
|
||||
class ChatInput extends HookConsumerWidget {
|
||||
@@ -24,6 +33,9 @@ class ChatInput extends HookConsumerWidget {
|
||||
final VoidCallback onSend;
|
||||
final VoidCallback onClear;
|
||||
final Function(bool isPhoto) onPickFile;
|
||||
final VoidCallback onPickAudio;
|
||||
final VoidCallback onPickGeneralFile;
|
||||
final VoidCallback? onLinkAttachment;
|
||||
final SnChatMessage? messageReplyingTo;
|
||||
final SnChatMessage? messageForwardingTo;
|
||||
final SnChatMessage? messageEditingTo;
|
||||
@@ -41,6 +53,9 @@ class ChatInput extends HookConsumerWidget {
|
||||
required this.onSend,
|
||||
required this.onClear,
|
||||
required this.onPickFile,
|
||||
required this.onPickAudio,
|
||||
required this.onPickGeneralFile,
|
||||
this.onLinkAttachment,
|
||||
required this.messageReplyingTo,
|
||||
required this.messageForwardingTo,
|
||||
required this.messageEditingTo,
|
||||
@@ -58,10 +73,8 @@ class ChatInput extends HookConsumerWidget {
|
||||
final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id));
|
||||
|
||||
void send() {
|
||||
inputFocusNode.requestFocus();
|
||||
onSend.call();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
inputFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void insertNewLine() {
|
||||
@@ -105,6 +118,8 @@ class ChatInput extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final settings = ref.watch(appSettingsNotifierProvider);
|
||||
|
||||
inputFocusNode.onKeyEvent = (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
@@ -118,7 +133,7 @@ class ChatInput extends HookConsumerWidget {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend;
|
||||
final enterToSend = settings.enterToSend;
|
||||
final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
|
||||
|
||||
if (isEnter) {
|
||||
@@ -210,86 +225,200 @@ class ChatInput extends HookConsumerWidget {
|
||||
key: ValueKey('typing-indicator-none'),
|
||||
),
|
||||
),
|
||||
if (attachments.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 180,
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: attachments.length,
|
||||
itemBuilder: (context, idx) {
|
||||
return SizedBox(
|
||||
width: 180,
|
||||
child: AttachmentPreview(
|
||||
isCompact: true,
|
||||
item: attachments[idx],
|
||||
progress: attachmentProgress['chat-upload']?[idx],
|
||||
onRequestUpload: () => onUploadAttachment(idx),
|
||||
onDelete: () => onDeleteAttachment(idx),
|
||||
onUpdate: (value) {
|
||||
attachments[idx] = value;
|
||||
onAttachmentsChanged(attachments);
|
||||
},
|
||||
onMove: (delta) => onMoveAttachment(idx, delta),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, _) => const Gap(8),
|
||||
),
|
||||
).padding(vertical: 12),
|
||||
if (messageReplyingTo != null ||
|
||||
messageForwardingTo != null ||
|
||||
messageEditingTo != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
margin: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 8,
|
||||
bottom: 4,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
messageReplyingTo != null
|
||||
? Symbols.reply
|
||||
: messageForwardingTo != null
|
||||
? Symbols.forward
|
||||
: Symbols.edit,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.1),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: SizeTransition(
|
||||
sizeFactor: animation,
|
||||
axisAlignment: -1.0,
|
||||
child: child,
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
messageReplyingTo != null
|
||||
? 'Replying to ${messageReplyingTo?.sender.account.nick}'
|
||||
: messageForwardingTo != null
|
||||
? 'Forwarding message'
|
||||
: 'Editing message',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
},
|
||||
child:
|
||||
attachments.isNotEmpty
|
||||
? SizedBox(
|
||||
key: ValueKey('attachments-${attachments.length}'),
|
||||
height: 180,
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: attachments.length,
|
||||
itemBuilder: (context, idx) {
|
||||
return SizedBox(
|
||||
width: 180,
|
||||
child: AttachmentPreview(
|
||||
isCompact: true,
|
||||
item: attachments[idx],
|
||||
progress:
|
||||
attachmentProgress['chat-upload']?[idx],
|
||||
onRequestUpload:
|
||||
() => onUploadAttachment(idx),
|
||||
onDelete: () => onDeleteAttachment(idx),
|
||||
onUpdate: (value) {
|
||||
attachments[idx] = value;
|
||||
onAttachmentsChanged(attachments);
|
||||
},
|
||||
onMove:
|
||||
(delta) => onMoveAttachment(idx, delta),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, _) => const Gap(8),
|
||||
),
|
||||
).padding(vertical: 12)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('no-attachments'),
|
||||
),
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, -0.2),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: SizeTransition(
|
||||
sizeFactor: animation,
|
||||
axisAlignment: -1.0,
|
||||
child: child,
|
||||
),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: InkWell(
|
||||
onTap: onClear,
|
||||
child: const Icon(Icons.close, size: 20).center(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child:
|
||||
(messageReplyingTo != null ||
|
||||
messageForwardingTo != null ||
|
||||
messageEditingTo != null)
|
||||
? Container(
|
||||
key: ValueKey(
|
||||
messageReplyingTo?.id ??
|
||||
messageForwardingTo?.id ??
|
||||
messageEditingTo?.id ??
|
||||
'action',
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
margin: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
messageReplyingTo != null
|
||||
? Symbols.reply
|
||||
: messageForwardingTo != null
|
||||
? Symbols.forward
|
||||
: Symbols.edit,
|
||||
size: 18,
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
messageReplyingTo != null
|
||||
? 'chatReplyingTo'.tr(
|
||||
args: [
|
||||
messageReplyingTo
|
||||
?.sender
|
||||
.account
|
||||
.nick ??
|
||||
'unknown'.tr(),
|
||||
],
|
||||
)
|
||||
: messageForwardingTo != null
|
||||
? 'chatForwarding'.tr()
|
||||
: 'chatEditing'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall!.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: onClear,
|
||||
tooltip: 'clear'.tr(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (messageReplyingTo != null ||
|
||||
messageForwardingTo != null ||
|
||||
messageEditingTo != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 6,
|
||||
left: 26,
|
||||
),
|
||||
child: Text(
|
||||
(messageReplyingTo ??
|
||||
messageForwardingTo ??
|
||||
messageEditingTo)
|
||||
?.content ??
|
||||
'chatNoContent'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall!.copyWith(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('no-action')),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -336,66 +465,191 @@ class ChatInput extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Symbols.photo_library),
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
onTap: () => onPickFile(true),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Icon(Symbols.photo),
|
||||
Text('addPhoto').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => onPickFile(false),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Icon(Symbols.video_call),
|
||||
Text('addVideo').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
UploadMenu(
|
||||
items: [
|
||||
UploadMenuItemData(
|
||||
Symbols.add_a_photo,
|
||||
'addPhoto',
|
||||
() => onPickFile(true),
|
||||
),
|
||||
UploadMenuItemData(
|
||||
Symbols.videocam,
|
||||
'addVideo',
|
||||
() => onPickFile(false),
|
||||
),
|
||||
UploadMenuItemData(
|
||||
Symbols.mic,
|
||||
'addAudio',
|
||||
onPickAudio,
|
||||
),
|
||||
UploadMenuItemData(
|
||||
Symbols.file_upload,
|
||||
'uploadFile',
|
||||
onPickGeneralFile,
|
||||
),
|
||||
if (onLinkAttachment != null)
|
||||
UploadMenuItemData(
|
||||
Symbols.attach_file,
|
||||
'linkAttachment',
|
||||
onLinkAttachment!,
|
||||
),
|
||||
],
|
||||
iconColor: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
focusNode: inputFocusNode,
|
||||
child: TypeAheadField<AutocompleteSuggestion>(
|
||||
controller: messageController,
|
||||
keyboardType: TextInputType.multiline,
|
||||
decoration: InputDecoration(
|
||||
hintMaxLines: 1,
|
||||
hintText:
|
||||
(chatRoom.type == 1 && chatRoom.name == null)
|
||||
? 'chatDirectMessageHint'.tr(
|
||||
args: [
|
||||
chatRoom.members!
|
||||
.map((e) => e.account.nick)
|
||||
.join(', '),
|
||||
],
|
||||
)
|
||||
: 'chatMessageHint'.tr(args: [chatRoom.name!]),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
counterText:
|
||||
messageController.text.length > 1024
|
||||
? '${messageController.text.length}/4096'
|
||||
: null,
|
||||
),
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
focusNode: inputFocusNode,
|
||||
builder: (context, controller, focusNode) {
|
||||
return TextField(
|
||||
focusNode: focusNode,
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.multiline,
|
||||
decoration: InputDecoration(
|
||||
hintMaxLines: 1,
|
||||
hintText:
|
||||
(chatRoom.type == 1 && chatRoom.name == null)
|
||||
? 'chatDirectMessageHint'.tr(
|
||||
args: [
|
||||
chatRoom.members!
|
||||
.map((e) => e.account.nick)
|
||||
.join(', '),
|
||||
],
|
||||
)
|
||||
: 'chatMessageHint'.tr(
|
||||
args: [chatRoom.name!],
|
||||
),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
counterText:
|
||||
messageController.text.length > 1024
|
||||
? '${messageController.text.length}/4096'
|
||||
: null,
|
||||
),
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
textInputAction:
|
||||
settings.enterToSend
|
||||
? TextInputAction.send
|
||||
: null,
|
||||
onSubmitted:
|
||||
settings.enterToSend ? (_) => send() : null,
|
||||
);
|
||||
},
|
||||
suggestionsCallback: (pattern) async {
|
||||
// Only trigger on @ or :
|
||||
final atIndex = pattern.lastIndexOf('@');
|
||||
final colonIndex = pattern.lastIndexOf(':');
|
||||
final triggerIndex =
|
||||
atIndex > colonIndex ? atIndex : colonIndex;
|
||||
if (triggerIndex == -1) return [];
|
||||
final chopped = pattern.substring(triggerIndex);
|
||||
if (chopped.contains(' ')) return [];
|
||||
final service = ref.read(autocompleteServiceProvider);
|
||||
try {
|
||||
return await service.getSuggestions(
|
||||
chatRoom.id,
|
||||
chopped,
|
||||
);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, suggestion) {
|
||||
String title = 'unknown'.tr();
|
||||
Widget leading = Icon(Symbols.help);
|
||||
switch (suggestion.type) {
|
||||
case 'user':
|
||||
final user = SnAccount.fromJson(suggestion.data);
|
||||
title = user.nick;
|
||||
leading = ProfilePictureWidget(
|
||||
file: user.profile.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'chatroom':
|
||||
final chatRoom = SnChatRoom.fromJson(
|
||||
suggestion.data,
|
||||
);
|
||||
title = chatRoom.name ?? 'Chat Room';
|
||||
leading = ProfilePictureWidget(
|
||||
file: chatRoom.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'realm':
|
||||
final realm = SnRealm.fromJson(suggestion.data);
|
||||
title = realm.name;
|
||||
leading = ProfilePictureWidget(
|
||||
file: realm.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'publisher':
|
||||
final publisher = SnPublisher.fromJson(
|
||||
suggestion.data,
|
||||
);
|
||||
title = publisher.name;
|
||||
leading = ProfilePictureWidget(
|
||||
file: publisher.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'sticker':
|
||||
final sticker = SnSticker.fromJson(suggestion.data);
|
||||
title = sticker.slug;
|
||||
leading = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CloudImageWidget(
|
||||
fileId: sticker.imageId,
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
return ListTile(
|
||||
leading: leading,
|
||||
title: Text(title),
|
||||
subtitle: Text(suggestion.keyword),
|
||||
dense: true,
|
||||
);
|
||||
},
|
||||
onSelected: (suggestion) {
|
||||
final text = messageController.text;
|
||||
final atIndex = text.lastIndexOf('@');
|
||||
final colonIndex = text.lastIndexOf(':');
|
||||
final triggerIndex =
|
||||
atIndex > colonIndex ? atIndex : colonIndex;
|
||||
if (triggerIndex == -1) return;
|
||||
final newText = text.replaceRange(
|
||||
triggerIndex,
|
||||
text.length,
|
||||
suggestion.keyword,
|
||||
);
|
||||
messageController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: triggerIndex + suggestion.keyword.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
direction: VerticalDirection.up,
|
||||
hideOnEmpty: true,
|
||||
hideOnLoading: true,
|
||||
debounceDuration: const Duration(milliseconds: 1000),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
|
||||
205
lib/widgets/chat/chat_link_attachments.dart
Normal file
205
lib/widgets/chat/chat_link_attachments.dart
Normal file
@@ -0,0 +1,205 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
part 'chat_link_attachments.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ChatCloudFileListNotifier extends _$ChatCloudFileListNotifier
|
||||
with CursorPagingNotifierMixin<SnCloudFile> {
|
||||
@override
|
||||
Future<CursorPagingData<SnCloudFile>> build() => fetch(cursor: null);
|
||||
|
||||
@override
|
||||
Future<CursorPagingData<SnCloudFile>> fetch({required String? cursor}) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final offset = cursor == null ? 0 : int.parse(cursor);
|
||||
final take = 20;
|
||||
|
||||
final queryParameters = {'offset': offset, 'take': take};
|
||||
|
||||
final response = await client.get(
|
||||
'/drive/files/me',
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
|
||||
final List<SnCloudFile> items =
|
||||
(response.data as List)
|
||||
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
final total = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
|
||||
final hasMore = offset + items.length < total;
|
||||
final nextCursor = hasMore ? (offset + items.length).toString() : null;
|
||||
|
||||
return CursorPagingData(
|
||||
items: items,
|
||||
hasMore: hasMore,
|
||||
nextCursor: nextCursor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatLinkAttachment extends HookConsumerWidget {
|
||||
const ChatLinkAttachment({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final idController = useTextEditingController();
|
||||
final errorMessage = useState<String?>(null);
|
||||
|
||||
return SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'linkAttachment'.tr(),
|
||||
child: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'attachmentsRecentUploads'.tr()),
|
||||
Tab(text: 'attachmentsManualInput'.tr()),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
PagingHelperView(
|
||||
provider: chatCloudFileListNotifierProvider,
|
||||
futureRefreshable: chatCloudFileListNotifierProvider.future,
|
||||
notifierRefreshable:
|
||||
chatCloudFileListNotifierProvider.notifier,
|
||||
contentBuilder:
|
||||
(data, widgetCount, endItemView) => ListView.builder(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
itemCount: widgetCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == widgetCount - 1) {
|
||||
return endItemView;
|
||||
}
|
||||
|
||||
final item = data.items[index];
|
||||
final itemType =
|
||||
item.mimeType?.split('/').firstOrNull;
|
||||
return ListTile(
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
width: 48,
|
||||
child: switch (itemType) {
|
||||
'image' => CloudImageWidget(file: item),
|
||||
'audio' =>
|
||||
const Icon(
|
||||
Symbols.audio_file,
|
||||
fill: 1,
|
||||
).center(),
|
||||
'video' =>
|
||||
const Icon(
|
||||
Symbols.video_file,
|
||||
fill: 1,
|
||||
).center(),
|
||||
_ =>
|
||||
const Icon(
|
||||
Symbols.body_system,
|
||||
fill: 1,
|
||||
).center(),
|
||||
},
|
||||
),
|
||||
),
|
||||
title:
|
||||
item.name.isEmpty
|
||||
? Text('untitled').tr().italic()
|
||||
: Text(item.name),
|
||||
onTap: () {
|
||||
Navigator.pop(context, item);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: idController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fileId'.tr(),
|
||||
helperText: 'fileIdHint'.tr(),
|
||||
helperMaxLines: 3,
|
||||
errorText: errorMessage.value,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(16),
|
||||
InkWell(
|
||||
child: Text(
|
||||
'fileIdLinkHint',
|
||||
).tr().fontSize(13).opacity(0.85),
|
||||
onTap: () {
|
||||
launchUrlString('https://fs.solian.app');
|
||||
},
|
||||
).padding(horizontal: 14),
|
||||
const Gap(16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Symbols.add),
|
||||
label: Text('add'.tr()),
|
||||
onPressed: () async {
|
||||
final fileId = idController.text.trim();
|
||||
if (fileId.isEmpty) {
|
||||
errorMessage.value = 'fileIdCannotBeEmpty'.tr();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get(
|
||||
'/drive/files/$fileId/info',
|
||||
);
|
||||
final SnCloudFile cloudFile =
|
||||
SnCloudFile.fromJson(response.data);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(cloudFile);
|
||||
}
|
||||
} catch (e) {
|
||||
errorMessage.value = 'failedToFetchFile'.tr(
|
||||
args: [e.toString()],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
lib/widgets/chat/chat_link_attachments.g.dart
Normal file
31
lib/widgets/chat/chat_link_attachments.g.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_link_attachments.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatCloudFileListNotifierHash() =>
|
||||
r'5da3929229fe00212530f63bd19ae4cd829176f5';
|
||||
|
||||
/// See also [ChatCloudFileListNotifier].
|
||||
@ProviderFor(ChatCloudFileListNotifier)
|
||||
final chatCloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
|
||||
ChatCloudFileListNotifier,
|
||||
CursorPagingData<SnCloudFile>
|
||||
>.internal(
|
||||
ChatCloudFileListNotifier.new,
|
||||
name: r'chatCloudFileListNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatCloudFileListNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ChatCloudFileListNotifier =
|
||||
AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFile>>;
|
||||
// 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
|
||||
@@ -13,8 +13,14 @@ import 'package:styled_widget/styled_widget.dart';
|
||||
class MessageContent extends StatelessWidget {
|
||||
final SnChatMessage item;
|
||||
final String? translatedText;
|
||||
final bool isSelectable;
|
||||
|
||||
const MessageContent({super.key, required this.item, this.translatedText});
|
||||
const MessageContent({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.translatedText,
|
||||
this.isSelectable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -59,18 +65,24 @@ class MessageContent extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.edit,
|
||||
item.type == 'messages.update.links'
|
||||
? Symbols.link
|
||||
: Symbols.edit,
|
||||
size: 16,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
).padding(top: 2),
|
||||
),
|
||||
const Gap(4),
|
||||
if (item.meta['previous_content'] is String)
|
||||
Flexible(
|
||||
child: PrettyDiffText(
|
||||
oldText: item.meta['previous_content'],
|
||||
newText: item.content ?? 'Edited a message',
|
||||
newText:
|
||||
item.content ??
|
||||
(item.type == 'messages.update.links'
|
||||
? 'messageUpdateLinks'.tr()
|
||||
: 'messageUpdateEdited'.tr()),
|
||||
defaultTextStyle: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium!.copyWith(
|
||||
@@ -107,10 +119,13 @@ class MessageContent extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: MarkdownTextContent(
|
||||
content: item.content ?? '*${item.type} has no content*',
|
||||
isSelectable: true,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.text,
|
||||
child: MarkdownTextContent(
|
||||
content: item.content ?? '*${item.type} has no content*',
|
||||
isSelectable: isSelectable,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (translatedText?.isNotEmpty ?? false)
|
||||
@@ -131,10 +146,13 @@ class MessageContent extends StatelessWidget {
|
||||
],
|
||||
).padding(vertical: 4),
|
||||
),
|
||||
MarkdownTextContent(
|
||||
content: translatedText!,
|
||||
isSelectable: true,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.text,
|
||||
child: MarkdownTextContent(
|
||||
content: translatedText!,
|
||||
isSelectable: isSelectable,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ class MessageIndicators extends StatelessWidget {
|
||||
final MessageStatus? status;
|
||||
final bool isCurrentUser;
|
||||
final Color textColor;
|
||||
final EdgeInsets padding;
|
||||
|
||||
const MessageIndicators({
|
||||
super.key,
|
||||
@@ -16,26 +17,43 @@ class MessageIndicators extends StatelessWidget {
|
||||
this.status,
|
||||
required this.isCurrentUser,
|
||||
required this.textColor,
|
||||
this.padding = const EdgeInsets.only(left: 6),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
spacing: 4,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (editedAt != null)
|
||||
Text(
|
||||
'edited'.tr().toLowerCase(),
|
||||
style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)),
|
||||
),
|
||||
if (isCurrentUser && status != null)
|
||||
_buildStatusIcon(
|
||||
context,
|
||||
status!,
|
||||
textColor.withOpacity(0.7),
|
||||
).padding(bottom: 3),
|
||||
],
|
||||
final children = <Widget>[];
|
||||
|
||||
if (editedAt != null) {
|
||||
children.add(
|
||||
Text(
|
||||
'edited'.tr().toLowerCase(),
|
||||
style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentUser && status != null && status != MessageStatus.sent) {
|
||||
children.add(
|
||||
_buildStatusIcon(
|
||||
context,
|
||||
status!,
|
||||
textColor.withOpacity(0.7),
|
||||
).padding(bottom: 2),
|
||||
);
|
||||
}
|
||||
|
||||
if (children.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,9 +64,18 @@ class MessageIndicators extends StatelessWidget {
|
||||
) {
|
||||
switch (status) {
|
||||
case MessageStatus.pending:
|
||||
return Icon(Icons.access_time, size: 12, color: textColor);
|
||||
return SizedBox(
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: CircularProgressIndicator(
|
||||
padding: EdgeInsets.zero,
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(textColor),
|
||||
),
|
||||
).padding(bottom: 2);
|
||||
case MessageStatus.sent:
|
||||
return Icon(Icons.check, size: 12, color: textColor);
|
||||
// Sent status is hidden
|
||||
return const SizedBox.shrink();
|
||||
case MessageStatus.failed:
|
||||
return Consumer(
|
||||
builder:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ class MessageListTile extends StatelessWidget {
|
||||
final sender = remoteMessage.sender;
|
||||
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -42,9 +43,10 @@ class MessageListTile extends StatelessWidget {
|
||||
createdAt: message.createdAt,
|
||||
textColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
showAvatar: false,
|
||||
isCompact: true,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
MessageContent(item: remoteMessage),
|
||||
MessageContent(item: remoteMessage, isSelectable: false),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/activity.dart';
|
||||
@@ -14,6 +13,8 @@ import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/screens/auth/captcha.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/account/event_calendar_content.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:slide_countdown/slide_countdown.dart';
|
||||
@@ -256,9 +257,17 @@ class CheckInWidget extends HookConsumerWidget {
|
||||
if (todayResult.valueOrNull == null) {
|
||||
checkIn();
|
||||
} else {
|
||||
context.pushNamed(
|
||||
'accountCalendar',
|
||||
pathParameters: {'name': 'me'},
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'eventCalendar'.tr(),
|
||||
child: EventCalendarContent(
|
||||
name: 'me',
|
||||
isSheet: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,16 @@ import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class CloudFilePicker extends HookConsumerWidget {
|
||||
final bool allowMultiple;
|
||||
const CloudFilePicker({super.key, this.allowMultiple = false});
|
||||
final Set<UniversalFileType> allowedTypes;
|
||||
const CloudFilePicker({
|
||||
super.key,
|
||||
this.allowMultiple = false,
|
||||
this.allowedTypes = const {
|
||||
UniversalFileType.image,
|
||||
UniversalFileType.video,
|
||||
UniversalFileType.file,
|
||||
},
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -71,7 +80,7 @@ class CloudFilePicker extends HookConsumerWidget {
|
||||
|
||||
void pickFile() async {
|
||||
showLoadingModal(context);
|
||||
final result = await FilePickerIO().pickFiles(
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: allowMultiple,
|
||||
);
|
||||
if (result == null) {
|
||||
@@ -80,9 +89,13 @@ class CloudFilePicker extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final newFiles =
|
||||
result.files
|
||||
.map((e) => UniversalFile(data: e, type: UniversalFileType.file))
|
||||
.toList();
|
||||
result.files.map((e) {
|
||||
final xfile =
|
||||
e.bytes != null
|
||||
? XFile.fromData(e.bytes!, name: e.name)
|
||||
: XFile(e.path!);
|
||||
return UniversalFile(data: xfile, type: UniversalFileType.file);
|
||||
}).toList();
|
||||
|
||||
if (!allowMultiple) {
|
||||
files.value = newFiles;
|
||||
@@ -99,23 +112,23 @@ class CloudFilePicker extends HookConsumerWidget {
|
||||
|
||||
void pickImage() async {
|
||||
showLoadingModal(context);
|
||||
final result =
|
||||
allowMultiple
|
||||
? await ref.read(imagePickerProvider).pickMultiImage()
|
||||
: [
|
||||
await ref
|
||||
.read(imagePickerProvider)
|
||||
.pickImage(source: ImageSource.gallery),
|
||||
];
|
||||
if (result.isEmpty) {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: allowMultiple,
|
||||
type: FileType.image,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
return;
|
||||
}
|
||||
|
||||
final newFiles =
|
||||
result
|
||||
.map((e) => UniversalFile(data: e, type: UniversalFileType.image))
|
||||
.toList();
|
||||
result.files.map((e) {
|
||||
final xfile =
|
||||
e.bytes != null
|
||||
? XFile.fromData(e.bytes!, name: e.name)
|
||||
: XFile(e.path!);
|
||||
return UniversalFile(data: xfile, type: UniversalFileType.image);
|
||||
}).toList();
|
||||
|
||||
if (!allowMultiple) {
|
||||
files.value = newFiles;
|
||||
@@ -132,21 +145,26 @@ class CloudFilePicker extends HookConsumerWidget {
|
||||
|
||||
void pickVideo() async {
|
||||
showLoadingModal(context);
|
||||
final result = await ref
|
||||
.read(imagePickerProvider)
|
||||
.pickVideo(source: ImageSource.gallery);
|
||||
if (result == null) {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: allowMultiple,
|
||||
type: FileType.video,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
return;
|
||||
}
|
||||
|
||||
final newFile = UniversalFile(
|
||||
data: result,
|
||||
type: UniversalFileType.video,
|
||||
);
|
||||
final newFiles =
|
||||
result.files.map((e) {
|
||||
final xfile =
|
||||
e.bytes != null
|
||||
? XFile.fromData(e.bytes!, name: e.name)
|
||||
: XFile(e.path!);
|
||||
return UniversalFile(data: xfile, type: UniversalFileType.video);
|
||||
}).toList();
|
||||
|
||||
if (!allowMultiple) {
|
||||
files.value = [newFile];
|
||||
files.value = newFiles;
|
||||
if (context.mounted) {
|
||||
hideLoadingModal(context);
|
||||
startUpload();
|
||||
@@ -154,7 +172,7 @@ class CloudFilePicker extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
files.value = [...files.value, newFile];
|
||||
files.value = [...files.value, ...newFiles];
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
|
||||
@@ -252,30 +270,33 @@ class CloudFilePicker extends HookConsumerWidget {
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
if (allowedTypes.contains(UniversalFileType.image))
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
leading: const Icon(Symbols.photo),
|
||||
title: Text('addPhoto'.tr()),
|
||||
onTap: () => pickImage(),
|
||||
),
|
||||
leading: const Icon(Symbols.photo),
|
||||
title: Text('addPhoto'.tr()),
|
||||
onTap: () => pickImage(),
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
if (allowedTypes.contains(UniversalFileType.video))
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
leading: const Icon(Symbols.video_call),
|
||||
title: Text('addVideo'.tr()),
|
||||
onTap: () => pickVideo(),
|
||||
),
|
||||
leading: const Icon(Symbols.video_call),
|
||||
title: Text('addVideo'.tr()),
|
||||
onTap: () => pickVideo(),
|
||||
),
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
if (allowedTypes.contains(UniversalFileType.file))
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
leading: const Icon(Symbols.draft),
|
||||
title: Text('addFile'.tr()),
|
||||
onTap: () => pickFile(),
|
||||
),
|
||||
leading: const Icon(Symbols.draft),
|
||||
title: Text('addFile'.tr()),
|
||||
onTap: () => pickFile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
@@ -11,12 +13,15 @@ import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/utils/format.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/audio.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:path/path.dart' show extension;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:island/widgets/data_saving_gate.dart';
|
||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||
|
||||
import 'image.dart';
|
||||
import 'video.dart';
|
||||
@@ -63,61 +68,304 @@ class CloudFileWidget extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
if (item.mimeType == 'application/pdf') {
|
||||
return Stack(
|
||||
children: [
|
||||
SizedBox(height: 600, child: SfPdfViewer.network(uri)),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.picture_as_pdf, size: 16, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'PDF',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
],
|
||||
final pdfViewer = useMemoized(() => SfPdfViewer.network(uri), [uri]);
|
||||
|
||||
Future<void> downloadFile() async {
|
||||
try {
|
||||
showSnackBar('Downloading file...');
|
||||
|
||||
final client = ref.read(apiClientProvider);
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
var extName = extension(item.name).trim();
|
||||
if (extName.isEmpty) {
|
||||
extName = item.mimeType?.split('/').lastOrNull ?? 'pdf';
|
||||
}
|
||||
final filePath = '${tempDir.path}/${item.id}.$extName';
|
||||
|
||||
await client.download(
|
||||
'/drive/files/${item.id}',
|
||||
filePath,
|
||||
queryParameters: {'original': true},
|
||||
);
|
||||
|
||||
await FileSaver.instance.saveFile(
|
||||
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
|
||||
file: File(filePath),
|
||||
);
|
||||
showSnackBar('File saved to downloads');
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
pdfViewer,
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 7,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.picture_as_pdf,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
).padding(top: 2),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
formatFileSize(item.size),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(vertical: 4, horizontal: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Symbols.download,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: downloadFile,
|
||||
padding: EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Symbols.info,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => FileInfoSheet(item: item),
|
||||
);
|
||||
},
|
||||
padding: EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (item.mimeType?.startsWith('text/') == true) {
|
||||
return SizedBox(
|
||||
final textFuture = useMemoized(
|
||||
() => ref
|
||||
.read(apiClientProvider)
|
||||
.get(uri)
|
||||
.then((response) => response.data as String),
|
||||
[uri],
|
||||
);
|
||||
|
||||
Future<void> downloadFile() async {
|
||||
try {
|
||||
showSnackBar('Downloading file...');
|
||||
|
||||
final client = ref.read(apiClientProvider);
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
var extName = extension(item.name).trim();
|
||||
if (extName.isEmpty) {
|
||||
extName = item.mimeType?.split('/').lastOrNull ?? 'txt';
|
||||
}
|
||||
final filePath = '${tempDir.path}/${item.id}.$extName';
|
||||
|
||||
await client.download(
|
||||
'/drive/files/${item.id}',
|
||||
filePath,
|
||||
queryParameters: {'original': true},
|
||||
);
|
||||
|
||||
await FileSaver.instance.saveFile(
|
||||
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
|
||||
file: File(filePath),
|
||||
);
|
||||
showSnackBar('File saved to downloads');
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 400,
|
||||
child: FutureBuilder<String>(
|
||||
future: ref
|
||||
.read(apiClientProvider)
|
||||
.get(uri)
|
||||
.then((response) => response.data as String),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text('Error loading text: ${snapshot.error}'),
|
||||
);
|
||||
} else if (snapshot.hasData) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SelectableText(
|
||||
snapshot.data!,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
FutureBuilder<String>(
|
||||
future: textFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text('Error loading text: ${snapshot.error}'),
|
||||
);
|
||||
} else if (snapshot.hasData) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20 + 48, 20, 20),
|
||||
child: SelectableText(
|
||||
snapshot.data!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(child: Text('No content'));
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(child: Text('No content'));
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 7,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.file_present,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
).padding(top: 2),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
formatFileSize(item.size),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(vertical: 4, horizontal: 8),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Symbols.download,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: downloadFile,
|
||||
padding: EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Symbols.info,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => FileInfoSheet(item: item),
|
||||
);
|
||||
},
|
||||
padding: EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -145,45 +393,99 @@ class CloudFileWidget extends HookConsumerWidget {
|
||||
child: UniversalAudio(uri: uri, filename: item.name),
|
||||
),
|
||||
),
|
||||
_ => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.insert_drive_file,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatFileSize(item.size),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
launchUrlString(
|
||||
'https://solian.app/files/${item.id}',
|
||||
mode: LaunchMode.externalApplication,
|
||||
_ => Builder(
|
||||
builder: (context) {
|
||||
Future<void> downloadFile() async {
|
||||
try {
|
||||
showSnackBar('Downloading file...');
|
||||
|
||||
final client = ref.read(apiClientProvider);
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
var extName = extension(item.name).trim();
|
||||
if (extName.isEmpty) {
|
||||
extName = item.mimeType?.split('/').lastOrNull ?? 'bin';
|
||||
}
|
||||
final filePath = '${tempDir.path}/${item.id}.$extName';
|
||||
|
||||
await client.download(
|
||||
'/drive/files/${item.id}',
|
||||
filePath,
|
||||
queryParameters: {'original': true},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.launch),
|
||||
label: Text('openInBrowser').tr(),
|
||||
),
|
||||
],
|
||||
).padding(all: 8),
|
||||
|
||||
await FileSaver.instance.saveFile(
|
||||
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
|
||||
file: File(filePath),
|
||||
);
|
||||
showSnackBar('File saved to downloads');
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.insert_drive_file,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatFileSize(item.size),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: downloadFile,
|
||||
icon: const Icon(Symbols.download),
|
||||
label: Text('download').tr(),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => FileInfoSheet(item: item),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.info),
|
||||
label: Text('info').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(all: 8),
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
if (heroTag != null) {
|
||||
|
||||
75
lib/widgets/content/embed/embed_list.dart
Normal file
75
lib/widgets/content/embed/embed_list.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/models/embed.dart';
|
||||
import 'package:island/models/poll.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/utils/mapping.dart';
|
||||
import 'package:island/widgets/content/embed/link.dart';
|
||||
import 'package:island/widgets/poll/poll_submit.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class EmbedListWidget extends StatelessWidget {
|
||||
final List<dynamic> embeds;
|
||||
final bool isInteractive;
|
||||
final bool isFullPost;
|
||||
final EdgeInsets renderingPadding;
|
||||
final double? maxWidth;
|
||||
|
||||
const EmbedListWidget({
|
||||
super.key,
|
||||
required this.embeds,
|
||||
this.isInteractive = true,
|
||||
this.isFullPost = false,
|
||||
this.renderingPadding = EdgeInsets.zero,
|
||||
this.maxWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children:
|
||||
embeds
|
||||
.map((embedData) => convertMapKeysToSnakeCase(embedData))
|
||||
.map(
|
||||
(embedData) => switch (embedData['type']) {
|
||||
'link' => EmbedLinkWidget(
|
||||
link: SnScrappedLink.fromJson(embedData),
|
||||
maxWidth:
|
||||
maxWidth ??
|
||||
math.min(
|
||||
MediaQuery.of(context).size.width,
|
||||
kWideScreenWidth,
|
||||
),
|
||||
margin: EdgeInsets.only(
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: renderingPadding.horizontal,
|
||||
right: renderingPadding.horizontal,
|
||||
),
|
||||
),
|
||||
'poll' => Card(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: renderingPadding.horizontal,
|
||||
vertical: 8,
|
||||
),
|
||||
child:
|
||||
embedData['poll'] == null
|
||||
? const Text('Poll was not loaded...')
|
||||
: PollSubmit(
|
||||
initialAnswers:
|
||||
embedData['poll']?['user_answer']?['answer'],
|
||||
stats: embedData['poll']?['stats'],
|
||||
poll: SnPollWithStats.fromJson(embedData['poll']),
|
||||
onSubmit: (_) {},
|
||||
isReadonly: !isInteractive,
|
||||
isInitiallyExpanded: isFullPost,
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
),
|
||||
_ => Text('Unable show embed: ${embedData['type']}'),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class FileInfoSheet extends StatelessWidget {
|
||||
final SnCloudFile item;
|
||||
@@ -140,6 +141,18 @@ class FileInfoSheet extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.launch),
|
||||
title: Text('openInBrowser').tr(),
|
||||
subtitle: Text('https://solian.app/files/${item.id}'),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://solian.app/files/${item.id}',
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (exifData.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
Theme(
|
||||
|
||||
@@ -15,12 +15,15 @@ import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/markdown_latex.dart';
|
||||
import 'package:markdown/markdown.dart' as markdown;
|
||||
import 'package:markdown_widget/markdown_widget.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'image.dart';
|
||||
|
||||
class MarkdownTextContent extends HookConsumerWidget {
|
||||
static const String stickerRegex = r':([-\w]*\+[-\w]*):';
|
||||
|
||||
final String content;
|
||||
final bool isAutoWarp;
|
||||
final TextScaler? textScaler;
|
||||
@@ -47,7 +50,7 @@ class MarkdownTextContent extends HookConsumerWidget {
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final doesEnlargeSticker = useMemoized(() {
|
||||
// Check if content only contains one sticker by matching the sticker pattern
|
||||
final stickerPattern = RegExp(r':([-\w]+):');
|
||||
final stickerPattern = RegExp(stickerRegex);
|
||||
final matches = stickerPattern.allMatches(content);
|
||||
|
||||
// Content should only contain one sticker and nothing else (except whitespace)
|
||||
@@ -60,6 +63,31 @@ class MarkdownTextContent extends HookConsumerWidget {
|
||||
final config =
|
||||
isDark ? MarkdownConfig.darkConfig : MarkdownConfig.defaultConfig;
|
||||
|
||||
final onMentionTap = useCallback((String type, String id) {
|
||||
final fullPath = '/$type/$id';
|
||||
context.push(fullPath);
|
||||
}, [context]);
|
||||
|
||||
final mentionGenerator = MentionChipGenerator(
|
||||
backgroundColor: Theme.of(context).colorScheme.secondary,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSecondary,
|
||||
onTap: onMentionTap,
|
||||
);
|
||||
|
||||
final highlightGenerator = HighlightGenerator(
|
||||
highlightColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
);
|
||||
|
||||
final spoilerRevealed = useState(false);
|
||||
|
||||
final spoilerGenerator = SpoilerGenerator(
|
||||
backgroundColor: Theme.of(context).colorScheme.tertiary,
|
||||
foregroundColor: Theme.of(context).colorScheme.onTertiary,
|
||||
outlineColor: Theme.of(context).colorScheme.outline,
|
||||
revealed: spoilerRevealed.value,
|
||||
onToggle: () => spoilerRevealed.value = !spoilerRevealed.value,
|
||||
);
|
||||
|
||||
return MarkdownBlock(
|
||||
data: content,
|
||||
selectable: isSelectable,
|
||||
@@ -96,16 +124,15 @@ class MarkdownTextContent extends HookConsumerWidget {
|
||||
final url = Uri.tryParse(href);
|
||||
if (url != null) {
|
||||
if (url.scheme == 'solian') {
|
||||
if (url.host == 'account') {
|
||||
context.pushNamed(
|
||||
'accountProfile',
|
||||
pathParameters: {'name': url.pathSegments[0]},
|
||||
);
|
||||
}
|
||||
final fullPath = ['/', url.host, url.path].join('');
|
||||
context.push(fullPath);
|
||||
return;
|
||||
}
|
||||
final whitelistDomains = ['solian.app', 'solsynth.dev'];
|
||||
if (whitelistDomains.contains(url.host)) {
|
||||
if (whitelistDomains.any(
|
||||
(domain) =>
|
||||
url.host == domain || url.host.endsWith('.$domain'),
|
||||
)) {
|
||||
launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
return;
|
||||
}
|
||||
@@ -201,6 +228,7 @@ class MarkdownTextContent extends HookConsumerWidget {
|
||||
generator: MarkdownTextContent.buildGenerator(
|
||||
isDark: isDark,
|
||||
linesMargin: linesMargin,
|
||||
generators: [mentionGenerator, highlightGenerator, spoilerGenerator],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -208,11 +236,14 @@ class MarkdownTextContent extends HookConsumerWidget {
|
||||
static MarkdownGenerator buildGenerator({
|
||||
bool isDark = false,
|
||||
EdgeInsets? linesMargin,
|
||||
List<dynamic> generators = const [],
|
||||
}) {
|
||||
return MarkdownGenerator(
|
||||
generators: [latexGenerator],
|
||||
generators: [latexGenerator, ...generators],
|
||||
inlineSyntaxList: [
|
||||
_UserNameCardInlineSyntax(),
|
||||
_MetionInlineSyntax(),
|
||||
_HighlightInlineSyntax(),
|
||||
_SpoilerInlineSyntax(),
|
||||
_StickerInlineSyntax(),
|
||||
LatexSyntax(isDark),
|
||||
],
|
||||
@@ -221,24 +252,34 @@ class MarkdownTextContent extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _UserNameCardInlineSyntax extends markdown.InlineSyntax {
|
||||
_UserNameCardInlineSyntax() : super(r'@[a-zA-Z0-9_]+');
|
||||
class _MetionInlineSyntax extends markdown.InlineSyntax {
|
||||
_MetionInlineSyntax() : super(r'@[-a-zA-Z0-9_./]+');
|
||||
|
||||
@override
|
||||
bool onMatch(markdown.InlineParser parser, Match match) {
|
||||
final alias = match[0]!;
|
||||
final anchor = markdown.Element.text('a', alias)
|
||||
..attributes['href'] = Uri.encodeFull(
|
||||
'solian://account/${alias.substring(1)}',
|
||||
);
|
||||
parser.addNode(anchor);
|
||||
final parts = alias.substring(1).split('/');
|
||||
final typeShortcut = parts.length == 1 ? 'u' : parts.first;
|
||||
final type = switch (typeShortcut) {
|
||||
'u' => 'accounts',
|
||||
'r' => 'realms',
|
||||
'p' => 'publishers',
|
||||
"c" => 'chat',
|
||||
_ => '',
|
||||
};
|
||||
final element =
|
||||
markdown.Element('mention-chip', [markdown.Text(alias)])
|
||||
..attributes['alias'] = alias
|
||||
..attributes['type'] = type
|
||||
..attributes['id'] = parts.last;
|
||||
parser.addNode(element);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _StickerInlineSyntax extends markdown.InlineSyntax {
|
||||
_StickerInlineSyntax() : super(r':([-\w]+):');
|
||||
_StickerInlineSyntax() : super(MarkdownTextContent.stickerRegex);
|
||||
|
||||
@override
|
||||
bool onMatch(markdown.InlineParser parser, Match match) {
|
||||
@@ -250,3 +291,271 @@ class _StickerInlineSyntax extends markdown.InlineSyntax {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _HighlightInlineSyntax extends markdown.InlineSyntax {
|
||||
_HighlightInlineSyntax() : super(r'==([^=]+)==');
|
||||
|
||||
@override
|
||||
bool onMatch(markdown.InlineParser parser, Match match) {
|
||||
final text = match[1]!;
|
||||
final element = markdown.Element('highlight', [markdown.Text(text)]);
|
||||
parser.addNode(element);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _SpoilerInlineSyntax extends markdown.InlineSyntax {
|
||||
_SpoilerInlineSyntax() : super(r'=!([^!]+)!=');
|
||||
|
||||
@override
|
||||
bool onMatch(markdown.InlineParser parser, Match match) {
|
||||
final text = match[1]!;
|
||||
final element = markdown.Element('spoiler', [markdown.Text(text)]);
|
||||
parser.addNode(element);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class MentionSpanNodeGenerator {
|
||||
final Color backgroundColor;
|
||||
final Color foregroundColor;
|
||||
final void Function(String type, String id) onTap;
|
||||
|
||||
MentionSpanNodeGenerator({
|
||||
required this.backgroundColor,
|
||||
required this.foregroundColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
SpanNode? call(
|
||||
String tag,
|
||||
Map<String, String> attributes,
|
||||
List<SpanNode> children,
|
||||
) {
|
||||
if (tag == 'mention-chip') {
|
||||
return MentionChipSpanNode(
|
||||
attributes: attributes,
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class MentionChipGenerator extends SpanNodeGeneratorWithTag {
|
||||
MentionChipGenerator({
|
||||
required Color backgroundColor,
|
||||
required Color foregroundColor,
|
||||
required void Function(String type, String id) onTap,
|
||||
}) : super(
|
||||
tag: 'mention-chip',
|
||||
generator: (
|
||||
markdown.Element element,
|
||||
MarkdownConfig config,
|
||||
WidgetVisitor visitor,
|
||||
) {
|
||||
return MentionChipSpanNode(
|
||||
attributes: element.attributes,
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
onTap: onTap,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class MentionChipSpanNode extends SpanNode {
|
||||
final Map<String, String> attributes;
|
||||
final Color backgroundColor;
|
||||
final Color foregroundColor;
|
||||
final void Function(String type, String id) onTap;
|
||||
|
||||
MentionChipSpanNode({
|
||||
required this.attributes,
|
||||
required this.backgroundColor,
|
||||
required this.foregroundColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
InlineSpan build() {
|
||||
final alias = attributes['alias'] ?? '';
|
||||
final type = attributes['type'] ?? '';
|
||||
final id = attributes['id'] ?? '';
|
||||
|
||||
final parts = alias.substring(1).split('/');
|
||||
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: InkWell(
|
||||
onTap: () => onTap(type, id),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 6,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withOpacity(0.5),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||
),
|
||||
child: Icon(
|
||||
switch (parts.first.isEmpty ? 'u' : parts.first) {
|
||||
'c' => Symbols.forum_rounded,
|
||||
'r' => Symbols.group_rounded,
|
||||
'u' => Symbols.person_rounded,
|
||||
'p' => Symbols.edit_rounded,
|
||||
_ => Symbols.person_rounded,
|
||||
},
|
||||
size: 14,
|
||||
color: foregroundColor,
|
||||
fill: 1,
|
||||
).padding(all: 2),
|
||||
),
|
||||
Text(
|
||||
parts.last,
|
||||
style: TextStyle(
|
||||
color: backgroundColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HighlightGenerator extends SpanNodeGeneratorWithTag {
|
||||
HighlightGenerator({required Color highlightColor})
|
||||
: super(
|
||||
tag: 'highlight',
|
||||
generator: (
|
||||
markdown.Element element,
|
||||
MarkdownConfig config,
|
||||
WidgetVisitor visitor,
|
||||
) {
|
||||
return HighlightSpanNode(
|
||||
text: element.textContent,
|
||||
highlightColor: highlightColor,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class HighlightSpanNode extends SpanNode {
|
||||
final String text;
|
||||
final Color highlightColor;
|
||||
|
||||
HighlightSpanNode({required this.text, required this.highlightColor});
|
||||
|
||||
@override
|
||||
InlineSpan build() {
|
||||
return TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(backgroundColor: highlightColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpoilerGenerator extends SpanNodeGeneratorWithTag {
|
||||
SpoilerGenerator({
|
||||
required Color backgroundColor,
|
||||
required Color foregroundColor,
|
||||
required Color outlineColor,
|
||||
required bool revealed,
|
||||
required VoidCallback onToggle,
|
||||
}) : super(
|
||||
tag: 'spoiler',
|
||||
generator: (
|
||||
markdown.Element element,
|
||||
MarkdownConfig config,
|
||||
WidgetVisitor visitor,
|
||||
) {
|
||||
return SpoilerSpanNode(
|
||||
text: element.textContent,
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
outlineColor: outlineColor,
|
||||
revealed: revealed,
|
||||
onToggle: onToggle,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class SpoilerSpanNode extends SpanNode {
|
||||
final String text;
|
||||
final Color backgroundColor;
|
||||
final Color foregroundColor;
|
||||
final Color outlineColor;
|
||||
final bool revealed;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
SpoilerSpanNode({
|
||||
required this.text,
|
||||
required this.backgroundColor,
|
||||
required this.foregroundColor,
|
||||
required this.outlineColor,
|
||||
required this.revealed,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
InlineSpan build() {
|
||||
return WidgetSpan(
|
||||
child: InkWell(
|
||||
onTap: onToggle,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: revealed ? Colors.transparent : backgroundColor,
|
||||
border: revealed ? Border.all(color: outlineColor, width: 1) : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child:
|
||||
revealed
|
||||
? Row(
|
||||
spacing: 6,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Symbols.visibility, size: 18).padding(top: 1),
|
||||
Flexible(child: Text(text)),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
spacing: 6,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.visibility_off,
|
||||
color: foregroundColor,
|
||||
size: 18,
|
||||
),
|
||||
Flexible(
|
||||
child:
|
||||
Text(
|
||||
'spoiler',
|
||||
style: TextStyle(color: foregroundColor),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ class SheetScaffold extends StatelessWidget {
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
|
||||
return Container(
|
||||
@@ -43,7 +45,7 @@ class SheetScaffold extends StatelessWidget {
|
||||
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
titleWidget,
|
||||
Expanded(child: titleWidget),
|
||||
const Spacer(),
|
||||
...actions,
|
||||
IconButton(
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/screens/tabs.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
|
||||
class ConditionalBottomNav extends HookConsumerWidget {
|
||||
final Widget child;
|
||||
@@ -17,10 +19,11 @@ class ConditionalBottomNav extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [currentLocation]);
|
||||
|
||||
// Use the same route logic as TabsScreen for consistency
|
||||
const mainTabRoutes = ['/', '/chat', '/realms', '/account'];
|
||||
|
||||
final shouldShowBottomNav = mainTabRoutes.contains(currentLocation);
|
||||
final routes = kTabRoutes.sublist(
|
||||
0,
|
||||
isWideScreen(context) ? null : kWideScreenRouteStart,
|
||||
);
|
||||
final shouldShowBottomNav = routes.contains(currentLocation);
|
||||
|
||||
return shouldShowBottomNav ? child : const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -11,6 +13,7 @@ import 'package:island/screens/creators/publishers_form.dart';
|
||||
import 'package:island/screens/posts/compose.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/post/compose_attachments.dart';
|
||||
import 'package:island/widgets/post/compose_form_fields.dart';
|
||||
import 'package:island/widgets/post/compose_info_banner.dart';
|
||||
@@ -31,9 +34,9 @@ class PostComposeCard extends HookConsumerWidget {
|
||||
final SnPost? originalPost;
|
||||
final PostComposeInitialState? initialState;
|
||||
final VoidCallback? onCancel;
|
||||
final Function(SnPost)? onSubmit;
|
||||
final Function()? onSubmit;
|
||||
final Function(ComposeState)? onStateChanged;
|
||||
final bool isInDialog;
|
||||
final bool isDialog;
|
||||
|
||||
const PostComposeCard({
|
||||
super.key,
|
||||
@@ -42,7 +45,7 @@ class PostComposeCard extends HookConsumerWidget {
|
||||
this.onCancel,
|
||||
this.onSubmit,
|
||||
this.onStateChanged,
|
||||
this.isInDialog = false,
|
||||
this.isDialog = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -160,15 +163,22 @@ class PostComposeCard extends HookConsumerWidget {
|
||||
|
||||
// Reset the form for new composition
|
||||
ComposeStateUtils.resetForm(state);
|
||||
|
||||
onSubmit?.call();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final maxHeight = math.min(
|
||||
640.0,
|
||||
MediaQuery.of(context).size.height * (isDialog ? 0.8 : 0.72),
|
||||
);
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
color: isDialog ? Theme.of(context).colorScheme.surfaceContainer : null,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 400),
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -186,11 +196,10 @@ class PostComposeCard extends HookConsumerWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Gap(4),
|
||||
Text(
|
||||
originalPost != null
|
||||
? 'postEditing'.tr()
|
||||
: 'postCompose'.tr(),
|
||||
style: theme.textTheme.titleMedium,
|
||||
'postCompose'.tr(),
|
||||
style: theme.textTheme.titleMedium!.copyWith(fontSize: 18),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
@@ -255,45 +264,12 @@ class PostComposeCard extends HookConsumerWidget {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder:
|
||||
(context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
maxChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
builder:
|
||||
(context, scrollController) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(context).scaffoldBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(context).colorScheme.outline,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: PostItem(item: post),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'Post Preview',
|
||||
child: SingleChildScrollView(
|
||||
child: PostItem(item: post),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -334,7 +310,7 @@ class PostComposeCard extends HookConsumerWidget {
|
||||
onTap: () {
|
||||
if (state.currentPublisher.value == null) {
|
||||
// No publisher loaded, guide user to create one
|
||||
if (isInDialog) {
|
||||
if (isDialog) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
context.pushNamed('creatorNew').then((value) {
|
||||
@@ -371,7 +347,7 @@ class PostComposeCard extends HookConsumerWidget {
|
||||
onPublisherTap: () {
|
||||
if (state.currentPublisher.value == null) {
|
||||
// No publisher loaded, guide user to create one
|
||||
if (isInDialog) {
|
||||
if (isDialog) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
context.pushNamed('creatorNew').then((
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -22,12 +23,12 @@ class PostComposeDialog extends HookConsumerWidget {
|
||||
this.isBottomSheet = false,
|
||||
});
|
||||
|
||||
static Future<SnPost?> show(
|
||||
static Future<bool?> show(
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
PostComposeInitialState? initialState,
|
||||
}) {
|
||||
return showDialog<SnPost>(
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder:
|
||||
@@ -49,7 +50,11 @@ class PostComposeDialog extends HookConsumerWidget {
|
||||
final isWide = isWideScreen(context);
|
||||
|
||||
useEffect(() {
|
||||
if (!prompted.value && originalPost == null && drafts.isNotEmpty) {
|
||||
if (!prompted.value &&
|
||||
originalPost == null &&
|
||||
initialState?.replyingTo == null &&
|
||||
initialState?.forwardingTo == null &&
|
||||
drafts.isNotEmpty) {
|
||||
prompted.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showRestoreDialog(ref, restoredInitialState);
|
||||
@@ -69,8 +74,8 @@ class PostComposeDialog extends HookConsumerWidget {
|
||||
originalPost: originalPost,
|
||||
initialState: restoredInitialState.value ?? initialState,
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onSubmit: (post) => Navigator.of(context).pop(post),
|
||||
isInDialog: true,
|
||||
onSubmit: () => Navigator.of(context).pop(true),
|
||||
isDialog: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -86,14 +91,15 @@ class PostComposeDialog extends HookConsumerWidget {
|
||||
|
||||
final restore = await showDialog<bool>(
|
||||
context: ref.context,
|
||||
useRootNavigator: true,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Restore Draft'),
|
||||
title: Text('restoreDraftTitle'.tr()),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('A draft was found. Do you want to restore it?'),
|
||||
Text('restoreDraftMessage'.tr()),
|
||||
const SizedBox(height: 16),
|
||||
_buildCompactDraftPreview(context, latestDraft),
|
||||
],
|
||||
@@ -101,11 +107,11 @@ class PostComposeDialog extends HookConsumerWidget {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('No'),
|
||||
child: Text('no'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Yes'),
|
||||
child: Text('yes'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -151,7 +157,7 @@ class PostComposeDialog extends HookConsumerWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Draft',
|
||||
'draft'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/models/autocomplete_response.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/models/publisher.dart';
|
||||
import 'package:island/models/realm.dart';
|
||||
import 'package:island/models/sticker.dart';
|
||||
import 'package:island/services/autocomplete_service.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/post/compose_shared.dart';
|
||||
|
||||
/// A reusable widget for the form fields in compose screens.
|
||||
/// Includes title, description, and content text fields.
|
||||
class ComposeFormFields extends StatelessWidget {
|
||||
class ComposeFormFields extends HookConsumerWidget {
|
||||
final ComposeState state;
|
||||
final bool enabled;
|
||||
final bool showPublisherAvatar;
|
||||
@@ -19,7 +29,7 @@ class ComposeFormFields extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Row(
|
||||
@@ -29,6 +39,7 @@ class ComposeFormFields extends StatelessWidget {
|
||||
// Publisher profile picture
|
||||
if (showPublisherAvatar)
|
||||
GestureDetector(
|
||||
onTap: onPublisherTap,
|
||||
child: ProfilePictureWidget(
|
||||
fileId: state.currentPublisher.value?.picture?.id,
|
||||
radius: 20,
|
||||
@@ -37,7 +48,6 @@ class ComposeFormFields extends StatelessWidget {
|
||||
? Icons.question_mark
|
||||
: null,
|
||||
),
|
||||
onTap: onPublisherTap,
|
||||
),
|
||||
|
||||
// Post content form
|
||||
@@ -79,7 +89,7 @@ class ComposeFormFields extends StatelessWidget {
|
||||
controller: state.titleController,
|
||||
enabled: enabled && state.currentPublisher.value != null,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'postTitle',
|
||||
hintText: 'postTitle'.tr(),
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
@@ -97,7 +107,7 @@ class ComposeFormFields extends StatelessWidget {
|
||||
controller: state.descriptionController,
|
||||
enabled: enabled && state.currentPublisher.value != null,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'postDescription',
|
||||
hintText: 'postDescription'.tr(),
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
@@ -110,22 +120,124 @@ class ComposeFormFields extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Content field
|
||||
TextField(
|
||||
TypeAheadField<AutocompleteSuggestion>(
|
||||
controller: state.contentController,
|
||||
enabled: enabled && state.currentPublisher.value != null,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'postContent',
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
builder: (context, controller, focusNode) {
|
||||
return TextField(
|
||||
focusNode: focusNode,
|
||||
controller: controller,
|
||||
enabled: enabled && state.currentPublisher.value != null,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'postContent'.tr(),
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
);
|
||||
},
|
||||
suggestionsCallback: (pattern) async {
|
||||
// Only trigger on @ or :
|
||||
final atIndex = pattern.lastIndexOf('@');
|
||||
final colonIndex = pattern.lastIndexOf(':');
|
||||
final triggerIndex =
|
||||
atIndex > colonIndex ? atIndex : colonIndex;
|
||||
if (triggerIndex == -1) return [];
|
||||
final chopped = pattern.substring(triggerIndex);
|
||||
if (chopped.contains(' ')) return [];
|
||||
final service = ref.read(autocompleteServiceProvider);
|
||||
try {
|
||||
return await service.getGeneralSuggestions(chopped);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, suggestion) {
|
||||
String title = 'unknown'.tr();
|
||||
Widget leading = Icon(Icons.help);
|
||||
switch (suggestion.type) {
|
||||
case 'user':
|
||||
final user = SnAccount.fromJson(suggestion.data);
|
||||
title = user.nick;
|
||||
leading = ProfilePictureWidget(
|
||||
file: user.profile.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'chatroom':
|
||||
final chatRoom = SnChatRoom.fromJson(suggestion.data);
|
||||
title = chatRoom.name ?? 'Chat Room';
|
||||
leading = ProfilePictureWidget(
|
||||
file: chatRoom.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'realm':
|
||||
final realm = SnRealm.fromJson(suggestion.data);
|
||||
title = realm.name;
|
||||
leading = ProfilePictureWidget(
|
||||
file: realm.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'publisher':
|
||||
final publisher = SnPublisher.fromJson(suggestion.data);
|
||||
title = publisher.name;
|
||||
leading = ProfilePictureWidget(
|
||||
file: publisher.picture,
|
||||
radius: 18,
|
||||
);
|
||||
break;
|
||||
case 'sticker':
|
||||
final sticker = SnSticker.fromJson(suggestion.data);
|
||||
title = sticker.slug;
|
||||
leading = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CloudImageWidget(fileId: sticker.imageId),
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
return ListTile(
|
||||
leading: leading,
|
||||
title: Text(title),
|
||||
subtitle: Text(suggestion.keyword),
|
||||
dense: true,
|
||||
);
|
||||
},
|
||||
onSelected: (suggestion) {
|
||||
final text = state.contentController.text;
|
||||
final atIndex = text.lastIndexOf('@');
|
||||
final colonIndex = text.lastIndexOf(':');
|
||||
final triggerIndex =
|
||||
atIndex > colonIndex ? atIndex : colonIndex;
|
||||
if (triggerIndex == -1) return;
|
||||
final newText = text.replaceRange(
|
||||
triggerIndex,
|
||||
text.length,
|
||||
suggestion.keyword,
|
||||
);
|
||||
state.contentController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: triggerIndex + suggestion.keyword.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
direction: VerticalDirection.down,
|
||||
hideOnEmpty: true,
|
||||
hideOnLoading: true,
|
||||
debounceDuration: const Duration(milliseconds: 1000),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -43,7 +43,7 @@ class ComposeInfoBanner extends StatelessWidget {
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'postEditing',
|
||||
'postEditing'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
@@ -57,14 +57,14 @@ class ComposeInfoBanner extends StatelessWidget {
|
||||
context,
|
||||
effectiveRepliedPost,
|
||||
Symbols.reply,
|
||||
'postReplyingTo',
|
||||
'postReplyingTo'.tr(),
|
||||
),
|
||||
if (effectiveForwardedPost != null)
|
||||
_buildReferenceBanner(
|
||||
context,
|
||||
effectiveForwardedPost,
|
||||
Symbols.forward,
|
||||
'postForwardingTo',
|
||||
'postForwardingTo'.tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -76,7 +76,7 @@ class ComposeInfoBanner extends StatelessWidget {
|
||||
context,
|
||||
effectiveRepliedPost,
|
||||
Symbols.reply,
|
||||
'postReplyingTo',
|
||||
'postReplyingTo'.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ class ComposeInfoBanner extends StatelessWidget {
|
||||
context,
|
||||
effectiveForwardedPost,
|
||||
Symbols.forward,
|
||||
'postForwardingTo',
|
||||
'postForwardingTo'.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/post_category.dart';
|
||||
import 'package:island/models/post_tag.dart';
|
||||
import 'package:island/models/realm.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/screens/realm/realms.dart';
|
||||
@@ -14,7 +16,6 @@ import 'package:island/widgets/post/compose_shared.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:textfield_tags/textfield_tags.dart';
|
||||
|
||||
part 'compose_settings_sheet.g.dart';
|
||||
|
||||
@@ -22,102 +23,17 @@ part 'compose_settings_sheet.g.dart';
|
||||
Future<List<SnPostCategory>> postCategories(Ref ref) async {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final resp = await apiClient.get('/sphere/posts/categories');
|
||||
return resp.data
|
||||
.map((e) => SnPostCategory.fromJson(e))
|
||||
.cast<SnPostCategory>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// A reusable widget for tag input fields with chip display
|
||||
class ChipTagInputField extends StatelessWidget {
|
||||
final InputFieldValues inputFieldValues;
|
||||
final String labelText;
|
||||
final String hintText;
|
||||
|
||||
const ChipTagInputField({
|
||||
super.key,
|
||||
required this.inputFieldValues,
|
||||
required this.labelText,
|
||||
required this.hintText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: inputFieldValues.textEditingController,
|
||||
focusNode: inputFieldValues.focusNode,
|
||||
decoration: InputDecoration(
|
||||
label: Text(labelText).tr(),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
hintText: inputFieldValues.tags.isNotEmpty ? '' : hintText.tr(),
|
||||
errorText: inputFieldValues.error,
|
||||
prefixIconConstraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.8,
|
||||
),
|
||||
prefixIcon:
|
||||
inputFieldValues.tags.isNotEmpty
|
||||
? SingleChildScrollView(
|
||||
controller: inputFieldValues.tagScrollController,
|
||||
scrollDirection: Axis.vertical,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 8, left: 8),
|
||||
child: Wrap(
|
||||
runSpacing: 4.0,
|
||||
spacing: 4.0,
|
||||
children:
|
||||
inputFieldValues.tags.map<Widget>((dynamic tag) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(20.0),
|
||||
),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
margin: const EdgeInsets.only(left: 5),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0,
|
||||
vertical: 5.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
child: Text(
|
||||
'#$tag',
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
InkWell(
|
||||
child: const Icon(
|
||||
Icons.cancel,
|
||||
size: 14.0,
|
||||
color: Color.fromARGB(255, 233, 233, 233),
|
||||
),
|
||||
onTap: () {
|
||||
inputFieldValues.onTagRemoved(tag);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: inputFieldValues.onTagChanged,
|
||||
onSubmitted: inputFieldValues.onTagSubmitted,
|
||||
);
|
||||
final categories =
|
||||
resp.data
|
||||
.map((e) => SnPostCategory.fromJson(e))
|
||||
.cast<SnPostCategory>()
|
||||
.toList();
|
||||
// Remove duplicates based on id
|
||||
final uniqueCategories = <String, SnPostCategory>{};
|
||||
for (final category in categories) {
|
||||
uniqueCategories[category.id] = category;
|
||||
}
|
||||
return uniqueCategories.values.toList();
|
||||
}
|
||||
|
||||
class ComposeSettingsSheet extends HookConsumerWidget {
|
||||
@@ -125,6 +41,26 @@ class ComposeSettingsSheet extends HookConsumerWidget {
|
||||
|
||||
const ComposeSettingsSheet({super.key, required this.state});
|
||||
|
||||
Future<List<SnPostTag>> _fetchTagSuggestions(
|
||||
String query,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get(
|
||||
'/sphere/posts/tags',
|
||||
queryParameters: {'query': query},
|
||||
);
|
||||
return response.data
|
||||
.map<SnPostTag>((json) => SnPostTag.fromJson(json))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -133,6 +69,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
|
||||
// Listen to visibility changes to trigger rebuilds
|
||||
final currentVisibility = useValueListenable(state.visibility);
|
||||
final currentCategories = useValueListenable(state.categories);
|
||||
final currentTags = useValueListenable(state.tags);
|
||||
final currentRealm = useValueListenable(state.realm);
|
||||
final postCategories = ref.watch(postCategoriesProvider);
|
||||
final userRealms = ref.watch(realmsJoinedProvider);
|
||||
@@ -248,23 +185,118 @@ class ComposeSettingsSheet extends HookConsumerWidget {
|
||||
),
|
||||
|
||||
// Tags field
|
||||
TextFieldTags(
|
||||
textfieldTagsController: state.tagsController,
|
||||
textSeparators: const [' ', ','],
|
||||
letterCase: LetterCase.normal,
|
||||
validator: (String tag) {
|
||||
if (tag.isEmpty) {
|
||||
return 'No, cannot be empty';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
inputFieldBuilder: (context, inputFieldValues) {
|
||||
return ChipTagInputField(
|
||||
inputFieldValues: inputFieldValues,
|
||||
labelText: 'tags',
|
||||
hintText: 'tagsHint',
|
||||
);
|
||||
},
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Text(
|
||||
'tags'.tr(),
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
// Existing tags display
|
||||
if (currentTags.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children:
|
||||
currentTags.map((tag) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'#$tag',
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final newTags = List<String>.from(
|
||||
state.tags.value,
|
||||
)..remove(tag);
|
||||
state.tags.value = newTags;
|
||||
},
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 16,
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
// Tag input with autocomplete
|
||||
TypeAheadField<SnPostTag>(
|
||||
builder: (context, controller, focusNode) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'addTag'.tr(),
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
state.tags.value = [...state.tags.value, value];
|
||||
controller.clear();
|
||||
},
|
||||
);
|
||||
},
|
||||
suggestionsCallback:
|
||||
(pattern) => _fetchTagSuggestions(pattern, ref),
|
||||
itemBuilder: (context, suggestion) {
|
||||
return ListTile(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
title: Text('#${suggestion.slug}'),
|
||||
dense: true,
|
||||
);
|
||||
},
|
||||
onSelected: (suggestion) {
|
||||
if (!state.tags.value.contains(suggestion.slug)) {
|
||||
state.tags.value = [
|
||||
...state.tags.value,
|
||||
suggestion.slug,
|
||||
];
|
||||
}
|
||||
},
|
||||
direction: VerticalDirection.down,
|
||||
hideOnEmpty: true,
|
||||
hideOnLoading: true,
|
||||
debounceDuration: const Duration(milliseconds: 300),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Categories field
|
||||
@@ -392,6 +424,25 @@ class ComposeSettingsSheet extends HookConsumerWidget {
|
||||
],
|
||||
).padding(left: 16, right: 8),
|
||||
),
|
||||
// Include current realm if it's not null and not in joined realms
|
||||
if (currentRealm != null &&
|
||||
!(userRealms.value ?? []).any(
|
||||
(r) => r.id == currentRealm.id,
|
||||
))
|
||||
DropdownMenuItem<SnRealm?>(
|
||||
value: currentRealm,
|
||||
child: Row(
|
||||
children: [
|
||||
ProfilePictureWidget(
|
||||
fileId: currentRealm.picture?.id,
|
||||
fallbackIcon: Symbols.workspaces,
|
||||
radius: 16,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(currentRealm.name),
|
||||
],
|
||||
).padding(left: 16, right: 8),
|
||||
),
|
||||
if (userRealms.hasValue)
|
||||
...(userRealms.value ?? []).map(
|
||||
(realm) => DropdownMenuItem<SnRealm?>(
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'compose_settings_sheet.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$postCategoriesHash() => r'24337fe806d088b6468a350f62d5a5d40232a73c';
|
||||
String _$postCategoriesHash() => r'8799c10eb91cf8c8c7ea72eff3475e1eaa7b9a2b';
|
||||
|
||||
/// See also [postCategories].
|
||||
@ProviderFor(postCategories)
|
||||
|
||||
@@ -23,7 +23,6 @@ import 'package:island/widgets/post/compose_poll.dart';
|
||||
import 'package:island/widgets/post/compose_recorder.dart';
|
||||
import 'package:island/pods/file_pool.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:textfield_tags/textfield_tags.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
class ComposeState {
|
||||
@@ -37,7 +36,7 @@ class ComposeState {
|
||||
final ValueNotifier<SnPublisher?> currentPublisher;
|
||||
final ValueNotifier<bool> submitting;
|
||||
final ValueNotifier<List<SnPostCategory>> categories;
|
||||
StringTagController tagsController;
|
||||
final ValueNotifier<List<String>> tags;
|
||||
final ValueNotifier<SnRealm?> realm;
|
||||
final ValueNotifier<SnPostEmbedView?> embedView;
|
||||
final String draftId;
|
||||
@@ -56,7 +55,7 @@ class ComposeState {
|
||||
required this.attachmentProgress,
|
||||
required this.currentPublisher,
|
||||
required this.submitting,
|
||||
required this.tagsController,
|
||||
required this.tags,
|
||||
required this.categories,
|
||||
required this.realm,
|
||||
required this.embedView,
|
||||
@@ -90,8 +89,14 @@ class ComposeLogic {
|
||||
int postType = 0,
|
||||
}) {
|
||||
final id = draftId ?? DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final tagsController = StringTagController();
|
||||
originalPost?.tags.forEach((x) => tagsController.addTag(x.slug));
|
||||
|
||||
// Initialize tags from original post
|
||||
final tags =
|
||||
originalPost?.tags.map((tag) => tag.slug).toList() ?? <String>[];
|
||||
|
||||
// Initialize categories from original post
|
||||
final categories = originalPost?.categories ?? <SnPostCategory>[];
|
||||
|
||||
return ComposeState(
|
||||
attachments: ValueNotifier<List<UniversalFile>>(
|
||||
originalPost?.attachments
|
||||
@@ -119,10 +124,8 @@ class ComposeLogic {
|
||||
submitting: ValueNotifier<bool>(false),
|
||||
attachmentProgress: ValueNotifier<Map<int, double>>({}),
|
||||
currentPublisher: ValueNotifier<SnPublisher?>(originalPost?.publisher),
|
||||
tagsController: tagsController,
|
||||
categories: ValueNotifier<List<SnPostCategory>>(
|
||||
originalPost?.categories ?? [],
|
||||
),
|
||||
tags: ValueNotifier<List<String>>(tags),
|
||||
categories: ValueNotifier<List<SnPostCategory>>(categories),
|
||||
realm: ValueNotifier(originalPost?.realm),
|
||||
embedView: ValueNotifier<SnPostEmbedView?>(originalPost?.embedView),
|
||||
draftId: id,
|
||||
@@ -133,14 +136,8 @@ class ComposeLogic {
|
||||
}
|
||||
|
||||
static ComposeState createStateFromDraft(SnPost draft, {int postType = 0}) {
|
||||
final tagsController = StringTagController();
|
||||
final categoriesController = StringTagController();
|
||||
for (var x in draft.tags) {
|
||||
tagsController.addTag(x.slug);
|
||||
}
|
||||
for (var x in draft.categories) {
|
||||
categoriesController.addTag(x.slug);
|
||||
}
|
||||
final tags = draft.tags.map((tag) => tag.slug).toList();
|
||||
|
||||
return ComposeState(
|
||||
attachments: ValueNotifier<List<UniversalFile>>(
|
||||
draft.attachments.map((e) => UniversalFile.fromAttachment(e)).toList(),
|
||||
@@ -153,9 +150,9 @@ class ComposeLogic {
|
||||
submitting: ValueNotifier<bool>(false),
|
||||
attachmentProgress: ValueNotifier<Map<int, double>>({}),
|
||||
currentPublisher: ValueNotifier<SnPublisher?>(null),
|
||||
tagsController: tagsController,
|
||||
categories: ValueNotifier<List<SnPostCategory>>([]),
|
||||
realm: ValueNotifier(null),
|
||||
tags: ValueNotifier<List<String>>(tags),
|
||||
categories: ValueNotifier<List<SnPostCategory>>(draft.categories),
|
||||
realm: ValueNotifier(draft.realm),
|
||||
embedView: ValueNotifier<SnPostEmbedView?>(draft.embedView),
|
||||
draftId: draft.id,
|
||||
postType: postType,
|
||||
@@ -680,7 +677,7 @@ class ComposeLogic {
|
||||
'type': state.postType,
|
||||
if (repliedPost != null) 'replied_post_id': repliedPost.id,
|
||||
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
||||
'tags': state.tagsController.getTags,
|
||||
'tags': state.tags.value,
|
||||
'categories': state.categories.value.map((e) => e.slug).toList(),
|
||||
if (state.realm.value != null) 'realm_id': state.realm.value?.id,
|
||||
if (state.pollId.value != null) 'poll_id': state.pollId.value,
|
||||
@@ -776,7 +773,7 @@ class ComposeLogic {
|
||||
state.submitting.dispose();
|
||||
state.attachmentProgress.dispose();
|
||||
state.currentPublisher.dispose();
|
||||
state.tagsController.dispose();
|
||||
state.tags.dispose();
|
||||
state.categories.dispose();
|
||||
state.realm.dispose();
|
||||
state.embedView.dispose();
|
||||
|
||||
@@ -173,7 +173,7 @@ class ComposeStateUtils {
|
||||
state.attachmentProgress.value = {};
|
||||
|
||||
// Clear tags
|
||||
state.tagsController.clearTags();
|
||||
state.tags.value = [];
|
||||
|
||||
// Clear categories
|
||||
state.categories.value = [];
|
||||
|
||||
@@ -9,16 +9,18 @@ import 'package:island/widgets/post/compose_shared.dart';
|
||||
/// Utility class for common compose submit logic.
|
||||
class ComposeSubmitUtils {
|
||||
/// Performs the submit action for posts.
|
||||
static Future<void> performSubmit(
|
||||
static Future<SnPost> performSubmit(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
SnPost? repliedPost,
|
||||
SnPost? forwardedPost,
|
||||
required VoidCallback onSuccess,
|
||||
required Function() onSuccess,
|
||||
}) async {
|
||||
if (state.submitting.value) return;
|
||||
if (state.submitting.value) {
|
||||
throw Exception('Already submitting');
|
||||
}
|
||||
|
||||
// Don't submit empty posts (no content and no attachments)
|
||||
final hasContent =
|
||||
@@ -34,7 +36,7 @@ class ComposeSubmitUtils {
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('postContentEmpty')));
|
||||
}
|
||||
return; // Don't submit empty posts
|
||||
throw Exception('Post content is empty'); // Don't submit empty posts
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -73,7 +75,7 @@ class ComposeSubmitUtils {
|
||||
'type': state.postType,
|
||||
if (repliedPost != null) 'replied_post_id': repliedPost.id,
|
||||
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
||||
'tags': state.tagsController.getTags,
|
||||
'tags': state.tags.value,
|
||||
'categories': state.categories.value.map((e) => e.slug).toList(),
|
||||
if (state.realm.value != null) 'realm_id': state.realm.value?.id,
|
||||
if (state.pollId.value != null) 'poll_id': state.pollId.value,
|
||||
@@ -82,15 +84,20 @@ class ComposeSubmitUtils {
|
||||
};
|
||||
|
||||
// Send request
|
||||
client.request(
|
||||
final response = await client.request(
|
||||
endpoint,
|
||||
queryParameters: {'pub': state.currentPublisher.value?.name},
|
||||
data: payload,
|
||||
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
|
||||
);
|
||||
|
||||
// Call the success callback with the created/updated post
|
||||
// Parse the response into a SnPost
|
||||
final post = SnPost.fromJson(response.data);
|
||||
|
||||
// Call the success callback
|
||||
onSuccess();
|
||||
|
||||
return post;
|
||||
} catch (err) {
|
||||
// Show error message if context is mounted
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:island/widgets/post/compose_embed_sheet.dart';
|
||||
import 'package:island/widgets/post/compose_shared.dart';
|
||||
import 'package:island/widgets/post/draft_manager.dart';
|
||||
import 'package:island/widgets/shared/upload_menu.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
@@ -80,6 +81,13 @@ class ComposeToolbar extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final uploadMenuItems = [
|
||||
UploadMenuItemData(Symbols.add_a_photo, 'addPhoto', pickPhotoMedia),
|
||||
UploadMenuItemData(Symbols.videocam, 'addVideo', pickVideoMedia),
|
||||
UploadMenuItemData(Symbols.mic, 'addAudio', addAudio),
|
||||
UploadMenuItemData(Symbols.file_upload, 'uploadFile', pickGeneralFile),
|
||||
];
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (isCompact) {
|
||||
@@ -96,45 +104,9 @@ class ComposeToolbar extends HookConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: pickPhotoMedia,
|
||||
tooltip: 'addPhoto'.tr(),
|
||||
icon: const Icon(Symbols.add_a_photo),
|
||||
color: colorScheme.primary,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: pickVideoMedia,
|
||||
tooltip: 'addVideo'.tr(),
|
||||
icon: const Icon(Symbols.videocam),
|
||||
color: colorScheme.primary,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: addAudio,
|
||||
tooltip: 'addAudio'.tr(),
|
||||
icon: const Icon(Symbols.mic),
|
||||
color: colorScheme.primary,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: pickGeneralFile,
|
||||
tooltip: 'uploadFile'.tr(),
|
||||
icon: const Icon(Symbols.file_upload),
|
||||
color: colorScheme.primary,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
UploadMenu(
|
||||
items: uploadMenuItems,
|
||||
isCompact: isCompact,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: linkAttachment,
|
||||
@@ -249,30 +221,7 @@ class ComposeToolbar extends HookConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: pickPhotoMedia,
|
||||
tooltip: 'addPhoto'.tr(),
|
||||
icon: const Icon(Symbols.add_a_photo),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: pickVideoMedia,
|
||||
tooltip: 'addVideo'.tr(),
|
||||
icon: const Icon(Symbols.videocam),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: addAudio,
|
||||
tooltip: 'addAudio'.tr(),
|
||||
icon: const Icon(Symbols.mic),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: pickGeneralFile,
|
||||
tooltip: 'uploadFile'.tr(),
|
||||
icon: const Icon(Symbols.file_upload),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
UploadMenu(items: uploadMenuItems, isCompact: isCompact),
|
||||
IconButton(
|
||||
onPressed: linkAttachment,
|
||||
icon: const Icon(Symbols.attach_file),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/widgets/post/post_item.dart';
|
||||
@@ -44,32 +45,22 @@ class PostFeaturedList extends HookConsumerWidget {
|
||||
|
||||
// Log isCollapsed state changes
|
||||
useEffect(() {
|
||||
debugPrint(
|
||||
'PostFeaturedList: isCollapsed changed to ${isCollapsed.value}',
|
||||
);
|
||||
talker.info('isCollapsed changed to ${isCollapsed.value}');
|
||||
return null;
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() {
|
||||
if (featuredPostsAsync.hasValue && featuredPostsAsync.value!.isNotEmpty) {
|
||||
final currentFirstPostId = featuredPostsAsync.value!.first.id;
|
||||
debugPrint(
|
||||
'PostFeaturedList: Current first post ID: $currentFirstPostId',
|
||||
);
|
||||
debugPrint(
|
||||
'PostFeaturedList: Previous first post ID: ${previousFirstPostId.value}',
|
||||
);
|
||||
debugPrint(
|
||||
'PostFeaturedList: Stored collapsed ID: ${storedCollapsedId.value}',
|
||||
);
|
||||
talker.info('Current first post ID: $currentFirstPostId');
|
||||
talker.info('Previous first post ID: ${previousFirstPostId.value}');
|
||||
talker.info('Stored collapsed ID: ${storedCollapsedId.value}');
|
||||
|
||||
if (previousFirstPostId.value == null) {
|
||||
// Initial load
|
||||
previousFirstPostId.value = currentFirstPostId;
|
||||
isCollapsed.value = (storedCollapsedId.value == currentFirstPostId);
|
||||
debugPrint(
|
||||
'PostFeaturedList: Initial load. isCollapsed set to ${isCollapsed.value}',
|
||||
);
|
||||
talker.info('Initial load. isCollapsed set to ${isCollapsed.value}');
|
||||
} else if (previousFirstPostId.value != currentFirstPostId) {
|
||||
// First post changed, expand by default
|
||||
previousFirstPostId.value = currentFirstPostId;
|
||||
@@ -77,20 +68,14 @@ class PostFeaturedList extends HookConsumerWidget {
|
||||
prefs.remove(
|
||||
kFeaturedPostsCollapsedId,
|
||||
); // Clear stored ID if post changes
|
||||
debugPrint(
|
||||
'PostFeaturedList: First post changed. isCollapsed set to false.',
|
||||
);
|
||||
talker.info('First post changed. isCollapsed set to false.');
|
||||
} else {
|
||||
// Same first post, maintain current collapse state
|
||||
// No change needed for isCollapsed.value unless manually toggled
|
||||
debugPrint(
|
||||
'PostFeaturedList: Same first post. Maintaining current collapse state.',
|
||||
);
|
||||
talker.info('Same first post. Maintaining current collapse state.');
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'PostFeaturedList: featuredPostsAsync has no value or is empty.',
|
||||
);
|
||||
talker.info('featuredPostsAsync has no value or is empty.');
|
||||
}
|
||||
return null;
|
||||
}, [featuredPostsAsync]);
|
||||
@@ -142,8 +127,8 @@ class PostFeaturedList extends HookConsumerWidget {
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
debugPrint(
|
||||
'PostFeaturedList: Manual toggle. isCollapsed set to ${isCollapsed.value}',
|
||||
talker.info(
|
||||
'Manual toggle. isCollapsed set to ${isCollapsed.value}',
|
||||
);
|
||||
if (isCollapsed.value &&
|
||||
featuredPostsAsync.hasValue &&
|
||||
@@ -152,14 +137,12 @@ class PostFeaturedList extends HookConsumerWidget {
|
||||
kFeaturedPostsCollapsedId,
|
||||
featuredPostsAsync.value!.first.id,
|
||||
);
|
||||
debugPrint(
|
||||
'PostFeaturedList: Stored collapsed ID: ${featuredPostsAsync.value!.first.id}',
|
||||
talker.info(
|
||||
'Stored collapsed ID: ${featuredPostsAsync.value!.first.id}',
|
||||
);
|
||||
} else {
|
||||
prefs.remove(kFeaturedPostsCollapsedId);
|
||||
debugPrint(
|
||||
'PostFeaturedList: Removed stored collapsed ID.',
|
||||
);
|
||||
talker.info('Removed stored collapsed ID.');
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user