Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
387d19d85c
|
|||
|
f7b991663f
|
|||
|
3a57f4265b
|
|||
|
d1ee2e5160
|
|||
|
bcd6753ed2
|
|||
|
321ea4458b
|
|||
|
8ad31dad58
|
|||
|
269c17d068
|
|||
|
a9abd777e1
|
|||
|
e24b1fc135
|
|||
|
d5feea52fa
|
|||
|
491252bba9
|
|||
|
4f569fbefd
|
|||
|
476da28b5e
|
|||
|
d639df7623
|
|||
|
e1fc5311d2
|
|||
|
d0e4fde6c2
|
|||
|
9437339b0f
|
|||
|
dd7696132c
|
|||
|
95daa3c28d
|
|||
|
ac5193e1f6
|
|||
|
0328a7736a
|
|||
|
03b332f677
|
|||
|
91b2797fb9
|
|||
|
b66640c6df
|
|||
|
4ac10706ae
|
|||
|
bf2844162d
|
|||
|
23c11a2fbd
|
|||
|
2b99f54bc5
|
|||
|
ab2fc1013b
|
|||
|
|
9a41ff26ef | ||
|
63a55658ab
|
|||
|
|
3122ae4cc2 | ||
|
00e063e99f
|
|||
|
532bb30c6a
|
|||
|
d8c33b576f
|
|||
|
a984cba2fa
|
|||
|
b7d5aa5dfb
|
|||
|
826238a374
|
|||
|
88c4d648d5
|
|||
|
bf59108569
|
|||
|
eec181da55
|
|||
|
c93b543da9
|
|||
|
2fd93246c7
|
|||
|
1b2620e957
|
|||
|
d443343052
|
|||
|
9957905212
|
|||
|
e36d694397
|
|||
|
3847581f1f
|
|||
|
64903bf1f3
|
|||
|
a449fbb58a
|
|||
|
6a4a50b81a
|
|||
|
5e328b736f
|
|||
|
d44e481a0d
|
|||
|
e6fb38572c
|
|||
|
64c5ee0fe9
|
|||
|
9cba568e47
|
|||
|
38fc4e969f
|
|||
|
0e100c3e64
|
|||
|
735724ae4d
|
|||
|
6c31337fbc
|
|||
|
d2806fdc3a
|
|||
|
4ebbda1b14
|
|||
|
3e7007f58f
|
|||
|
d96629a7f1
|
|||
|
1b6ccccf32
|
|||
|
910dafaa43
|
|||
|
c6fd342072
|
|||
|
757f1b880c
|
|||
|
67805b413e
|
|||
|
e1eb0f705e
|
|||
|
1e8e785e5f
|
|||
|
d9dfea6b8b
|
|||
|
d29f0b1170
|
|||
|
479a79c7f6
|
BIN
assets/audio/messages.mp3
Normal file
BIN
assets/audio/messages.mp3
Normal file
Binary file not shown.
BIN
assets/audio/notification.mp3
Normal file
BIN
assets/audio/notification.mp3
Normal file
Binary file not shown.
@@ -283,14 +283,17 @@
|
||||
"relationshipStatusBlocked": "Blocked",
|
||||
"blockUser": "Block User",
|
||||
"unblockUser": "Unblock User",
|
||||
"forgotRelationship": "Forgot Relationship",
|
||||
"forgotRelationshipConfirm": "Are you sure you want to forget your relationship with {}? This only changes your relationship status with she / her, she / her might not able to see your friend only posts and use other friend only features. But they can send the friend request again.",
|
||||
"relationshipDeleted": "Relationship deleted",
|
||||
"friendRequestAccepted": "Accepted friend request from {}",
|
||||
"friendRequestDeclined": "Declined friend request from {}",
|
||||
"requestExpiredIn": "Expired in {}",
|
||||
"friendSentRequest": "Sent Friend Requests",
|
||||
"friendSentRequestEmpty": "No sent friend requests",
|
||||
"friendSentRequestHint": {
|
||||
"one": "{} friend request sent",
|
||||
"other": "{} friend requests sent"
|
||||
"friendRequests": "Friend Requests",
|
||||
"friendRequestsEmpty": "No related friend requests",
|
||||
"friendRequestsHint": {
|
||||
"one": "{} friend request",
|
||||
"other": "{} friend requests"
|
||||
},
|
||||
"levelingProgress": "Leveling Progress",
|
||||
"levelingProgressExperience": "{} EXP",
|
||||
@@ -1584,5 +1587,12 @@
|
||||
"fediversePostDescribe": "Post from the Fediverse Network",
|
||||
"settingsShowFediverseContent": "Show Fediverse Content",
|
||||
"universalSearch": "Universal Search",
|
||||
"universalSearchDescription": "Search content across the Solar Network and the fediverse network."
|
||||
"universalSearchDescription": "Search content across the Solar Network and the fediverse network.",
|
||||
"tasks": "Tasks",
|
||||
"tasksCount": {
|
||||
"one": "{} task",
|
||||
"other": "{} tasks"
|
||||
},
|
||||
"setAsThumbnail": "Set as thumbnail",
|
||||
"unsetAsThumbnail": "Unset as thumbnail"
|
||||
}
|
||||
@@ -265,6 +265,9 @@
|
||||
"relationshipStatusBlocked": "Blocked",
|
||||
"blockUser": "Block User",
|
||||
"unblockUser": "Unblock User",
|
||||
"forgotRelationship": "Olvidar Relación",
|
||||
"forgotRelationshipConfirm": "¿Estás seguro de que quieres olvidar tu relación con {}?",
|
||||
"relationshipDeleted": "Relación eliminada",
|
||||
"friendRequestAccepted": "Accepted friend request from {}",
|
||||
"friendRequestDeclined": "Declined friend request from {}",
|
||||
"requestExpiredIn": "Expired in {}",
|
||||
|
||||
@@ -265,6 +265,9 @@
|
||||
"relationshipStatusBlocked": "Blocked",
|
||||
"blockUser": "Block User",
|
||||
"unblockUser": "Unblock User",
|
||||
"forgotRelationship": "関係を忘れる",
|
||||
"forgotRelationshipConfirm": "{} との関係を忘れてもよろしいですか?",
|
||||
"relationshipDeleted": "関係が削除されました",
|
||||
"friendRequestAccepted": "Accepted friend request from {}",
|
||||
"friendRequestDeclined": "Declined friend request from {}",
|
||||
"requestExpiredIn": "Expired in {}",
|
||||
|
||||
@@ -265,6 +265,9 @@
|
||||
"relationshipStatusBlocked": "Blocked",
|
||||
"blockUser": "Block User",
|
||||
"unblockUser": "Unblock User",
|
||||
"forgotRelationship": "관계 잊기",
|
||||
"forgotRelationshipConfirm": "{}와의 관계를 잊으시겠습니까?",
|
||||
"relationshipDeleted": "관계가 삭제되었습니다",
|
||||
"friendRequestAccepted": "Accepted friend request from {}",
|
||||
"friendRequestDeclined": "Declined friend request from {}",
|
||||
"requestExpiredIn": "Expired in {}",
|
||||
|
||||
@@ -265,6 +265,9 @@
|
||||
"relationshipStatusBlocked": "已屏蔽",
|
||||
"blockUser": "屏蔽用户",
|
||||
"unblockUser": "解除屏蔽用户",
|
||||
"forgotRelationship": "忘记关系",
|
||||
"forgotRelationshipConfirm": "确定要忘记与 {} 的关系吗?这只会改变您对他/她的关系,他可能看到不到您的友限帖,但是您仍然可以看到他/她的。他/她也可以再次提出好友申请。",
|
||||
"relationshipDeleted": "关系已删除",
|
||||
"friendRequestAccepted": "已接受 {} 的好友请求",
|
||||
"friendRequestDeclined": "已拒绝 {} 的好友请求",
|
||||
"requestExpiredIn": "{} 后过期",
|
||||
|
||||
@@ -265,6 +265,9 @@
|
||||
"relationshipStatusBlocked": "已阻",
|
||||
"blockUser": "禁此戶",
|
||||
"unblockUser": "解禁",
|
||||
"forgotRelationship": "忘締交",
|
||||
"forgotRelationshipConfirm": "確定要忘與 {} 之締交乎?",
|
||||
"relationshipDeleted": "締交已忘",
|
||||
"friendRequestAccepted": "已納 {} 締交書",
|
||||
"friendRequestDeclined": "已謝 {} 締交書",
|
||||
"requestExpiredIn": "效期 {}",
|
||||
|
||||
@@ -265,6 +265,9 @@
|
||||
"relationshipStatusBlocked": "已屏蔽",
|
||||
"blockUser": "屏蔽用戶",
|
||||
"unblockUser": "解除屏蔽用戶",
|
||||
"forgotRelationship": "忘記關係",
|
||||
"forgotRelationshipConfirm": "確定要忘記與 {} 的關係嗎?",
|
||||
"relationshipDeleted": "關係已刪除",
|
||||
"friendRequestAccepted": "已接受 {} 的好友請求",
|
||||
"friendRequestDeclined": "已拒絕 {} 的好友請求",
|
||||
"requestExpiredIn": "{} 後過期",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
platform :ios, '15.0'
|
||||
platform :ios, '16.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
PODS:
|
||||
- Alamofire (5.11.0)
|
||||
- audio_session (0.0.1):
|
||||
- Flutter
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- croppy (0.0.1):
|
||||
@@ -125,6 +127,8 @@ PODS:
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesSwift (~> 2.1)
|
||||
- Flutter (1.0.0)
|
||||
- flutter_app_intents (0.1.0):
|
||||
- Flutter
|
||||
- flutter_app_update (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview_ios (0.0.1):
|
||||
@@ -217,6 +221,9 @@ PODS:
|
||||
- Flutter
|
||||
- irondash_engine_context (0.0.1):
|
||||
- Flutter
|
||||
- just_audio (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- KeychainAccess (4.2.2)
|
||||
- Kingfisher (8.6.2)
|
||||
- KingfisherWebP (1.7.2):
|
||||
@@ -269,6 +276,8 @@ PODS:
|
||||
- PromisesObjC (= 2.4.0)
|
||||
- protocol_handler_ios (0.0.1):
|
||||
- Flutter
|
||||
- quick_actions_ios (0.0.1):
|
||||
- Flutter
|
||||
- receive_sharing_intent (1.8.1):
|
||||
- Flutter
|
||||
- record_ios (1.1.0):
|
||||
@@ -320,12 +329,16 @@ PODS:
|
||||
- Flutter
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- video_thumbnail (0.0.1):
|
||||
- Flutter
|
||||
- libwebp
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
- WebRTC-SDK (137.7151.04)
|
||||
|
||||
DEPENDENCIES:
|
||||
- Alamofire
|
||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- croppy (from `.symlinks/plugins/croppy/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
@@ -336,6 +349,7 @@ DEPENDENCIES:
|
||||
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
|
||||
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_app_intents (from `.symlinks/plugins/flutter_app_intents/ios`)
|
||||
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
|
||||
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
||||
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
|
||||
@@ -349,6 +363,7 @@ DEPENDENCIES:
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
|
||||
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
|
||||
- Kingfisher (~> 8.0)
|
||||
- KingfisherWebP
|
||||
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
|
||||
@@ -362,6 +377,7 @@ DEPENDENCIES:
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
|
||||
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
|
||||
- quick_actions_ios (from `.symlinks/plugins/quick_actions_ios/ios`)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||
- sensors_plus (from `.symlinks/plugins/sensors_plus/ios`)
|
||||
@@ -373,6 +389,7 @@ DEPENDENCIES:
|
||||
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
|
||||
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
@@ -408,6 +425,8 @@ SPEC REPOS:
|
||||
- WebRTC-SDK
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_session:
|
||||
:path: ".symlinks/plugins/audio_session/ios"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
croppy:
|
||||
@@ -428,6 +447,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/firebase_messaging/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_app_intents:
|
||||
:path: ".symlinks/plugins/flutter_app_intents/ios"
|
||||
flutter_app_update:
|
||||
:path: ".symlinks/plugins/flutter_app_update/ios"
|
||||
flutter_inappwebview_ios:
|
||||
@@ -454,6 +475,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
irondash_engine_context:
|
||||
:path: ".symlinks/plugins/irondash_engine_context/ios"
|
||||
just_audio:
|
||||
:path: ".symlinks/plugins/just_audio/darwin"
|
||||
livekit_client:
|
||||
:path: ".symlinks/plugins/livekit_client/ios"
|
||||
local_auth_darwin:
|
||||
@@ -476,6 +499,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
|
||||
protocol_handler_ios:
|
||||
:path: ".symlinks/plugins/protocol_handler_ios/ios"
|
||||
quick_actions_ios:
|
||||
:path: ".symlinks/plugins/quick_actions_ios/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
record_ios:
|
||||
@@ -498,11 +523,14 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_thumbnail:
|
||||
:path: ".symlinks/plugins/video_thumbnail/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: bd5e7b23a1a750975288482c1831d71e74415f86
|
||||
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
@@ -525,6 +553,7 @@ SPEC CHECKSUMS:
|
||||
FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4
|
||||
FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_app_intents: e77f999f398c841ab584a1925dbce33ee0168fb5
|
||||
flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
|
||||
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
|
||||
flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619
|
||||
@@ -542,6 +571,7 @@ SPEC CHECKSUMS:
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
|
||||
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
||||
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
||||
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
|
||||
Kingfisher: 23d18f54677d973b713e54ce6a8f5eef6e7056ba
|
||||
KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402
|
||||
@@ -561,6 +591,7 @@ SPEC CHECKSUMS:
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
|
||||
protocol_handler_ios: 59f23ee71f3ec602d67902ca7f669a80957888d5
|
||||
quick_actions_ios: 500fcc11711d9f646739093395c4ae8eec25f779
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
|
||||
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
|
||||
@@ -575,9 +606,10 @@ SPEC CHECKSUMS:
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
|
||||
|
||||
PODFILE CHECKSUM: 585198f58dca90ac6492607c83a8d17045ab3852
|
||||
PODFILE CHECKSUM: 5a3652182a0f0e1093d3ac36dcabc72f6cc46b3a
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -15,6 +15,10 @@
|
||||
7301DB052F08D99C008390F3 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7301DB042F08D99C008390F3 /* SwiftUI.framework */; };
|
||||
7301DB102F08D99D008390F3 /* SolianWidgetExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7301DB012F08D99C008390F3 /* SolianWidgetExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
7310A7DF2EB10963002C0FD3 /* Solian Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 7310A7D42EB10962002C0FD3 /* Solian Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
73595B1B2F17FF8000AAD53C /* SfxMessage.caf in Resources */ = {isa = PBXBuildFile; fileRef = 73595B162F17FF8000AAD53C /* SfxMessage.caf */; };
|
||||
73595B1C2F17FF8000AAD53C /* SfxNotification.caf in Resources */ = {isa = PBXBuildFile; fileRef = 73595B172F17FF8000AAD53C /* SfxNotification.caf */; };
|
||||
73595B832F1803D300AAD53C /* SfxNotification.caf in Resources */ = {isa = PBXBuildFile; fileRef = 73595B172F17FF8000AAD53C /* SfxNotification.caf */; };
|
||||
73595B842F1803D300AAD53C /* SfxMessage.caf in Resources */ = {isa = PBXBuildFile; fileRef = 73595B162F17FF8000AAD53C /* SfxMessage.caf */; };
|
||||
73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; };
|
||||
73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
@@ -133,6 +137,8 @@
|
||||
7301DB042F08D99C008390F3 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
7301DB162F08D9A5008390F3 /* SolianWidgetExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SolianWidgetExtensionExtension.entitlements; sourceTree = "<group>"; };
|
||||
7310A7D42EB10962002C0FD3 /* Solian Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Solian Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
73595B162F17FF8000AAD53C /* SfxMessage.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = SfxMessage.caf; sourceTree = "<group>"; };
|
||||
73595B172F17FF8000AAD53C /* SfxNotification.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = SfxNotification.caf; sourceTree = "<group>"; };
|
||||
737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianBroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; };
|
||||
@@ -216,6 +222,8 @@
|
||||
};
|
||||
7310A7D52EB10962002C0FD3 /* Solian Watch App */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = "Solian Watch App";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -394,6 +402,8 @@
|
||||
91E124CE95BCB4DCD890160D /* Pods */,
|
||||
498A09270B73B217F0279168 /* Frameworks */,
|
||||
9AE244813FCDFAA941430393 /* GoogleService-Info.plist */,
|
||||
73595B162F17FF8000AAD53C /* SfxMessage.caf */,
|
||||
73595B172F17FF8000AAD53C /* SfxNotification.caf */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -693,6 +703,8 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
73595B1B2F17FF8000AAD53C /* SfxMessage.caf in Resources */,
|
||||
73595B1C2F17FF8000AAD53C /* SfxNotification.caf in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -701,6 +713,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||
73595B832F1803D300AAD53C /* SfxNotification.caf in Resources */,
|
||||
73595B842F1803D300AAD53C /* SfxMessage.caf in Resources */,
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
@@ -757,14 +771,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
@@ -822,14 +832,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
@@ -880,14 +886,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks.sh\"\n";
|
||||
@@ -1106,7 +1108,7 @@
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Solian;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1607,7 +1609,7 @@
|
||||
INFOPLIST_FILE = SolianShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1652,7 +1654,7 @@
|
||||
INFOPLIST_FILE = SolianShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1694,7 +1696,7 @@
|
||||
INFOPLIST_FILE = SolianShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1960,7 +1962,7 @@
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Solian;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1991,7 +1993,7 @@
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Solian;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -2,6 +2,8 @@ import Flutter
|
||||
import WidgetKit
|
||||
import UIKit
|
||||
import WatchConnectivity
|
||||
import AppIntents
|
||||
import flutter_app_intents
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -107,7 +109,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
let token = UserDefaults.standard.getFlutterToken()
|
||||
let serverUrl = UserDefaults.standard.getServerUrl()
|
||||
|
||||
var data: [String: Any] = ["serverUrl": serverUrl ?? ""]
|
||||
var data: [String: Any] = ["serverUrl": serverUrl]
|
||||
if let token = token {
|
||||
data["token"] = token
|
||||
}
|
||||
@@ -125,7 +127,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
let token = UserDefaults.standard.getFlutterToken()
|
||||
let serverUrl = UserDefaults.standard.getServerUrl()
|
||||
|
||||
var data: [String: Any] = ["serverUrl": serverUrl ?? ""]
|
||||
var data: [String: Any] = ["serverUrl": serverUrl]
|
||||
if let token = token {
|
||||
data["token"] = token
|
||||
}
|
||||
@@ -138,3 +140,412 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Intents
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct OpenChatIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Open Chat"
|
||||
static var description = IntentDescription("Open a specific chat room")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Channel ID")
|
||||
var channelId: String?
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "open_chat",
|
||||
parameters: ["channelId": channelId ?? ""]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Chat opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to open chat"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct OpenPostIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Open Post"
|
||||
static var description = IntentDescription("Open a specific post")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Post ID")
|
||||
var postId: String?
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "open_post",
|
||||
parameters: ["postId": postId ?? ""]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Post opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to open post"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct OpenComposeIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Open Compose"
|
||||
static var description = IntentDescription("Open compose post screen")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "open_compose",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Compose screen opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to open compose"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct ComposePostIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Compose Post"
|
||||
static var description = IntentDescription("Create a new post")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "compose_post",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Compose screen opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to compose post"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct SearchContentIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Search Content"
|
||||
static var description = IntentDescription("Search for content")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Search Query")
|
||||
var query: String?
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "search_content",
|
||||
parameters: ["query": query ?? ""]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Search opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to search"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct ViewNotificationsIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "View Notifications"
|
||||
static var description = IntentDescription("View notifications")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "view_notifications",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Notifications opened"
|
||||
return .result(value: value)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to view notifications"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct CheckNotificationsIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Check Notifications"
|
||||
static var description = IntentDescription("Check notification count")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = false
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "check_notifications",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "You have new notifications"
|
||||
return .result(
|
||||
value: value,
|
||||
dialog: "\(value)"
|
||||
)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to check notifications"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct SendMessageIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Send Message"
|
||||
static var description = IntentDescription("Send a message to a chat channel")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = false
|
||||
|
||||
@Parameter(title: "Channel ID")
|
||||
var channelId: String?
|
||||
|
||||
@Parameter(title: "Message Content")
|
||||
var content: String?
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
|
||||
guard let channelId = channelId, !channelId.isEmpty else {
|
||||
throw AppIntentError.executionFailed("Channel ID is required")
|
||||
}
|
||||
|
||||
guard let content = content, !content.isEmpty else {
|
||||
throw AppIntentError.executionFailed("Message content is required")
|
||||
}
|
||||
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "send_message",
|
||||
parameters: ["channelId": channelId, "content": content]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Message sent"
|
||||
return .result(
|
||||
value: value,
|
||||
dialog: "\(value)"
|
||||
)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to send message"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct ReadMessagesIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Read Messages"
|
||||
static var description = IntentDescription("Read recent messages from a chat channel")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = false
|
||||
|
||||
@Parameter(title: "Channel ID")
|
||||
var channelId: String?
|
||||
|
||||
@Parameter(title: "Number of Messages", default: "5")
|
||||
var limit: String?
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
|
||||
guard let channelId = channelId, !channelId.isEmpty else {
|
||||
throw AppIntentError.executionFailed("Channel ID is required")
|
||||
}
|
||||
|
||||
let limit = limit ?? "5"
|
||||
var parameters: [String: Any] = ["channelId": channelId]
|
||||
parameters["limit"] = limit
|
||||
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "read_messages",
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Messages retrieved"
|
||||
return .result(
|
||||
value: value,
|
||||
dialog: "\(value)"
|
||||
)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to read messages"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct CheckUnreadChatsIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Check Unread Chats"
|
||||
static var description = IntentDescription("Check number of unread chat messages")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = false
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "check_unread_chats",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "No unread messages"
|
||||
return .result(
|
||||
value: value,
|
||||
dialog: "\(value)"
|
||||
)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to check unread chats"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct MarkNotificationsReadIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Mark Notifications Read"
|
||||
static var description = IntentDescription("Mark all notifications as read")
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = false
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
|
||||
let plugin = FlutterAppIntentsPlugin.shared
|
||||
let result = await plugin.handleIntentInvocation(
|
||||
identifier: "mark_notifications_read",
|
||||
parameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? "Notifications marked as read"
|
||||
return .result(
|
||||
value: value,
|
||||
dialog: "\(value)"
|
||||
)
|
||||
} else {
|
||||
let errorMessage = result["error"] as? String ?? "Failed to mark notifications as read"
|
||||
throw AppIntentError.executionFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppIntentError: Error {
|
||||
case executionFailed(String)
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct AppShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
return [
|
||||
// Open chat
|
||||
AppShortcut(
|
||||
intent: OpenChatIntent(),
|
||||
phrases: [
|
||||
"Open chat with \(.applicationName)",
|
||||
"Go to chat using \(.applicationName)",
|
||||
"Show chat in \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Open post
|
||||
AppShortcut(
|
||||
intent: OpenPostIntent(),
|
||||
phrases: [
|
||||
"Open post with \(.applicationName)",
|
||||
"Show post using \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Compose
|
||||
AppShortcut(
|
||||
intent: OpenComposeIntent(),
|
||||
phrases: [
|
||||
"Open compose with \(.applicationName)",
|
||||
"New post using \(.applicationName)",
|
||||
"Write post in \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Search
|
||||
AppShortcut(
|
||||
intent: SearchContentIntent(),
|
||||
phrases: [
|
||||
"Search in \(.applicationName)",
|
||||
"Find content using \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Check notifications
|
||||
AppShortcut(
|
||||
intent: CheckNotificationsIntent(),
|
||||
phrases: [
|
||||
"Check notifications with \(.applicationName)",
|
||||
"Get notifications using \(.applicationName)",
|
||||
"Do I have notifications in \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Send message
|
||||
AppShortcut(
|
||||
intent: SendMessageIntent(),
|
||||
phrases: [
|
||||
"Send message with \(.applicationName)",
|
||||
"Post message using \(.applicationName)",
|
||||
"Send text using \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Read messages
|
||||
AppShortcut(
|
||||
intent: ReadMessagesIntent(),
|
||||
phrases: [
|
||||
"Read messages with \(.applicationName)",
|
||||
"Get chat using \(.applicationName)",
|
||||
"Show messages with \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Check unread chats
|
||||
AppShortcut(
|
||||
intent: CheckUnreadChatsIntent(),
|
||||
phrases: [
|
||||
"Check unread chats with \(.applicationName)",
|
||||
"Do I have messages using \(.applicationName)",
|
||||
"Get unread messages with \(.applicationName)"
|
||||
]
|
||||
),
|
||||
// Mark notifications read
|
||||
AppShortcut(
|
||||
intent: MarkNotificationsReadIntent(),
|
||||
phrases: [
|
||||
"Mark notifications read with \(.applicationName)",
|
||||
"Clear notifications using \(.applicationName)",
|
||||
"Mark all read with \(.applicationName)"
|
||||
]
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
6
ios/Runner/Assets.xcassets/Contents.json
Normal file
6
ios/Runner/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,18 @@
|
||||
<string>Allow the Solar Network verify your ownership of the logged in account and continue your action quickly.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Grant access to Microphone will allow Solian record audio for your post.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Solian uses speech recognition for Siri integration</string>
|
||||
<key>NSAppIntentsConfiguration</key>
|
||||
<dict>
|
||||
<key>NSAppIntentsPackage</key>
|
||||
<string>dev.solsynth.solian</string>
|
||||
</dict>
|
||||
<key>NSAppIntentsMetadata</key>
|
||||
<dict>
|
||||
<key>NSAppIntentsSupported</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Grant access to Photo Library will allow Solian download photo to album for you.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
|
||||
BIN
ios/SfxMessage.caf
Normal file
BIN
ios/SfxMessage.caf
Normal file
Binary file not shown.
BIN
ios/SfxNotification.caf
Normal file
BIN
ios/SfxNotification.caf
Normal file
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
// Created by LittleSheep on 2025/5/31.
|
||||
//
|
||||
|
||||
import UserNotifications
|
||||
@preconcurrency import UserNotifications
|
||||
import Intents
|
||||
import Kingfisher
|
||||
import UniformTypeIdentifiers
|
||||
@@ -53,8 +53,10 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
private func processNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws {
|
||||
switch content.userInfo["type"] as? String {
|
||||
case "messages.new":
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName("SfxMessage.caf"))
|
||||
try handleMessagingNotification(request: request, content: content)
|
||||
default:
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName("SfxNotification.caf"))
|
||||
try handleDefaultNotification(content: content)
|
||||
}
|
||||
}
|
||||
@@ -149,7 +151,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
var attachments: [UNNotificationAttachment] = []
|
||||
let lock = NSLock() // To synchronize access to the attachments array
|
||||
let queue = DispatchQueue(label: "dev.solsynth.solian.nse")
|
||||
|
||||
for attachmentUrl in attachmentUrls {
|
||||
guard let remoteUrl = URL(string: attachmentUrl) else {
|
||||
@@ -163,7 +165,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
.processor(scaleProcessor)
|
||||
] : nil) { [weak self] result in
|
||||
defer { dispatchGroup.leave() }
|
||||
guard let self = self else { return }
|
||||
guard self != nil else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let retrievalResult):
|
||||
@@ -176,12 +178,12 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
try retrievalResult.image.pngData()?.write(to: cachedFileUrl)
|
||||
|
||||
if let attachment = try? UNNotificationAttachment(identifier: attachmentUrl, url: cachedFileUrl, options: [
|
||||
UNNotificationAttachmentOptionsTypeHintKey: type?.identifier as Any,
|
||||
UNNotificationAttachmentOptionsTypeHintKey: UTType.png.identifier,
|
||||
UNNotificationAttachmentOptionsThumbnailHiddenKey: 0,
|
||||
]) {
|
||||
lock.lock()
|
||||
queue.async {
|
||||
attachments.append(attachment)
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed to write media to temporary file: \(error.localizedDescription)")
|
||||
|
||||
@@ -56,7 +56,16 @@ struct CheckInResult: Codable {
|
||||
}
|
||||
|
||||
var createdDate: Date? {
|
||||
ISO8601DateFormatter().date(from: createdAt)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
if let date = formatter.date(from: createdAt) {
|
||||
return date
|
||||
}
|
||||
|
||||
// Fallback for timestamps without fractional seconds
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.date(from: createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,16 @@ struct SnNotification: Codable, Identifiable {
|
||||
}
|
||||
|
||||
var createdDate: Date? {
|
||||
ISO8601DateFormatter().date(from: createdAt)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
if let date = formatter.date(from: createdAt) {
|
||||
return date
|
||||
}
|
||||
|
||||
// Fallback for timestamps without fractional seconds
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.date(from: createdAt)
|
||||
}
|
||||
|
||||
var isUnread: Bool {
|
||||
@@ -276,6 +285,18 @@ struct NotificationWidgetEntryView: View {
|
||||
if case .accessoryRectangular = family {
|
||||
return true
|
||||
}
|
||||
if case .accessoryCircular = family {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private var isCircular: Bool {
|
||||
if #available(iOS 16.0, *) {
|
||||
if case .accessoryCircular = family {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -285,6 +306,22 @@ struct NotificationWidgetEntryView: View {
|
||||
Link(destination: URL(string: "solian://notifications")!) {
|
||||
if isCompact {
|
||||
if isAccessory {
|
||||
if isCircular {
|
||||
ZStack {
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(unreadCount > 0 ? .orange : .gray)
|
||||
|
||||
if unreadCount > 0 {
|
||||
Text("\(min(unreadCount, 99))")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(4)
|
||||
.background(Circle().fill(Color.blue))
|
||||
.offset(x: 12, y: -12)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "bell.fill")
|
||||
@@ -320,6 +357,7 @@ struct NotificationWidgetEntryView: View {
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 1.5)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
@@ -490,6 +528,13 @@ struct NotificationWidgetEntryView: View {
|
||||
@ViewBuilder
|
||||
private func EmptyView() -> some View {
|
||||
Link(destination: URL(string: "solian://notifications")!) {
|
||||
if isCircular {
|
||||
ZStack {
|
||||
Image(systemName: "bell")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "bell")
|
||||
@@ -514,9 +559,14 @@ struct NotificationWidgetEntryView: View {
|
||||
.padding(isAccessory ? 4 : 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func LoadingView() -> some View {
|
||||
if isCircular {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
@@ -533,10 +583,18 @@ struct NotificationWidgetEntryView: View {
|
||||
}
|
||||
.padding(isAccessory ? 4 : 12)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ErrorView(error: String) -> some View {
|
||||
Link(destination: URL(string: "solian://notifications")!) {
|
||||
if isCircular {
|
||||
ZStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
@@ -565,6 +623,7 @@ struct NotificationWidgetEntryView: View {
|
||||
.padding(isAccessory ? 4 : 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatRelativeTime(_ date: Date) -> String {
|
||||
let now = Date()
|
||||
@@ -647,13 +706,14 @@ struct SolianNotificationWidget: Widget {
|
||||
|
||||
private var supportedFamilies: [WidgetFamily] {
|
||||
#if os(iOS)
|
||||
return [.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular]
|
||||
return [.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryCircular]
|
||||
#else
|
||||
return [.systemSmall, .systemMedium, .systemLarge]
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
#Preview(as: .accessoryRectangular) {
|
||||
SolianNotificationWidget()
|
||||
} timeline: {
|
||||
@@ -694,6 +754,7 @@ struct SolianNotificationWidget: Widget {
|
||||
isLoading: false
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
#Preview(as: .systemSmall) {
|
||||
SolianNotificationWidget()
|
||||
@@ -846,4 +907,31 @@ struct SolianNotificationWidget: Widget {
|
||||
isLoading: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview(as: .accessoryCircular) {
|
||||
SolianNotificationWidget()
|
||||
} timeline: {
|
||||
NotificationEntry(
|
||||
date: .now,
|
||||
notifications: [
|
||||
SnNotification(
|
||||
id: "1",
|
||||
topic: "post.replies",
|
||||
title: "New reply",
|
||||
subtitle: "Someone replied",
|
||||
content: "Content",
|
||||
meta: nil,
|
||||
priority: 0,
|
||||
viewedAt: nil,
|
||||
accountId: "acc-1",
|
||||
createdAt: ISO8601DateFormatter().string(from: Date()),
|
||||
updatedAt: ISO8601DateFormatter().string(from: Date()),
|
||||
deletedAt: nil
|
||||
)
|
||||
],
|
||||
unreadCount: 5,
|
||||
error: nil,
|
||||
isLoading: false
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
549
ios/SolianWidgetExtension/SolianPostShuffleWidget.swift
Normal file
549
ios/SolianWidgetExtension/SolianPostShuffleWidget.swift
Normal file
@@ -0,0 +1,549 @@
|
||||
//
|
||||
// SolianPostShuffleWidget.swift
|
||||
// SolianWidgetExtension
|
||||
//
|
||||
// Created by LittleSheep on 2026/1/4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
struct SnPostPublisher: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let nick: String?
|
||||
let description: String?
|
||||
let picture: SnCloudFile?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case nick
|
||||
case description
|
||||
case picture
|
||||
}
|
||||
}
|
||||
|
||||
struct SnCloudFile: Codable {
|
||||
let id: String
|
||||
let url: String?
|
||||
let thumbnail: String?
|
||||
let mimeType: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case url
|
||||
case thumbnail
|
||||
case mimeType = "mimetype"
|
||||
}
|
||||
}
|
||||
|
||||
struct SnPost: Codable, Identifiable {
|
||||
let id: String
|
||||
let title: String?
|
||||
let description: String?
|
||||
let content: String?
|
||||
let publisher: SnPostPublisher
|
||||
let tags: [SnPostTag]?
|
||||
let createdAt: String?
|
||||
let updatedAt: String?
|
||||
let attachments: [SnCloudFile]?
|
||||
|
||||
var createdDate: Date? {
|
||||
guard let createdAt = createdAt else { return nil }
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
if let date = formatter.date(from: createdAt) {
|
||||
return date
|
||||
}
|
||||
|
||||
// Fallback for timestamps without fractional seconds
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.date(from: createdAt)
|
||||
}
|
||||
|
||||
var hasTitle: Bool {
|
||||
guard let title = title else { return false }
|
||||
return !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var hasDescription: Bool {
|
||||
guard let description = description else { return false }
|
||||
return !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var hasContent: Bool {
|
||||
guard let content = content else { return false }
|
||||
return !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var attachmentCount: Int {
|
||||
attachments?.count ?? 0
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case description
|
||||
case content
|
||||
case publisher
|
||||
case tags
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case attachments
|
||||
}
|
||||
}
|
||||
|
||||
struct SnPostTag: Codable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let name: String?
|
||||
}
|
||||
|
||||
class PostShuffleService {
|
||||
private let networkService = WidgetNetworkService()
|
||||
|
||||
private lazy var session: URLSession = {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 10.0
|
||||
configuration.timeoutIntervalForResource = 10.0
|
||||
configuration.waitsForConnectivity = false
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
|
||||
func fetchRandomPost() async throws -> SnPost? {
|
||||
guard let token = networkService.token else {
|
||||
throw RemoteError.missingCredentials
|
||||
}
|
||||
|
||||
let baseURL = networkService.baseURL
|
||||
guard let url = URL(string: "\(baseURL)/sphere/posts?shuffle=true&take=1") else {
|
||||
throw RemoteError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.timeoutInterval = 10.0
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw RemoteError.invalidResponse
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200...299:
|
||||
let decoder = JSONDecoder()
|
||||
let posts = try decoder.decode([SnPost].self, from: data)
|
||||
return posts.first
|
||||
case 404:
|
||||
return nil
|
||||
default:
|
||||
throw RemoteError.httpError(httpResponse.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PostShuffleEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let post: SnPost?
|
||||
let error: String?
|
||||
let isLoading: Bool
|
||||
|
||||
static func placeholder() -> PostShuffleEntry {
|
||||
PostShuffleEntry(date: Date(), post: nil, error: nil, isLoading: true)
|
||||
}
|
||||
}
|
||||
|
||||
struct PostShuffleProvider: TimelineProvider {
|
||||
private let postShuffleService = PostShuffleService()
|
||||
|
||||
func placeholder(in context: Context) -> PostShuffleEntry {
|
||||
PostShuffleEntry.placeholder()
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (PostShuffleEntry) -> ()) {
|
||||
Task {
|
||||
print("[WidgetKit] [PostShuffleProvider] Getting snapshot...")
|
||||
let post = try? await postShuffleService.fetchRandomPost()
|
||||
|
||||
print("[WidgetKit] [PostShuffleProvider] Snapshot - Post: \(post != nil ? "Found" : "Not found")")
|
||||
|
||||
let entry = PostShuffleEntry(date: Date(), post: post, error: nil, isLoading: false)
|
||||
completion(entry)
|
||||
}
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
|
||||
Task {
|
||||
let currentDate = Date()
|
||||
print("[WidgetKit] [PostShuffleProvider] Getting timeline at \(currentDate)...")
|
||||
|
||||
do {
|
||||
let post = try await postShuffleService.fetchRandomPost()
|
||||
|
||||
print("[WidgetKit] [PostShuffleProvider] Timeline - Post: \(post != nil ? "Found" : "Not found")")
|
||||
|
||||
let entry = PostShuffleEntry(date: currentDate, post: post, error: nil, isLoading: false)
|
||||
|
||||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 10, to: currentDate)!
|
||||
print("[WidgetKit] [PostShuffleProvider] Next update at: \(nextUpdate)")
|
||||
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
|
||||
completion(timeline)
|
||||
} catch {
|
||||
print("[WidgetKit] [PostShuffleProvider] Error in getTimeline: \(error.localizedDescription)")
|
||||
let entry = PostShuffleEntry(date: currentDate, post: nil, error: error.localizedDescription, isLoading: false)
|
||||
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)!
|
||||
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
|
||||
completion(timeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PostShuffleWidgetEntryView: View {
|
||||
var entry: PostShuffleProvider.Entry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
var body: some View {
|
||||
if let post = entry.post {
|
||||
PostContentView(post: post)
|
||||
} else if entry.isLoading {
|
||||
LoadingView()
|
||||
} else if let error = entry.error {
|
||||
ErrorView(error: error)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func PostContentView(post: SnPost) -> some View {
|
||||
Link(destination: URL(string: "solian://posts/\(post.id)")!) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if let avatarUrl = post.publisher.picture?.url ?? post.publisher.picture?.thumbnail {
|
||||
AsyncImage(url: URL(string: avatarUrl)) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
case .failure(_):
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
default:
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.overlay(
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(post.publisher.nick ?? post.publisher.name)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
|
||||
if let createdDate = post.createdDate {
|
||||
Text(formatRelativeTime(createdDate))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if post.attachmentCount > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(post.attachmentCount)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if post.hasTitle {
|
||||
Text(post.title!)
|
||||
.font(family == .systemMedium ? .subheadline : .body)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(family == .systemMedium ? 2 : 3)
|
||||
}
|
||||
|
||||
if post.hasDescription {
|
||||
Text(post.description!)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(family == .systemMedium ? 2 : 4)
|
||||
}
|
||||
|
||||
if post.hasContent {
|
||||
Text(post.content!)
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(family == .systemMedium ? 3 : 8)
|
||||
}
|
||||
|
||||
if let tags = post.tags, !tags.isEmpty {
|
||||
let displayTags = Array(tags.prefix(family == .systemMedium ? 2 : 4))
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(displayTags, id: \.id) { tag in
|
||||
Text("#\(tag.name ?? tag.slug)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.blue)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func EmptyView() -> some View {
|
||||
Link(destination: URL(string: "solian://posts/shuffle")!) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "text.alignleft")
|
||||
.font(.title3)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(NSLocalizedString("noPostsAvailable", comment: "No posts available"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(NSLocalizedString("tapToRefresh", comment: "Tap to refresh"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func LoadingView() -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text(NSLocalizedString("loadingPost", comment: "Loading post..."))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ErrorView(error: String) -> some View {
|
||||
Link(destination: URL(string: "solian://posts/shuffle")!) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title3)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(NSLocalizedString("error", comment: "Error"))
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(NSLocalizedString("openAppToRefresh", comment: "Open app to refresh"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(5)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatRelativeTime(_ date: Date) -> String {
|
||||
let now = Date()
|
||||
let interval = now.timeIntervalSince(date)
|
||||
|
||||
if interval < 60 {
|
||||
return NSLocalizedString("justNow", comment: "Just now")
|
||||
} else if interval < 3600 {
|
||||
let minutes = Int(interval / 60)
|
||||
return String(format: NSLocalizedString("minutesAgo", comment: "%d min ago"), minutes)
|
||||
} else if interval < 86400 {
|
||||
let hours = Int(interval / 3600)
|
||||
return String(format: NSLocalizedString("hoursAgo", comment: "%d hr ago"), hours)
|
||||
} else {
|
||||
let days = Int(interval / 86400)
|
||||
return String(format: NSLocalizedString("daysAgo", comment: "%d d ago"), days)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PostShuffleWidgetRootView: View {
|
||||
var entry: PostShuffleProvider.Entry
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 17.0, *) {
|
||||
ZStack {
|
||||
PostShuffleWidgetEntryView(entry: entry)
|
||||
|
||||
if entry.post != nil {
|
||||
GeometryReader { geometry in
|
||||
Image(colorScheme == .dark ? "CloudyLambDark" : "CloudyLamb")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(
|
||||
width: geometry.size.width * 0.9,
|
||||
height: geometry.size.width * 0.9
|
||||
)
|
||||
.opacity(0.12)
|
||||
.mask(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color.white,
|
||||
Color.white,
|
||||
Color.clear
|
||||
]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.position(
|
||||
x: geometry.size.width * 0.9,
|
||||
y: 20
|
||||
)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
PostShuffleWidgetEntryView(entry: entry)
|
||||
.padding()
|
||||
.background()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SolianPostShuffleWidget: Widget {
|
||||
let kind: String = "SolianPostShuffleWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: PostShuffleProvider()) { entry in
|
||||
PostShuffleWidgetRootView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Random Post")
|
||||
.description("Discover a random post from the network")
|
||||
.supportedFamilies(supportedFamilies)
|
||||
}
|
||||
|
||||
private var supportedFamilies: [WidgetFamily] {
|
||||
return [.systemMedium, .systemLarge]
|
||||
}
|
||||
}
|
||||
|
||||
#Preview(as: .systemMedium) {
|
||||
SolianPostShuffleWidget()
|
||||
} timeline: {
|
||||
PostShuffleEntry(
|
||||
date: .now,
|
||||
post: SnPost(
|
||||
id: "test-post-id",
|
||||
title: "Hello World!",
|
||||
description: "This is a test post description",
|
||||
content: "This is a content of a test post. It can be longer and show more text in the widget.",
|
||||
publisher: SnPostPublisher(
|
||||
id: "publisher-1",
|
||||
name: "Test Publisher",
|
||||
nick: "Testy",
|
||||
description: "A test publisher",
|
||||
picture: nil
|
||||
),
|
||||
tags: [
|
||||
SnPostTag(id: "tag-1", slug: "test", name: "Test"),
|
||||
SnPostTag(id: "tag-2", slug: "example", name: "Example")
|
||||
],
|
||||
createdAt: ISO8601DateFormatter().string(from: Date()),
|
||||
updatedAt: ISO8601DateFormatter().string(from: Date()),
|
||||
attachments: []
|
||||
),
|
||||
error: nil,
|
||||
isLoading: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview(as: .systemLarge) {
|
||||
SolianPostShuffleWidget()
|
||||
} timeline: {
|
||||
PostShuffleEntry(
|
||||
date: .now,
|
||||
post: SnPost(
|
||||
id: "test-post-id",
|
||||
title: "Welcome to the Solar Network!",
|
||||
description: "This is a test post description that is a bit longer to demonstrate the widget layout with various content types",
|
||||
content: "This is content of a test post. It can be longer and show more text in the widget. The large widget should display more content and allow for better reading experience. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
publisher: SnPostPublisher(
|
||||
id: "publisher-1",
|
||||
name: "Test Publisher",
|
||||
nick: "Testy McTestface",
|
||||
description: "A test publisher",
|
||||
picture: nil
|
||||
),
|
||||
tags: [
|
||||
SnPostTag(id: "tag-1", slug: "test", name: "Test"),
|
||||
SnPostTag(id: "tag-2", slug: "example", name: "Example"),
|
||||
SnPostTag(id: "tag-3", slug: "widget", name: "Widget"),
|
||||
SnPostTag(id: "tag-4", slug: "ios", name: "iOS")
|
||||
],
|
||||
createdAt: ISO8601DateFormatter().string(from: Date()),
|
||||
updatedAt: ISO8601DateFormatter().string(from: Date()),
|
||||
attachments: [
|
||||
SnCloudFile(id: "file-1", url: nil, thumbnail: nil, mimeType: "image/jpeg"),
|
||||
SnCloudFile(id: "file-2", url: nil, thumbnail: nil, mimeType: "image/png")
|
||||
]
|
||||
),
|
||||
error: nil,
|
||||
isLoading: false
|
||||
)
|
||||
}
|
||||
@@ -13,5 +13,6 @@ struct SolianWidgetExtensionBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
SolianCheckInWidget()
|
||||
SolianNotificationWidget()
|
||||
SolianPostShuffleWidget()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,3 +39,8 @@
|
||||
"noNotifications" = "No notifications";
|
||||
"noUnreadNotifications" = "All notifications are read";
|
||||
"unread" = "unread";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "No posts available";
|
||||
"tapToRefresh" = "Tap to refresh";
|
||||
"loadingPost" = "Loading post...";
|
||||
|
||||
@@ -34,3 +34,8 @@
|
||||
"invalidResponse" = "El servidor devolvió una respuesta no válida.";
|
||||
"httpError" = "Error del servidor (%d).";
|
||||
"decodingError" = "Error al leer los datos del servidor.";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "No hay publicaciones disponibles";
|
||||
"tapToRefresh" = "Toca para actualizar";
|
||||
"loadingPost" = "Cargando publicación...";
|
||||
|
||||
@@ -34,3 +34,8 @@
|
||||
"invalidResponse" = "サーバーから無効な応答が返されました。";
|
||||
"httpError" = "サーバーエラー (%d)。";
|
||||
"decodingError" = "サーバーデータの読み込みに失敗しました。";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "利用可能な投稿がありません";
|
||||
"tapToRefresh" = "タップして更新";
|
||||
"loadingPost" = "投稿を読み込み中...";
|
||||
|
||||
@@ -34,3 +34,8 @@
|
||||
"invalidResponse" = "서버에서 잘못된 응답을 받았습니다.";
|
||||
"httpError" = "서버 오류 (%d).";
|
||||
"decodingError" = "서버 데이터 읽기에 실패했습니다.";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "사용 가능한 게시물이 없습니다";
|
||||
"tapToRefresh" = "탭하여 새로고침";
|
||||
"loadingPost" = "게시물 로딩 중...";
|
||||
|
||||
@@ -39,3 +39,8 @@
|
||||
"noNotifications" = "没有通知";
|
||||
"noUnreadNotifications" = "没有未读通知";
|
||||
"unread" = "未读";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "没有可用的帖子";
|
||||
"tapToRefresh" = "点击刷新";
|
||||
"loadingPost" = "加载帖子中...";
|
||||
|
||||
@@ -34,3 +34,8 @@
|
||||
"invalidResponse" = "伺服器返回了無效響應。";
|
||||
"httpError" = "伺服器錯誤 (%d)。";
|
||||
"decodingError" = "讀取伺服器數據失敗。";
|
||||
|
||||
/* Post Shuffle Widget Strings */
|
||||
"noPostsAvailable" = "沒有可用的帖子";
|
||||
"tapToRefresh" = "點擊刷新";
|
||||
"loadingPost" = "載入帖子中...";
|
||||
|
||||
135
lib/hooks/use_room_file_picker.dart
Normal file
135
lib/hooks/use_room_file_picker.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
import 'dart:async';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/widgets/chat/chat_link_attachments.dart';
|
||||
|
||||
class RoomFilePicker {
|
||||
final List<UniversalFile> attachments;
|
||||
final void Function(List<UniversalFile>) updateAttachments;
|
||||
final Future<void> Function() pickPhotos;
|
||||
final Future<void> Function() pickVideos;
|
||||
final Future<void> Function() pickAudio;
|
||||
final Future<void> Function() pickFiles;
|
||||
final Future<void> Function() linkAttachment;
|
||||
|
||||
RoomFilePicker({
|
||||
required this.attachments,
|
||||
required this.updateAttachments,
|
||||
required this.pickPhotos,
|
||||
required this.pickVideos,
|
||||
required this.pickAudio,
|
||||
required this.pickFiles,
|
||||
required this.linkAttachment,
|
||||
});
|
||||
}
|
||||
|
||||
RoomFilePicker useRoomFilePicker(
|
||||
BuildContext context,
|
||||
List<UniversalFile> currentAttachments,
|
||||
Function(List<UniversalFile>) onAttachmentsChanged,
|
||||
) {
|
||||
final attachments = useState<List<UniversalFile>>(currentAttachments);
|
||||
|
||||
Future<void> pickPhotos() async {
|
||||
final picker = ImagePicker();
|
||||
final results = await picker.pickMultiImage();
|
||||
if (results.isEmpty) return;
|
||||
attachments.value = [
|
||||
...attachments.value,
|
||||
...results.map(
|
||||
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
|
||||
),
|
||||
];
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
Future<void> pickVideos() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.video,
|
||||
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.video),
|
||||
),
|
||||
];
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
Future<void> pickAudio() 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),
|
||||
),
|
||||
];
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
Future<void> pickFiles() 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),
|
||||
),
|
||||
];
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
Future<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,
|
||||
),
|
||||
];
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
void updateAttachments(List<UniversalFile> newAttachments) {
|
||||
attachments.value = newAttachments;
|
||||
onAttachmentsChanged(attachments.value);
|
||||
}
|
||||
|
||||
return RoomFilePicker(
|
||||
attachments: attachments.value,
|
||||
updateAttachments: updateAttachments,
|
||||
pickPhotos: pickPhotos,
|
||||
pickVideos: pickVideos,
|
||||
pickAudio: pickAudio,
|
||||
pickFiles: pickFiles,
|
||||
linkAttachment: linkAttachment,
|
||||
);
|
||||
}
|
||||
236
lib/hooks/use_room_input.dart
Normal file
236
lib/hooks/use_room_input.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/models/poll.dart';
|
||||
import 'package:island/models/wallet.dart';
|
||||
import 'package:island/pods/chat/chat_subscribe.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/pods/chat/messages_notifier.dart';
|
||||
import 'package:island/widgets/chat/message_item.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
|
||||
class RoomInputManager {
|
||||
final TextEditingController messageController;
|
||||
final List<UniversalFile> attachments;
|
||||
final Map<String, Map<int, double?>> attachmentProgress;
|
||||
final SnChatMessage? messageEditingTo;
|
||||
final SnChatMessage? messageReplyingTo;
|
||||
final SnChatMessage? messageForwardingTo;
|
||||
final SnPoll? selectedPoll;
|
||||
final SnWalletFund? selectedFund;
|
||||
final void Function(List<UniversalFile>) updateAttachments;
|
||||
final void Function(String, double?) updateAttachmentProgress;
|
||||
final void Function(SnChatMessage?) setEditingTo;
|
||||
final void Function(SnChatMessage?) setReplyingTo;
|
||||
final void Function(SnChatMessage?) setForwardingTo;
|
||||
final void Function(SnPoll?) setPoll;
|
||||
final void Function(SnWalletFund?) setFund;
|
||||
final void Function() clear;
|
||||
final void Function() clearAttachmentsOnly;
|
||||
final Future<void> Function() handlePaste;
|
||||
final void Function(WidgetRef ref) sendMessage;
|
||||
final void Function(String action, LocalChatMessage message) onMessageAction;
|
||||
|
||||
RoomInputManager({
|
||||
required this.messageController,
|
||||
required this.attachments,
|
||||
required this.attachmentProgress,
|
||||
this.messageEditingTo,
|
||||
this.messageReplyingTo,
|
||||
this.messageForwardingTo,
|
||||
this.selectedPoll,
|
||||
this.selectedFund,
|
||||
required this.updateAttachments,
|
||||
required this.updateAttachmentProgress,
|
||||
required this.setEditingTo,
|
||||
required this.setReplyingTo,
|
||||
required this.setForwardingTo,
|
||||
required this.setPoll,
|
||||
required this.setFund,
|
||||
required this.clear,
|
||||
required this.clearAttachmentsOnly,
|
||||
required this.handlePaste,
|
||||
required this.sendMessage,
|
||||
required this.onMessageAction,
|
||||
});
|
||||
}
|
||||
|
||||
RoomInputManager useRoomInputManager(WidgetRef ref, String roomId) {
|
||||
final messageController = useTextEditingController();
|
||||
final attachments = useState<List<UniversalFile>>([]);
|
||||
final attachmentProgress = useState<Map<String, Map<int, double?>>>({});
|
||||
final messageEditingTo = useState<SnChatMessage?>(null);
|
||||
final messageReplyingTo = useState<SnChatMessage?>(null);
|
||||
final messageForwardingTo = useState<SnChatMessage?>(null);
|
||||
final selectedPoll = useState<SnPoll?>(null);
|
||||
final selectedFund = useState<SnWalletFund?>(null);
|
||||
|
||||
final chatSubscribeNotifier = ref.read(
|
||||
chatSubscribeProvider(roomId).notifier,
|
||||
);
|
||||
final messagesNotifier = ref.read(messagesProvider(roomId).notifier);
|
||||
|
||||
void updateAttachments(List<UniversalFile> newAttachments) {
|
||||
attachments.value = newAttachments;
|
||||
}
|
||||
|
||||
void updateAttachmentProgress(String messageId, double? progress) {
|
||||
attachmentProgress.value = {
|
||||
...attachmentProgress.value,
|
||||
messageId: {0: progress},
|
||||
};
|
||||
}
|
||||
|
||||
void setEditingTo(SnChatMessage? message) {
|
||||
messageEditingTo.value = message;
|
||||
if (message != null) {
|
||||
messageController.text = message.content ?? '';
|
||||
attachments.value = message.attachments
|
||||
.map((e) => UniversalFile.fromAttachment(e))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
void setReplyingTo(SnChatMessage? message) {
|
||||
messageReplyingTo.value = message;
|
||||
}
|
||||
|
||||
void setForwardingTo(SnChatMessage? message) {
|
||||
messageForwardingTo.value = message;
|
||||
}
|
||||
|
||||
void setPoll(SnPoll? poll) {
|
||||
selectedPoll.value = poll;
|
||||
}
|
||||
|
||||
void setFund(SnWalletFund? fund) {
|
||||
selectedFund.value = fund;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
messageController.clear();
|
||||
messageEditingTo.value = null;
|
||||
messageReplyingTo.value = null;
|
||||
messageForwardingTo.value = null;
|
||||
selectedPoll.value = null;
|
||||
selectedFund.value = null;
|
||||
attachments.value = [];
|
||||
}
|
||||
|
||||
void clearAttachmentsOnly() {
|
||||
messageController.clear();
|
||||
attachments.value = [];
|
||||
}
|
||||
|
||||
void onTextChange() {
|
||||
if (messageController.text.isNotEmpty) {
|
||||
chatSubscribeNotifier.sendTypingStatus();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
messageController.addListener(onTextChange);
|
||||
return () => messageController.removeListener(onTextChange);
|
||||
}, [messageController]);
|
||||
|
||||
Future<void> handlePaste() async {
|
||||
final image = await Pasteboard.image;
|
||||
if (image != null) {
|
||||
final newAttachments = [
|
||||
...attachments.value,
|
||||
UniversalFile(
|
||||
displayName: 'image.jpeg',
|
||||
data: XFile.fromData(
|
||||
image,
|
||||
mimeType: "image/jpeg",
|
||||
name: 'image.jpeg',
|
||||
),
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
];
|
||||
attachments.value = newAttachments;
|
||||
}
|
||||
|
||||
final textData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (textData != null && textData.text != null) {
|
||||
final text = messageController.text;
|
||||
final selection = messageController.selection;
|
||||
final start = selection.start >= 0 ? selection.start : text.length;
|
||||
final end = selection.end >= 0 ? selection.end : text.length;
|
||||
final newText = text.replaceRange(start, end, textData.text!);
|
||||
messageController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: start + textData.text!.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onMessageAction(String action, LocalChatMessage message) {
|
||||
switch (action) {
|
||||
case MessageItemAction.delete:
|
||||
messagesNotifier.deleteMessage(message.id);
|
||||
case MessageItemAction.edit:
|
||||
setEditingTo(message.toRemoteMessage());
|
||||
case MessageItemAction.forward:
|
||||
setForwardingTo(message.toRemoteMessage());
|
||||
case MessageItemAction.reply:
|
||||
setReplyingTo(message.toRemoteMessage());
|
||||
case MessageItemAction.resend:
|
||||
messagesNotifier.retryMessage(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
void sendMessage(WidgetRef ref) {
|
||||
if (messageController.text.trim().isNotEmpty ||
|
||||
attachments.value.isNotEmpty ||
|
||||
selectedPoll.value != null ||
|
||||
selectedFund.value != null) {
|
||||
messagesNotifier.sendMessage(
|
||||
ref,
|
||||
messageController.text.trim(),
|
||||
attachments.value,
|
||||
poll: selectedPoll.value,
|
||||
fund: selectedFund.value,
|
||||
editingTo: messageEditingTo.value,
|
||||
forwardingTo: messageForwardingTo.value,
|
||||
replyingTo: messageReplyingTo.value,
|
||||
onProgress: (messageId, progress) {
|
||||
attachmentProgress.value = {
|
||||
...attachmentProgress.value,
|
||||
messageId: progress,
|
||||
};
|
||||
},
|
||||
);
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
return RoomInputManager(
|
||||
messageController: messageController,
|
||||
attachments: attachments.value,
|
||||
attachmentProgress: attachmentProgress.value,
|
||||
messageEditingTo: messageEditingTo.value,
|
||||
messageReplyingTo: messageReplyingTo.value,
|
||||
messageForwardingTo: messageForwardingTo.value,
|
||||
selectedPoll: selectedPoll.value,
|
||||
selectedFund: selectedFund.value,
|
||||
updateAttachments: updateAttachments,
|
||||
updateAttachmentProgress: updateAttachmentProgress,
|
||||
setEditingTo: setEditingTo,
|
||||
setReplyingTo: setReplyingTo,
|
||||
setForwardingTo: setForwardingTo,
|
||||
setPoll: setPoll,
|
||||
setFund: setFund,
|
||||
clear: clear,
|
||||
clearAttachmentsOnly: clearAttachmentsOnly,
|
||||
handlePaste: handlePaste,
|
||||
sendMessage: sendMessage,
|
||||
onMessageAction: onMessageAction,
|
||||
);
|
||||
}
|
||||
127
lib/hooks/use_room_scroll.dart
Normal file
127
lib/hooks/use_room_scroll.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/chat/chat_room.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/pods/chat/messages_notifier.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
class RoomScrollManager {
|
||||
final ScrollController scrollController;
|
||||
final ListController listController;
|
||||
final ValueNotifier<double> bottomGradientOpacity;
|
||||
bool isScrollingToMessage;
|
||||
final void Function({
|
||||
required String messageId,
|
||||
required List<LocalChatMessage> messageList,
|
||||
})
|
||||
scrollToMessage;
|
||||
|
||||
RoomScrollManager({
|
||||
required this.scrollController,
|
||||
required this.listController,
|
||||
required this.bottomGradientOpacity,
|
||||
required this.scrollToMessage,
|
||||
this.isScrollingToMessage = false,
|
||||
});
|
||||
}
|
||||
|
||||
RoomScrollManager useRoomScrollManager(
|
||||
WidgetRef ref,
|
||||
String roomId,
|
||||
Future<int> Function(String) jumpToMessage,
|
||||
AsyncValue<List<LocalChatMessage>> messagesAsync,
|
||||
) {
|
||||
final scrollController = useScrollController();
|
||||
final listController = useMemoized(() => ListController(), []);
|
||||
final bottomGradientOpacity = useState(ValueNotifier<double>(0.0));
|
||||
|
||||
var isLoading = false;
|
||||
var isScrollingToMessage = false;
|
||||
final messagesNotifier = ref.read(messagesProvider(roomId).notifier);
|
||||
final flashingMessagesNotifier = ref.read(flashingMessagesProvider.notifier);
|
||||
|
||||
void performScrollAnimation({required int index, required String messageId}) {
|
||||
flashingMessagesNotifier.update((set) => set.union({messageId}));
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
isScrollingToMessage = false;
|
||||
});
|
||||
} catch (e) {
|
||||
isScrollingToMessage = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void scrollToMessageWrapper({
|
||||
required String messageId,
|
||||
required List<LocalChatMessage> messageList,
|
||||
}) {
|
||||
if (isScrollingToMessage) return;
|
||||
isScrollingToMessage = true;
|
||||
|
||||
final messageIndex = messageList.indexWhere((m) => m.id == messageId);
|
||||
|
||||
if (messageIndex == -1) {
|
||||
jumpToMessage(messageId).then((index) {
|
||||
if (index != -1) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
performScrollAnimation(index: index, messageId: messageId);
|
||||
});
|
||||
} else {
|
||||
isScrollingToMessage = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
performScrollAnimation(index: messageIndex, messageId: messageId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
void onScroll() {
|
||||
messagesAsync.when(
|
||||
data: (messageList) {
|
||||
if (scrollController.position.pixels >=
|
||||
scrollController.position.maxScrollExtent - 200) {
|
||||
if (!isLoading) {
|
||||
isLoading = true;
|
||||
messagesNotifier.loadMore().then((_) => isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
final pixels = scrollController.position.pixels;
|
||||
bottomGradientOpacity.value.value = (pixels / 500.0).clamp(0.0, 1.0);
|
||||
},
|
||||
loading: () {},
|
||||
error: (_, _) {},
|
||||
);
|
||||
}
|
||||
|
||||
scrollController.addListener(onScroll);
|
||||
return () => scrollController.removeListener(onScroll);
|
||||
}, [scrollController, messagesAsync]);
|
||||
|
||||
return RoomScrollManager(
|
||||
scrollController: scrollController,
|
||||
listController: listController,
|
||||
bottomGradientOpacity: bottomGradientOpacity.value,
|
||||
scrollToMessage: scrollToMessageWrapper,
|
||||
isScrollingToMessage: isScrollingToMessage,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:croppy/croppy.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
@@ -11,9 +12,11 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
import 'package:image_picker_android/image_picker_android.dart';
|
||||
import 'package:island/services/analytics_service.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/firebase_options.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/audio.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/theme.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
@@ -22,6 +25,8 @@ import 'package:island/route.dart';
|
||||
import 'package:island/services/notify.dart';
|
||||
import 'package:island/services/widget_sync_service.dart';
|
||||
import 'package:island/services/timezone.dart';
|
||||
import 'package:island/services/app_intents.dart';
|
||||
import 'package:island/services/quick_actions.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
@@ -98,6 +103,36 @@ void main() async {
|
||||
talker.error("[SplashScreen] Failed to load timezone database... $err");
|
||||
}
|
||||
|
||||
try {
|
||||
talker.info("[AppIntents] Initializing App Intents service...");
|
||||
final appIntentsService = AppIntentsService();
|
||||
await appIntentsService.initialize();
|
||||
talker.info("[AppIntents] App Intents service is ready!");
|
||||
} catch (err) {
|
||||
talker.error(
|
||||
"[AppIntents] Failed to initialize App Intents service... $err",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
talker.info("[QuickActions] Initializing Quick Actions service...");
|
||||
final quickActionsService = QuickActionsService();
|
||||
await quickActionsService.initialize();
|
||||
talker.info("[QuickActions] Quick Actions service is ready!");
|
||||
} catch (err) {
|
||||
talker.error(
|
||||
"[QuickActions] Failed to initialize Quick Actions service... $err",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
talker.info("[Analytics] Initializing Analytics service...");
|
||||
final analyticsService = AnalyticsService();
|
||||
analyticsService.initialize();
|
||||
} catch (err) {
|
||||
talker.error("[Analytics] Failed to initialize Analytics service... $err");
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) {
|
||||
@@ -170,6 +205,16 @@ void main() async {
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
retry: (retryCount, error) {
|
||||
if (retryCount > 3) return null;
|
||||
if (error is DioException) {
|
||||
if (error.response?.statusCode == 401) return null;
|
||||
if (error.response?.statusCode == 403) return null;
|
||||
if (error.response?.statusCode == 404) return null;
|
||||
if (error.response?.statusCode == 500) return null;
|
||||
}
|
||||
return const Duration(milliseconds: 300);
|
||||
},
|
||||
observers: [
|
||||
TalkerRiverpodObserver(
|
||||
talker: talker,
|
||||
@@ -295,6 +340,9 @@ class IslandApp extends HookConsumerWidget {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
subscribePushNotification(apiClient);
|
||||
initializeLocalNotifications();
|
||||
ref.read(audioSessionProvider);
|
||||
ref.read(notificationSfxProvider);
|
||||
ref.read(messageSfxProvider);
|
||||
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
||||
wsNotifier.connect();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ sealed class SnScrappedLink with _$SnScrappedLink {
|
||||
const factory SnScrappedLink({
|
||||
required String type,
|
||||
required String url,
|
||||
required String title,
|
||||
required String? title,
|
||||
required String? description,
|
||||
required String? imageUrl,
|
||||
required String? faviconUrl,
|
||||
|
||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$SnScrappedLink {
|
||||
|
||||
String get type; String get url; String get title; String? get description; String? get imageUrl; String? get faviconUrl; String? get siteName; String? get contentType; String? get author; DateTime? get publishedDate;
|
||||
String get type; String get url; String? get title; String? get description; String? get imageUrl; String? get faviconUrl; String? get siteName; String? get contentType; String? get author; DateTime? get publishedDate;
|
||||
/// Create a copy of SnScrappedLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -48,7 +48,7 @@ abstract mixin class $SnScrappedLinkCopyWith<$Res> {
|
||||
factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate
|
||||
String type, String url, String? title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate
|
||||
});
|
||||
|
||||
|
||||
@@ -65,12 +65,12 @@ class _$SnScrappedLinkCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnScrappedLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = freezed,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
|
||||
as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,faviconUrl: freezed == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,siteName: freezed == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable
|
||||
@@ -159,7 +159,7 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type, String url, String? title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnScrappedLink() when $default != null:
|
||||
return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _:
|
||||
@@ -180,7 +180,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type, String url, String? title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnScrappedLink():
|
||||
return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);}
|
||||
@@ -197,7 +197,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type, String url, String? title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnScrappedLink() when $default != null:
|
||||
return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _:
|
||||
@@ -217,7 +217,7 @@ class _SnScrappedLink implements SnScrappedLink {
|
||||
|
||||
@override final String type;
|
||||
@override final String url;
|
||||
@override final String title;
|
||||
@override final String? title;
|
||||
@override final String? description;
|
||||
@override final String? imageUrl;
|
||||
@override final String? faviconUrl;
|
||||
@@ -259,7 +259,7 @@ abstract mixin class _$SnScrappedLinkCopyWith<$Res> implements $SnScrappedLinkCo
|
||||
factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate
|
||||
String type, String url, String? title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate
|
||||
});
|
||||
|
||||
|
||||
@@ -276,12 +276,12 @@ class __$SnScrappedLinkCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnScrappedLink
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = freezed,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
|
||||
return _then(_SnScrappedLink(
|
||||
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
|
||||
as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,faviconUrl: freezed == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String?,siteName: freezed == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable
|
||||
|
||||
@@ -10,7 +10,7 @@ _SnScrappedLink _$SnScrappedLinkFromJson(Map<String, dynamic> json) =>
|
||||
_SnScrappedLink(
|
||||
type: json['type'] as String,
|
||||
url: json['url'] as String,
|
||||
title: json['title'] as String,
|
||||
title: json['title'] as String?,
|
||||
description: json['description'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
faviconUrl: json['favicon_url'] as String?,
|
||||
|
||||
@@ -37,6 +37,45 @@ sealed class UniversalFile with _$UniversalFile {
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnFileReplica with _$SnFileReplica {
|
||||
const factory SnFileReplica({
|
||||
required String id,
|
||||
required String objectId,
|
||||
required String poolId,
|
||||
required SnFilePool? pool,
|
||||
required String storageId,
|
||||
required int status,
|
||||
required bool isPrimary,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
}) = _SnFileReplica;
|
||||
|
||||
factory SnFileReplica.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnFileReplicaFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnCloudFileObject with _$SnCloudFileObject {
|
||||
const factory SnCloudFileObject({
|
||||
required String id,
|
||||
required int size,
|
||||
required Map<String, dynamic>? meta,
|
||||
required String? mimeType,
|
||||
required String? hash,
|
||||
required bool hasCompression,
|
||||
required bool hasThumbnail,
|
||||
required List<SnFileReplica> fileReplicas,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
}) = _SnCloudFileObject;
|
||||
|
||||
factory SnCloudFileObject.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnCloudFileObjectFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnCloudFile with _$SnCloudFile {
|
||||
const factory SnCloudFile({
|
||||
@@ -45,13 +84,11 @@ sealed class SnCloudFile with _$SnCloudFile {
|
||||
required String? description,
|
||||
required Map<String, dynamic>? fileMeta,
|
||||
required Map<String, dynamic>? userMeta,
|
||||
required SnFilePool? pool,
|
||||
@Default([]) List<int> sensitiveMarks,
|
||||
required String? mimeType,
|
||||
required String? hash,
|
||||
required int size,
|
||||
required DateTime? uploadedAt,
|
||||
required String? uploadedTo,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
|
||||
@@ -279,42 +279,42 @@ as String?,
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnCloudFile {
|
||||
mixin _$SnFileReplica {
|
||||
|
||||
String get id; String get name; String? get description; Map<String, dynamic>? get fileMeta; Map<String, dynamic>? get userMeta; SnFilePool? get pool; List<int> get sensitiveMarks; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; String? get uploadedTo; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String? get url;
|
||||
/// Create a copy of SnCloudFile
|
||||
String get id; String get objectId; String get poolId; SnFilePool? get pool; String get storageId; int get status; bool get isPrimary; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
|
||||
/// Create a copy of SnFileReplica
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnCloudFileCopyWith<SnCloudFile> get copyWith => _$SnCloudFileCopyWithImpl<SnCloudFile>(this as SnCloudFile, _$identity);
|
||||
$SnFileReplicaCopyWith<SnFileReplica> get copyWith => _$SnFileReplicaCopyWithImpl<SnFileReplica>(this as SnFileReplica, _$identity);
|
||||
|
||||
/// Serializes this SnCloudFile to a JSON map.
|
||||
/// Serializes this SnFileReplica to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.fileMeta, fileMeta)&&const DeepCollectionEquality().equals(other.userMeta, userMeta)&&(identical(other.pool, pool) || other.pool == pool)&&const DeepCollectionEquality().equals(other.sensitiveMarks, sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.url, url) || other.url == url));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnFileReplica&&(identical(other.id, id) || other.id == id)&&(identical(other.objectId, objectId) || other.objectId == objectId)&&(identical(other.poolId, poolId) || other.poolId == poolId)&&(identical(other.pool, pool) || other.pool == pool)&&(identical(other.storageId, storageId) || other.storageId == storageId)&&(identical(other.status, status) || other.status == status)&&(identical(other.isPrimary, isPrimary) || other.isPrimary == isPrimary)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),pool,const DeepCollectionEquality().hash(sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt,url);
|
||||
int get hashCode => Object.hash(runtimeType,id,objectId,poolId,pool,storageId,status,isPrimary,createdAt,updatedAt,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, pool: $pool, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, url: $url)';
|
||||
return 'SnFileReplica(id: $id, objectId: $objectId, poolId: $poolId, pool: $pool, storageId: $storageId, status: $status, isPrimary: $isPrimary, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $SnCloudFileCopyWith<$Res> {
|
||||
factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl;
|
||||
abstract mixin class $SnFileReplicaCopyWith<$Res> {
|
||||
factory $SnFileReplicaCopyWith(SnFileReplica value, $Res Function(SnFileReplica) _then) = _$SnFileReplicaCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? url
|
||||
String id, String objectId, String poolId, SnFilePool? pool, String storageId, int status, bool isPrimary, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
@@ -322,37 +322,31 @@ $SnFilePoolCopyWith<$Res>? get pool;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$SnCloudFileCopyWithImpl<$Res>
|
||||
implements $SnCloudFileCopyWith<$Res> {
|
||||
_$SnCloudFileCopyWithImpl(this._self, this._then);
|
||||
class _$SnFileReplicaCopyWithImpl<$Res>
|
||||
implements $SnFileReplicaCopyWith<$Res> {
|
||||
_$SnFileReplicaCopyWithImpl(this._self, this._then);
|
||||
|
||||
final SnCloudFile _self;
|
||||
final $Res Function(SnCloudFile) _then;
|
||||
final SnFileReplica _self;
|
||||
final $Res Function(SnFileReplica) _then;
|
||||
|
||||
/// Create a copy of SnCloudFile
|
||||
/// Create a copy of SnFileReplica
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? pool = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? url = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? objectId = null,Object? poolId = null,Object? pool = freezed,Object? storageId = null,Object? status = null,Object? isPrimary = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,fileMeta: freezed == fileMeta ? _self.fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self.userMeta : userMeta // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,pool: freezed == pool ? _self.pool : pool // ignore: cast_nullable_to_non_nullable
|
||||
as SnFilePool?,sensitiveMarks: null == sensitiveMarks ? _self.sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable
|
||||
as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable
|
||||
as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,uploadedTo: freezed == uploadedTo ? _self.uploadedTo : uploadedTo // ignore: cast_nullable_to_non_nullable
|
||||
as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as String,objectId: null == objectId ? _self.objectId : objectId // ignore: cast_nullable_to_non_nullable
|
||||
as String,poolId: null == poolId ? _self.poolId : poolId // ignore: cast_nullable_to_non_nullable
|
||||
as String,pool: freezed == pool ? _self.pool : pool // ignore: cast_nullable_to_non_nullable
|
||||
as SnFilePool?,storageId: null == storageId ? _self.storageId : storageId // ignore: cast_nullable_to_non_nullable
|
||||
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||
as int,isPrimary: null == isPrimary ? _self.isPrimary : isPrimary // ignore: cast_nullable_to_non_nullable
|
||||
as bool,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,url: freezed == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
as DateTime?,
|
||||
));
|
||||
}
|
||||
/// Create a copy of SnCloudFile
|
||||
/// Create a copy of SnFileReplica
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
@@ -368,6 +362,607 @@ $SnFilePoolCopyWith<$Res>? get pool {
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [SnFileReplica].
|
||||
extension SnFileReplicaPatterns on SnFileReplica {
|
||||
/// 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( _SnFileReplica value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnFileReplica() 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( _SnFileReplica value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnFileReplica():
|
||||
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( _SnFileReplica value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnFileReplica() 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 objectId, String poolId, SnFilePool? pool, String storageId, int status, bool isPrimary, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnFileReplica() when $default != null:
|
||||
return $default(_that.id,_that.objectId,_that.poolId,_that.pool,_that.storageId,_that.status,_that.isPrimary,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String objectId, String poolId, SnFilePool? pool, String storageId, int status, bool isPrimary, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnFileReplica():
|
||||
return $default(_that.id,_that.objectId,_that.poolId,_that.pool,_that.storageId,_that.status,_that.isPrimary,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String objectId, String poolId, SnFilePool? pool, String storageId, int status, bool isPrimary, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnFileReplica() when $default != null:
|
||||
return $default(_that.id,_that.objectId,_that.poolId,_that.pool,_that.storageId,_that.status,_that.isPrimary,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _SnFileReplica implements SnFileReplica {
|
||||
const _SnFileReplica({required this.id, required this.objectId, required this.poolId, required this.pool, required this.storageId, required this.status, required this.isPrimary, required this.createdAt, required this.updatedAt, required this.deletedAt});
|
||||
factory _SnFileReplica.fromJson(Map<String, dynamic> json) => _$SnFileReplicaFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override final String objectId;
|
||||
@override final String poolId;
|
||||
@override final SnFilePool? pool;
|
||||
@override final String storageId;
|
||||
@override final int status;
|
||||
@override final bool isPrimary;
|
||||
@override final DateTime createdAt;
|
||||
@override final DateTime updatedAt;
|
||||
@override final DateTime? deletedAt;
|
||||
|
||||
/// Create a copy of SnFileReplica
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$SnFileReplicaCopyWith<_SnFileReplica> get copyWith => __$SnFileReplicaCopyWithImpl<_SnFileReplica>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$SnFileReplicaToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnFileReplica&&(identical(other.id, id) || other.id == id)&&(identical(other.objectId, objectId) || other.objectId == objectId)&&(identical(other.poolId, poolId) || other.poolId == poolId)&&(identical(other.pool, pool) || other.pool == pool)&&(identical(other.storageId, storageId) || other.storageId == storageId)&&(identical(other.status, status) || other.status == status)&&(identical(other.isPrimary, isPrimary) || other.isPrimary == isPrimary)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,objectId,poolId,pool,storageId,status,isPrimary,createdAt,updatedAt,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnFileReplica(id: $id, objectId: $objectId, poolId: $poolId, pool: $pool, storageId: $storageId, status: $status, isPrimary: $isPrimary, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$SnFileReplicaCopyWith<$Res> implements $SnFileReplicaCopyWith<$Res> {
|
||||
factory _$SnFileReplicaCopyWith(_SnFileReplica value, $Res Function(_SnFileReplica) _then) = __$SnFileReplicaCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String objectId, String poolId, SnFilePool? pool, String storageId, int status, bool isPrimary, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
@override $SnFilePoolCopyWith<$Res>? get pool;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$SnFileReplicaCopyWithImpl<$Res>
|
||||
implements _$SnFileReplicaCopyWith<$Res> {
|
||||
__$SnFileReplicaCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _SnFileReplica _self;
|
||||
final $Res Function(_SnFileReplica) _then;
|
||||
|
||||
/// Create a copy of SnFileReplica
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? objectId = null,Object? poolId = null,Object? pool = freezed,Object? storageId = null,Object? status = null,Object? isPrimary = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_SnFileReplica(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,objectId: null == objectId ? _self.objectId : objectId // ignore: cast_nullable_to_non_nullable
|
||||
as String,poolId: null == poolId ? _self.poolId : poolId // ignore: cast_nullable_to_non_nullable
|
||||
as String,pool: freezed == pool ? _self.pool : pool // ignore: cast_nullable_to_non_nullable
|
||||
as SnFilePool?,storageId: null == storageId ? _self.storageId : storageId // ignore: cast_nullable_to_non_nullable
|
||||
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||
as int,isPrimary: null == isPrimary ? _self.isPrimary : isPrimary // ignore: cast_nullable_to_non_nullable
|
||||
as bool,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of SnFileReplica
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnFilePoolCopyWith<$Res>? get pool {
|
||||
if (_self.pool == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnFilePoolCopyWith<$Res>(_self.pool!, (value) {
|
||||
return _then(_self.copyWith(pool: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnCloudFileObject {
|
||||
|
||||
String get id; int get size; Map<String, dynamic>? get meta; String? get mimeType; String? get hash; bool get hasCompression; bool get hasThumbnail; List<SnFileReplica> get fileReplicas; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
|
||||
/// Create a copy of SnCloudFileObject
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnCloudFileObjectCopyWith<SnCloudFileObject> get copyWith => _$SnCloudFileObjectCopyWithImpl<SnCloudFileObject>(this as SnCloudFileObject, _$identity);
|
||||
|
||||
/// Serializes this SnCloudFileObject to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFileObject&&(identical(other.id, id) || other.id == id)&&(identical(other.size, size) || other.size == size)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.hasCompression, hasCompression) || other.hasCompression == hasCompression)&&(identical(other.hasThumbnail, hasThumbnail) || other.hasThumbnail == hasThumbnail)&&const DeepCollectionEquality().equals(other.fileReplicas, fileReplicas)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,size,const DeepCollectionEquality().hash(meta),mimeType,hash,hasCompression,hasThumbnail,const DeepCollectionEquality().hash(fileReplicas),createdAt,updatedAt,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnCloudFileObject(id: $id, size: $size, meta: $meta, mimeType: $mimeType, hash: $hash, hasCompression: $hasCompression, hasThumbnail: $hasThumbnail, fileReplicas: $fileReplicas, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $SnCloudFileObjectCopyWith<$Res> {
|
||||
factory $SnCloudFileObjectCopyWith(SnCloudFileObject value, $Res Function(SnCloudFileObject) _then) = _$SnCloudFileObjectCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, int size, Map<String, dynamic>? meta, String? mimeType, String? hash, bool hasCompression, bool hasThumbnail, List<SnFileReplica> fileReplicas, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$SnCloudFileObjectCopyWithImpl<$Res>
|
||||
implements $SnCloudFileObjectCopyWith<$Res> {
|
||||
_$SnCloudFileObjectCopyWithImpl(this._self, this._then);
|
||||
|
||||
final SnCloudFileObject _self;
|
||||
final $Res Function(SnCloudFileObject) _then;
|
||||
|
||||
/// Create a copy of SnCloudFileObject
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? size = null,Object? meta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? hasCompression = null,Object? hasThumbnail = null,Object? fileReplicas = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable
|
||||
as int,meta: freezed == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable
|
||||
as String?,hasCompression: null == hasCompression ? _self.hasCompression : hasCompression // ignore: cast_nullable_to_non_nullable
|
||||
as bool,hasThumbnail: null == hasThumbnail ? _self.hasThumbnail : hasThumbnail // ignore: cast_nullable_to_non_nullable
|
||||
as bool,fileReplicas: null == fileReplicas ? _self.fileReplicas : fileReplicas // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnFileReplica>,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?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [SnCloudFileObject].
|
||||
extension SnCloudFileObjectPatterns on SnCloudFileObject {
|
||||
/// 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( _SnCloudFileObject value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnCloudFileObject() 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( _SnCloudFileObject value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnCloudFileObject():
|
||||
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( _SnCloudFileObject value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _SnCloudFileObject() 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, int size, Map<String, dynamic>? meta, String? mimeType, String? hash, bool hasCompression, bool hasThumbnail, List<SnFileReplica> fileReplicas, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnCloudFileObject() when $default != null:
|
||||
return $default(_that.id,_that.size,_that.meta,_that.mimeType,_that.hash,_that.hasCompression,_that.hasThumbnail,_that.fileReplicas,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, int size, Map<String, dynamic>? meta, String? mimeType, String? hash, bool hasCompression, bool hasThumbnail, List<SnFileReplica> fileReplicas, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnCloudFileObject():
|
||||
return $default(_that.id,_that.size,_that.meta,_that.mimeType,_that.hash,_that.hasCompression,_that.hasThumbnail,_that.fileReplicas,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, int size, Map<String, dynamic>? meta, String? mimeType, String? hash, bool hasCompression, bool hasThumbnail, List<SnFileReplica> fileReplicas, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnCloudFileObject() when $default != null:
|
||||
return $default(_that.id,_that.size,_that.meta,_that.mimeType,_that.hash,_that.hasCompression,_that.hasThumbnail,_that.fileReplicas,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _SnCloudFileObject implements SnCloudFileObject {
|
||||
const _SnCloudFileObject({required this.id, required this.size, required final Map<String, dynamic>? meta, required this.mimeType, required this.hash, required this.hasCompression, required this.hasThumbnail, required final List<SnFileReplica> fileReplicas, required this.createdAt, required this.updatedAt, required this.deletedAt}): _meta = meta,_fileReplicas = fileReplicas;
|
||||
factory _SnCloudFileObject.fromJson(Map<String, dynamic> json) => _$SnCloudFileObjectFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override final int size;
|
||||
final Map<String, dynamic>? _meta;
|
||||
@override Map<String, dynamic>? get meta {
|
||||
final value = _meta;
|
||||
if (value == null) return null;
|
||||
if (_meta is EqualUnmodifiableMapView) return _meta;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(value);
|
||||
}
|
||||
|
||||
@override final String? mimeType;
|
||||
@override final String? hash;
|
||||
@override final bool hasCompression;
|
||||
@override final bool hasThumbnail;
|
||||
final List<SnFileReplica> _fileReplicas;
|
||||
@override List<SnFileReplica> get fileReplicas {
|
||||
if (_fileReplicas is EqualUnmodifiableListView) return _fileReplicas;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_fileReplicas);
|
||||
}
|
||||
|
||||
@override final DateTime createdAt;
|
||||
@override final DateTime updatedAt;
|
||||
@override final DateTime? deletedAt;
|
||||
|
||||
/// Create a copy of SnCloudFileObject
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$SnCloudFileObjectCopyWith<_SnCloudFileObject> get copyWith => __$SnCloudFileObjectCopyWithImpl<_SnCloudFileObject>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$SnCloudFileObjectToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFileObject&&(identical(other.id, id) || other.id == id)&&(identical(other.size, size) || other.size == size)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.hasCompression, hasCompression) || other.hasCompression == hasCompression)&&(identical(other.hasThumbnail, hasThumbnail) || other.hasThumbnail == hasThumbnail)&&const DeepCollectionEquality().equals(other._fileReplicas, _fileReplicas)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,size,const DeepCollectionEquality().hash(_meta),mimeType,hash,hasCompression,hasThumbnail,const DeepCollectionEquality().hash(_fileReplicas),createdAt,updatedAt,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnCloudFileObject(id: $id, size: $size, meta: $meta, mimeType: $mimeType, hash: $hash, hasCompression: $hasCompression, hasThumbnail: $hasThumbnail, fileReplicas: $fileReplicas, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$SnCloudFileObjectCopyWith<$Res> implements $SnCloudFileObjectCopyWith<$Res> {
|
||||
factory _$SnCloudFileObjectCopyWith(_SnCloudFileObject value, $Res Function(_SnCloudFileObject) _then) = __$SnCloudFileObjectCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, int size, Map<String, dynamic>? meta, String? mimeType, String? hash, bool hasCompression, bool hasThumbnail, List<SnFileReplica> fileReplicas, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$SnCloudFileObjectCopyWithImpl<$Res>
|
||||
implements _$SnCloudFileObjectCopyWith<$Res> {
|
||||
__$SnCloudFileObjectCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _SnCloudFileObject _self;
|
||||
final $Res Function(_SnCloudFileObject) _then;
|
||||
|
||||
/// Create a copy of SnCloudFileObject
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? size = null,Object? meta = freezed,Object? mimeType = freezed,Object? hash = freezed,Object? hasCompression = null,Object? hasThumbnail = null,Object? fileReplicas = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_SnCloudFileObject(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable
|
||||
as int,meta: freezed == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable
|
||||
as String?,hasCompression: null == hasCompression ? _self.hasCompression : hasCompression // ignore: cast_nullable_to_non_nullable
|
||||
as bool,hasThumbnail: null == hasThumbnail ? _self.hasThumbnail : hasThumbnail // ignore: cast_nullable_to_non_nullable
|
||||
as bool,fileReplicas: null == fileReplicas ? _self._fileReplicas : fileReplicas // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnFileReplica>,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?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnCloudFile {
|
||||
|
||||
String get id; String get name; String? get description; Map<String, dynamic>? get fileMeta; Map<String, dynamic>? get userMeta; List<int> get sensitiveMarks; String? get mimeType; String? get hash; int get size; DateTime? get uploadedAt; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String? get url;
|
||||
/// Create a copy of SnCloudFile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnCloudFileCopyWith<SnCloudFile> get copyWith => _$SnCloudFileCopyWithImpl<SnCloudFile>(this as SnCloudFile, _$identity);
|
||||
|
||||
/// Serializes this SnCloudFile to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other.fileMeta, fileMeta)&&const DeepCollectionEquality().equals(other.userMeta, userMeta)&&const DeepCollectionEquality().equals(other.sensitiveMarks, sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.url, url) || other.url == url));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(fileMeta),const DeepCollectionEquality().hash(userMeta),const DeepCollectionEquality().hash(sensitiveMarks),mimeType,hash,size,uploadedAt,createdAt,updatedAt,deletedAt,url);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, url: $url)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $SnCloudFileCopyWith<$Res> {
|
||||
factory $SnCloudFileCopyWith(SnCloudFile value, $Res Function(SnCloudFile) _then) = _$SnCloudFileCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? url
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$SnCloudFileCopyWithImpl<$Res>
|
||||
implements $SnCloudFileCopyWith<$Res> {
|
||||
_$SnCloudFileCopyWithImpl(this._self, this._then);
|
||||
|
||||
final SnCloudFile _self;
|
||||
final $Res Function(SnCloudFile) _then;
|
||||
|
||||
/// Create a copy of SnCloudFile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? url = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,fileMeta: freezed == fileMeta ? _self.fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self.userMeta : userMeta // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,sensitiveMarks: null == sensitiveMarks ? _self.sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable
|
||||
as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable
|
||||
as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,url: freezed == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [SnCloudFile].
|
||||
extension SnCloudFilePatterns on SnCloudFile {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
@@ -443,10 +1038,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? url)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? url)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnCloudFile() when $default != null:
|
||||
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.pool,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.url);case _:
|
||||
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.url);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -464,10 +1059,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? url) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? url) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnCloudFile():
|
||||
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.pool,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.url);}
|
||||
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.url);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
@@ -481,10 +1076,10 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? url)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? url)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnCloudFile() when $default != null:
|
||||
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.pool,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.uploadedTo,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.url);case _:
|
||||
return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userMeta,_that.sensitiveMarks,_that.mimeType,_that.hash,_that.size,_that.uploadedAt,_that.createdAt,_that.updatedAt,_that.deletedAt,_that.url);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -496,7 +1091,7 @@ return $default(_that.id,_that.name,_that.description,_that.fileMeta,_that.userM
|
||||
@JsonSerializable()
|
||||
|
||||
class _SnCloudFile implements SnCloudFile {
|
||||
const _SnCloudFile({required this.id, required this.name, required this.description, required final Map<String, dynamic>? fileMeta, required final Map<String, dynamic>? userMeta, required this.pool, final List<int> sensitiveMarks = const [], required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.uploadedTo, required this.createdAt, required this.updatedAt, required this.deletedAt, this.url}): _fileMeta = fileMeta,_userMeta = userMeta,_sensitiveMarks = sensitiveMarks;
|
||||
const _SnCloudFile({required this.id, required this.name, required this.description, required final Map<String, dynamic>? fileMeta, required final Map<String, dynamic>? userMeta, final List<int> sensitiveMarks = const [], required this.mimeType, required this.hash, required this.size, required this.uploadedAt, required this.createdAt, required this.updatedAt, required this.deletedAt, this.url}): _fileMeta = fileMeta,_userMeta = userMeta,_sensitiveMarks = sensitiveMarks;
|
||||
factory _SnCloudFile.fromJson(Map<String, dynamic> json) => _$SnCloudFileFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@@ -520,7 +1115,6 @@ class _SnCloudFile implements SnCloudFile {
|
||||
return EqualUnmodifiableMapView(value);
|
||||
}
|
||||
|
||||
@override final SnFilePool? pool;
|
||||
final List<int> _sensitiveMarks;
|
||||
@override@JsonKey() List<int> get sensitiveMarks {
|
||||
if (_sensitiveMarks is EqualUnmodifiableListView) return _sensitiveMarks;
|
||||
@@ -532,7 +1126,6 @@ class _SnCloudFile implements SnCloudFile {
|
||||
@override final String? hash;
|
||||
@override final int size;
|
||||
@override final DateTime? uploadedAt;
|
||||
@override final String? uploadedTo;
|
||||
@override final DateTime createdAt;
|
||||
@override final DateTime updatedAt;
|
||||
@override final DateTime? deletedAt;
|
||||
@@ -551,16 +1144,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&(identical(other.pool, pool) || other.pool == pool)&&const DeepCollectionEquality().equals(other._sensitiveMarks, _sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.uploadedTo, uploadedTo) || other.uploadedTo == uploadedTo)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.url, url) || other.url == url));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFile&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&const DeepCollectionEquality().equals(other._fileMeta, _fileMeta)&&const DeepCollectionEquality().equals(other._userMeta, _userMeta)&&const DeepCollectionEquality().equals(other._sensitiveMarks, _sensitiveMarks)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)&&(identical(other.hash, hash) || other.hash == hash)&&(identical(other.size, size) || other.size == size)&&(identical(other.uploadedAt, uploadedAt) || other.uploadedAt == uploadedAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.url, url) || other.url == url));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),pool,const DeepCollectionEquality().hash(_sensitiveMarks),mimeType,hash,size,uploadedAt,uploadedTo,createdAt,updatedAt,deletedAt,url);
|
||||
int get hashCode => Object.hash(runtimeType,id,name,description,const DeepCollectionEquality().hash(_fileMeta),const DeepCollectionEquality().hash(_userMeta),const DeepCollectionEquality().hash(_sensitiveMarks),mimeType,hash,size,uploadedAt,createdAt,updatedAt,deletedAt,url);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, pool: $pool, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, uploadedTo: $uploadedTo, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, url: $url)';
|
||||
return 'SnCloudFile(id: $id, name: $name, description: $description, fileMeta: $fileMeta, userMeta: $userMeta, sensitiveMarks: $sensitiveMarks, mimeType: $mimeType, hash: $hash, size: $size, uploadedAt: $uploadedAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, url: $url)';
|
||||
}
|
||||
|
||||
|
||||
@@ -571,11 +1164,11 @@ abstract mixin class _$SnCloudFileCopyWith<$Res> implements $SnCloudFileCopyWith
|
||||
factory _$SnCloudFileCopyWith(_SnCloudFile value, $Res Function(_SnCloudFile) _then) = __$SnCloudFileCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, SnFilePool? pool, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, String? uploadedTo, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? url
|
||||
String id, String name, String? description, Map<String, dynamic>? fileMeta, Map<String, dynamic>? userMeta, List<int> sensitiveMarks, String? mimeType, String? hash, int size, DateTime? uploadedAt, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? url
|
||||
});
|
||||
|
||||
|
||||
@override $SnFilePoolCopyWith<$Res>? get pool;
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
@@ -588,21 +1181,19 @@ class __$SnCloudFileCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnCloudFile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? pool = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? uploadedTo = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? url = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = freezed,Object? fileMeta = freezed,Object? userMeta = freezed,Object? sensitiveMarks = null,Object? mimeType = freezed,Object? hash = freezed,Object? size = null,Object? uploadedAt = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? url = freezed,}) {
|
||||
return _then(_SnCloudFile(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,fileMeta: freezed == fileMeta ? _self._fileMeta : fileMeta // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,userMeta: freezed == userMeta ? _self._userMeta : userMeta // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,pool: freezed == pool ? _self.pool : pool // ignore: cast_nullable_to_non_nullable
|
||||
as SnFilePool?,sensitiveMarks: null == sensitiveMarks ? _self._sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,sensitiveMarks: null == sensitiveMarks ? _self._sensitiveMarks : sensitiveMarks // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,hash: freezed == hash ? _self.hash : hash // ignore: cast_nullable_to_non_nullable
|
||||
as String?,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable
|
||||
as int,uploadedAt: freezed == uploadedAt ? _self.uploadedAt : uploadedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,uploadedTo: freezed == uploadedTo ? _self.uploadedTo : uploadedTo // ignore: cast_nullable_to_non_nullable
|
||||
as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,url: freezed == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
|
||||
@@ -610,19 +1201,7 @@ as String?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of SnCloudFile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnFilePoolCopyWith<$Res>? get pool {
|
||||
if (_self.pool == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnFilePoolCopyWith<$Res>(_self.pool!, (value) {
|
||||
return _then(_self.copyWith(pool: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -29,15 +29,78 @@ const _$UniversalFileTypeEnumMap = {
|
||||
UniversalFileType.file: 'file',
|
||||
};
|
||||
|
||||
_SnFileReplica _$SnFileReplicaFromJson(Map<String, dynamic> json) =>
|
||||
_SnFileReplica(
|
||||
id: json['id'] as String,
|
||||
objectId: json['object_id'] as String,
|
||||
poolId: json['pool_id'] as String,
|
||||
pool: json['pool'] == null
|
||||
? null
|
||||
: SnFilePool.fromJson(json['pool'] as Map<String, dynamic>),
|
||||
storageId: json['storage_id'] as String,
|
||||
status: (json['status'] as num).toInt(),
|
||||
isPrimary: json['is_primary'] as bool,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnFileReplicaToJson(_SnFileReplica instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'object_id': instance.objectId,
|
||||
'pool_id': instance.poolId,
|
||||
'pool': instance.pool?.toJson(),
|
||||
'storage_id': instance.storageId,
|
||||
'status': instance.status,
|
||||
'is_primary': instance.isPrimary,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_SnCloudFileObject _$SnCloudFileObjectFromJson(Map<String, dynamic> json) =>
|
||||
_SnCloudFileObject(
|
||||
id: json['id'] as String,
|
||||
size: (json['size'] as num).toInt(),
|
||||
meta: json['meta'] as Map<String, dynamic>?,
|
||||
mimeType: json['mime_type'] as String?,
|
||||
hash: json['hash'] as String?,
|
||||
hasCompression: json['has_compression'] as bool,
|
||||
hasThumbnail: json['has_thumbnail'] as bool,
|
||||
fileReplicas: (json['file_replicas'] as List<dynamic>)
|
||||
.map((e) => SnFileReplica.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnCloudFileObjectToJson(_SnCloudFileObject instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'size': instance.size,
|
||||
'meta': instance.meta,
|
||||
'mime_type': instance.mimeType,
|
||||
'hash': instance.hash,
|
||||
'has_compression': instance.hasCompression,
|
||||
'has_thumbnail': instance.hasThumbnail,
|
||||
'file_replicas': instance.fileReplicas.map((e) => e.toJson()).toList(),
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String?,
|
||||
fileMeta: json['file_meta'] as Map<String, dynamic>?,
|
||||
userMeta: json['user_meta'] as Map<String, dynamic>?,
|
||||
pool: json['pool'] == null
|
||||
? null
|
||||
: SnFilePool.fromJson(json['pool'] as Map<String, dynamic>),
|
||||
sensitiveMarks:
|
||||
(json['sensitive_marks'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
@@ -49,7 +112,6 @@ _SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile(
|
||||
uploadedAt: json['uploaded_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['uploaded_at'] as String),
|
||||
uploadedTo: json['uploaded_to'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
@@ -65,13 +127,11 @@ Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) =>
|
||||
'description': instance.description,
|
||||
'file_meta': instance.fileMeta,
|
||||
'user_meta': instance.userMeta,
|
||||
'pool': instance.pool?.toJson(),
|
||||
'sensitive_marks': instance.sensitiveMarks,
|
||||
'mime_type': instance.mimeType,
|
||||
'hash': instance.hash,
|
||||
'size': instance.size,
|
||||
'uploaded_at': instance.uploadedAt?.toIso8601String(),
|
||||
'uploaded_to': instance.uploadedTo,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
|
||||
part 'reference.freezed.dart';
|
||||
part 'reference.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class Reference with _$Reference {
|
||||
const factory Reference({
|
||||
required String id,
|
||||
@JsonKey(name: 'file_id') required String fileId,
|
||||
SnCloudFile? file,
|
||||
required String usage,
|
||||
@JsonKey(name: 'resource_id') required String resourceId,
|
||||
@JsonKey(name: 'expired_at') DateTime? expiredAt,
|
||||
@JsonKey(name: 'created_at') required DateTime createdAt,
|
||||
@JsonKey(name: 'updated_at') required DateTime updatedAt,
|
||||
@JsonKey(name: 'deleted_at') DateTime? deletedAt,
|
||||
}) = _Reference;
|
||||
|
||||
factory Reference.fromJson(Map<String, dynamic> json) =>
|
||||
_$ReferenceFromJson(json);
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'reference.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$Reference {
|
||||
|
||||
String get id;@JsonKey(name: 'file_id') String get fileId; SnCloudFile? get file; String get usage;@JsonKey(name: 'resource_id') String get resourceId;@JsonKey(name: 'expired_at') DateTime? get expiredAt;@JsonKey(name: 'created_at') DateTime get createdAt;@JsonKey(name: 'updated_at') DateTime get updatedAt;@JsonKey(name: 'deleted_at') DateTime? get deletedAt;
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ReferenceCopyWith<Reference> get copyWith => _$ReferenceCopyWithImpl<Reference>(this as Reference, _$identity);
|
||||
|
||||
/// Serializes this Reference to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is Reference&&(identical(other.id, id) || other.id == id)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.usage, usage) || other.usage == usage)&&(identical(other.resourceId, resourceId) || other.resourceId == resourceId)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,fileId,file,usage,resourceId,expiredAt,createdAt,updatedAt,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Reference(id: $id, fileId: $fileId, file: $file, usage: $usage, resourceId: $resourceId, expiredAt: $expiredAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ReferenceCopyWith<$Res> {
|
||||
factory $ReferenceCopyWith(Reference value, $Res Function(Reference) _then) = _$ReferenceCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,@JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage,@JsonKey(name: 'resource_id') String resourceId,@JsonKey(name: 'expired_at') DateTime? expiredAt,@JsonKey(name: 'created_at') DateTime createdAt,@JsonKey(name: 'updated_at') DateTime updatedAt,@JsonKey(name: 'deleted_at') DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
$SnCloudFileCopyWith<$Res>? get file;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ReferenceCopyWithImpl<$Res>
|
||||
implements $ReferenceCopyWith<$Res> {
|
||||
_$ReferenceCopyWithImpl(this._self, this._then);
|
||||
|
||||
final Reference _self;
|
||||
final $Res Function(Reference) _then;
|
||||
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? fileId = null,Object? file = freezed,Object? usage = null,Object? resourceId = null,Object? expiredAt = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
|
||||
as String,file: freezed == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
|
||||
as SnCloudFile?,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable
|
||||
as String,resourceId: null == resourceId ? _self.resourceId : resourceId // ignore: cast_nullable_to_non_nullable
|
||||
as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
));
|
||||
}
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnCloudFileCopyWith<$Res>? get file {
|
||||
if (_self.file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnCloudFileCopyWith<$Res>(_self.file!, (value) {
|
||||
return _then(_self.copyWith(file: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [Reference].
|
||||
extension ReferencePatterns on Reference {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _Reference value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _Reference value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference():
|
||||
return $default(_that);}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _Reference value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference() when $default != null:
|
||||
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference():
|
||||
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, @JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage, @JsonKey(name: 'resource_id') String resourceId, @JsonKey(name: 'expired_at') DateTime? expiredAt, @JsonKey(name: 'created_at') DateTime createdAt, @JsonKey(name: 'updated_at') DateTime updatedAt, @JsonKey(name: 'deleted_at') DateTime? deletedAt)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Reference() when $default != null:
|
||||
return $default(_that.id,_that.fileId,_that.file,_that.usage,_that.resourceId,_that.expiredAt,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
|
||||
class _Reference implements Reference {
|
||||
const _Reference({required this.id, @JsonKey(name: 'file_id') required this.fileId, this.file, required this.usage, @JsonKey(name: 'resource_id') required this.resourceId, @JsonKey(name: 'expired_at') this.expiredAt, @JsonKey(name: 'created_at') required this.createdAt, @JsonKey(name: 'updated_at') required this.updatedAt, @JsonKey(name: 'deleted_at') this.deletedAt});
|
||||
factory _Reference.fromJson(Map<String, dynamic> json) => _$ReferenceFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@override@JsonKey(name: 'file_id') final String fileId;
|
||||
@override final SnCloudFile? file;
|
||||
@override final String usage;
|
||||
@override@JsonKey(name: 'resource_id') final String resourceId;
|
||||
@override@JsonKey(name: 'expired_at') final DateTime? expiredAt;
|
||||
@override@JsonKey(name: 'created_at') final DateTime createdAt;
|
||||
@override@JsonKey(name: 'updated_at') final DateTime updatedAt;
|
||||
@override@JsonKey(name: 'deleted_at') final DateTime? deletedAt;
|
||||
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ReferenceCopyWith<_Reference> get copyWith => __$ReferenceCopyWithImpl<_Reference>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$ReferenceToJson(this, );
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Reference&&(identical(other.id, id) || other.id == id)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.usage, usage) || other.usage == usage)&&(identical(other.resourceId, resourceId) || other.resourceId == resourceId)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,fileId,file,usage,resourceId,expiredAt,createdAt,updatedAt,deletedAt);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Reference(id: $id, fileId: $fileId, file: $file, usage: $usage, resourceId: $resourceId, expiredAt: $expiredAt, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ReferenceCopyWith<$Res> implements $ReferenceCopyWith<$Res> {
|
||||
factory _$ReferenceCopyWith(_Reference value, $Res Function(_Reference) _then) = __$ReferenceCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id,@JsonKey(name: 'file_id') String fileId, SnCloudFile? file, String usage,@JsonKey(name: 'resource_id') String resourceId,@JsonKey(name: 'expired_at') DateTime? expiredAt,@JsonKey(name: 'created_at') DateTime createdAt,@JsonKey(name: 'updated_at') DateTime updatedAt,@JsonKey(name: 'deleted_at') DateTime? deletedAt
|
||||
});
|
||||
|
||||
|
||||
@override $SnCloudFileCopyWith<$Res>? get file;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ReferenceCopyWithImpl<$Res>
|
||||
implements _$ReferenceCopyWith<$Res> {
|
||||
__$ReferenceCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _Reference _self;
|
||||
final $Res Function(_Reference) _then;
|
||||
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? fileId = null,Object? file = freezed,Object? usage = null,Object? resourceId = null,Object? expiredAt = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
return _then(_Reference(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
|
||||
as String,file: freezed == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
|
||||
as SnCloudFile?,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable
|
||||
as String,resourceId: null == resourceId ? _self.resourceId : resourceId // ignore: cast_nullable_to_non_nullable
|
||||
as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
));
|
||||
}
|
||||
|
||||
/// Create a copy of Reference
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnCloudFileCopyWith<$Res>? get file {
|
||||
if (_self.file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnCloudFileCopyWith<$Res>(_self.file!, (value) {
|
||||
return _then(_self.copyWith(file: value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
@@ -1,38 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'reference.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_Reference _$ReferenceFromJson(Map<String, dynamic> json) => _Reference(
|
||||
id: json['id'] as String,
|
||||
fileId: json['file_id'] as String,
|
||||
file: json['file'] == null
|
||||
? null
|
||||
: SnCloudFile.fromJson(json['file'] as Map<String, dynamic>),
|
||||
usage: json['usage'] as String,
|
||||
resourceId: json['resource_id'] as String,
|
||||
expiredAt: json['expired_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['expired_at'] as String),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ReferenceToJson(_Reference instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'file_id': instance.fileId,
|
||||
'file': instance.file?.toJson(),
|
||||
'usage': instance.usage,
|
||||
'resource_id': instance.resourceId,
|
||||
'expired_at': instance.expiredAt?.toIso8601String(),
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
};
|
||||
@@ -12,9 +12,10 @@ sealed class SnRelationship with _$SnRelationship {
|
||||
required DateTime? updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required String accountId,
|
||||
required SnAccount account,
|
||||
// Usually the account was not included in the response
|
||||
required SnAccount? account,
|
||||
required String relatedId,
|
||||
required SnAccount related,
|
||||
required SnAccount? related,
|
||||
required DateTime? expiredAt,
|
||||
required int status,
|
||||
}) = _SnRelationship;
|
||||
|
||||
@@ -15,7 +15,8 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$SnRelationship {
|
||||
|
||||
DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; String get accountId; SnAccount get account; String get relatedId; SnAccount get related; DateTime? get expiredAt; int get status;
|
||||
DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; String get accountId;// Usually the account was not included in the response
|
||||
SnAccount? get account; String get relatedId; SnAccount? get related; DateTime? get expiredAt; int get status;
|
||||
/// Create a copy of SnRelationship
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -48,11 +49,11 @@ abstract mixin class $SnRelationshipCopyWith<$Res> {
|
||||
factory $SnRelationshipCopyWith(SnRelationship value, $Res Function(SnRelationship) _then) = _$SnRelationshipCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount account, String relatedId, SnAccount related, DateTime? expiredAt, int status
|
||||
DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status
|
||||
});
|
||||
|
||||
|
||||
$SnAccountCopyWith<$Res> get account;$SnAccountCopyWith<$Res> get related;
|
||||
$SnAccountCopyWith<$Res>? get account;$SnAccountCopyWith<$Res>? get related;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
@@ -65,16 +66,16 @@ class _$SnRelationshipCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnRelationship
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? accountId = null,Object? account = null,Object? relatedId = null,Object? related = null,Object? expiredAt = freezed,Object? status = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? accountId = null,Object? account = freezed,Object? relatedId = null,Object? related = freezed,Object? expiredAt = freezed,Object? status = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,updatedAt: freezed == 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?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
|
||||
as String,account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount,relatedId: null == relatedId ? _self.relatedId : relatedId // ignore: cast_nullable_to_non_nullable
|
||||
as String,related: null == related ? _self.related : related // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as String,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount?,relatedId: null == relatedId ? _self.relatedId : relatedId // ignore: cast_nullable_to_non_nullable
|
||||
as String,related: freezed == related ? _self.related : related // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
@@ -83,18 +84,24 @@ as int,
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnAccountCopyWith<$Res> get account {
|
||||
$SnAccountCopyWith<$Res>? get account {
|
||||
if (_self.account == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnAccountCopyWith<$Res>(_self.account, (value) {
|
||||
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
|
||||
return _then(_self.copyWith(account: value));
|
||||
});
|
||||
}/// Create a copy of SnRelationship
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnAccountCopyWith<$Res> get related {
|
||||
$SnAccountCopyWith<$Res>? get related {
|
||||
if (_self.related == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnAccountCopyWith<$Res>(_self.related, (value) {
|
||||
return $SnAccountCopyWith<$Res>(_self.related!, (value) {
|
||||
return _then(_self.copyWith(related: value));
|
||||
});
|
||||
}
|
||||
@@ -176,7 +183,7 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount account, String relatedId, SnAccount related, DateTime? expiredAt, int status)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnRelationship() when $default != null:
|
||||
return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,_that.account,_that.relatedId,_that.related,_that.expiredAt,_that.status);case _:
|
||||
@@ -197,7 +204,7 @@ return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount account, String relatedId, SnAccount related, DateTime? expiredAt, int status) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnRelationship():
|
||||
return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,_that.account,_that.relatedId,_that.related,_that.expiredAt,_that.status);}
|
||||
@@ -214,7 +221,7 @@ return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount account, String relatedId, SnAccount related, DateTime? expiredAt, int status)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _SnRelationship() when $default != null:
|
||||
return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,_that.account,_that.relatedId,_that.related,_that.expiredAt,_that.status);case _:
|
||||
@@ -236,9 +243,10 @@ class _SnRelationship implements SnRelationship {
|
||||
@override final DateTime? updatedAt;
|
||||
@override final DateTime? deletedAt;
|
||||
@override final String accountId;
|
||||
@override final SnAccount account;
|
||||
// Usually the account was not included in the response
|
||||
@override final SnAccount? account;
|
||||
@override final String relatedId;
|
||||
@override final SnAccount related;
|
||||
@override final SnAccount? related;
|
||||
@override final DateTime? expiredAt;
|
||||
@override final int status;
|
||||
|
||||
@@ -275,11 +283,11 @@ abstract mixin class _$SnRelationshipCopyWith<$Res> implements $SnRelationshipCo
|
||||
factory _$SnRelationshipCopyWith(_SnRelationship value, $Res Function(_SnRelationship) _then) = __$SnRelationshipCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount account, String relatedId, SnAccount related, DateTime? expiredAt, int status
|
||||
DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status
|
||||
});
|
||||
|
||||
|
||||
@override $SnAccountCopyWith<$Res> get account;@override $SnAccountCopyWith<$Res> get related;
|
||||
@override $SnAccountCopyWith<$Res>? get account;@override $SnAccountCopyWith<$Res>? get related;
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
@@ -292,16 +300,16 @@ class __$SnRelationshipCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnRelationship
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? accountId = null,Object? account = null,Object? relatedId = null,Object? related = null,Object? expiredAt = freezed,Object? status = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? accountId = null,Object? account = freezed,Object? relatedId = null,Object? related = freezed,Object? expiredAt = freezed,Object? status = null,}) {
|
||||
return _then(_SnRelationship(
|
||||
createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,updatedAt: freezed == 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?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
|
||||
as String,account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount,relatedId: null == relatedId ? _self.relatedId : relatedId // ignore: cast_nullable_to_non_nullable
|
||||
as String,related: null == related ? _self.related : related // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as String,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount?,relatedId: null == relatedId ? _self.relatedId : relatedId // ignore: cast_nullable_to_non_nullable
|
||||
as String,related: freezed == related ? _self.related : related // ignore: cast_nullable_to_non_nullable
|
||||
as SnAccount?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
@@ -311,18 +319,24 @@ as int,
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnAccountCopyWith<$Res> get account {
|
||||
$SnAccountCopyWith<$Res>? get account {
|
||||
if (_self.account == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnAccountCopyWith<$Res>(_self.account, (value) {
|
||||
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
|
||||
return _then(_self.copyWith(account: value));
|
||||
});
|
||||
}/// Create a copy of SnRelationship
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnAccountCopyWith<$Res> get related {
|
||||
$SnAccountCopyWith<$Res>? get related {
|
||||
if (_self.related == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $SnAccountCopyWith<$Res>(_self.related, (value) {
|
||||
return $SnAccountCopyWith<$Res>(_self.related!, (value) {
|
||||
return _then(_self.copyWith(related: value));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,9 +18,13 @@ _SnRelationship _$SnRelationshipFromJson(Map<String, dynamic> json) =>
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
accountId: json['account_id'] as String,
|
||||
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
|
||||
account: json['account'] == null
|
||||
? null
|
||||
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
|
||||
relatedId: json['related_id'] as String,
|
||||
related: SnAccount.fromJson(json['related'] as Map<String, dynamic>),
|
||||
related: json['related'] == null
|
||||
? null
|
||||
: SnAccount.fromJson(json['related'] as Map<String, dynamic>),
|
||||
expiredAt: json['expired_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['expired_at'] as String),
|
||||
@@ -33,9 +37,9 @@ Map<String, dynamic> _$SnRelationshipToJson(_SnRelationship instance) =>
|
||||
'updated_at': instance.updatedAt?.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'account_id': instance.accountId,
|
||||
'account': instance.account.toJson(),
|
||||
'account': instance.account?.toJson(),
|
||||
'related_id': instance.relatedId,
|
||||
'related': instance.related.toJson(),
|
||||
'related': instance.related?.toJson(),
|
||||
'expired_at': instance.expiredAt?.toIso8601String(),
|
||||
'status': instance.status,
|
||||
};
|
||||
|
||||
65
lib/pods/audio.dart
Normal file
65
lib/pods/audio.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
|
||||
final sfxPlayerProvider = Provider<AudioPlayer>((ref) {
|
||||
final player = AudioPlayer();
|
||||
ref.onDispose(() {
|
||||
player.dispose();
|
||||
});
|
||||
return player;
|
||||
});
|
||||
|
||||
Future<void> _configureAudioSession() async {
|
||||
final session = await AudioSession.instance;
|
||||
await session.configure(
|
||||
const AudioSessionConfiguration(
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playback,
|
||||
avAudioSessionCategoryOptions:
|
||||
AVAudioSessionCategoryOptions.mixWithOthers,
|
||||
),
|
||||
);
|
||||
await session.setActive(true);
|
||||
}
|
||||
|
||||
final audioSessionProvider = FutureProvider<void>((ref) async {
|
||||
await _configureAudioSession();
|
||||
});
|
||||
|
||||
final notificationSfxProvider = FutureProvider<void>((ref) async {
|
||||
final player = ref.watch(sfxPlayerProvider);
|
||||
await player.setVolume(0.75);
|
||||
await player.setAudioSource(
|
||||
AudioSource.asset('assets/audio/notification.mp3'),
|
||||
preload: true,
|
||||
);
|
||||
});
|
||||
|
||||
final messageSfxProvider = FutureProvider<void>((ref) async {
|
||||
final player = ref.watch(sfxPlayerProvider);
|
||||
await player.setAudioSource(
|
||||
AudioSource.asset('assets/audio/messages.mp3'),
|
||||
preload: true,
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> _playSfx(String assetPath, double volume) async {
|
||||
final player = AudioPlayer();
|
||||
await player.setVolume(volume);
|
||||
await player.setAudioSource(AudioSource.asset(assetPath));
|
||||
await player.play();
|
||||
await player.dispose();
|
||||
}
|
||||
|
||||
void playNotificationSfx(WidgetRef ref) {
|
||||
final settings = ref.read(appSettingsProvider);
|
||||
if (!settings.soundEffects) return;
|
||||
_playSfx('assets/audio/notification.mp3', 0.75);
|
||||
}
|
||||
|
||||
void playMessageSfx(WidgetRef ref) {
|
||||
final settings = ref.read(appSettingsProvider);
|
||||
if (!settings.soundEffects) return;
|
||||
_playSfx('assets/audio/messages.mp3', 0.75);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import "dart:async";
|
||||
import "dart:convert";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:just_audio/just_audio.dart";
|
||||
import "package:island/pods/config.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/pods/chat/chat_room.dart";
|
||||
import "package:island/pods/lifecycle.dart";
|
||||
@@ -198,7 +200,7 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
|
||||
return _typingStatuses;
|
||||
}
|
||||
|
||||
void onMessage(WebSocketPacket pkt) {
|
||||
Future<void> onMessage(WebSocketPacket pkt) async {
|
||||
if (!pkt.type.startsWith('messages')) return;
|
||||
if (['messages.read'].contains(pkt.type)) return;
|
||||
|
||||
@@ -221,7 +223,7 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
|
||||
// Add new typing status
|
||||
_typingStatuses.add(sender);
|
||||
}
|
||||
state = List.of(_typingStatuses);
|
||||
if (ref.mounted) state = List.of(_typingStatuses);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,6 +240,20 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
|
||||
_messagesNotifier.receiveMessage(message);
|
||||
// Send read receipt for new message
|
||||
sendReadReceipt();
|
||||
// Play sound for new messages when app is unfocused
|
||||
if (pkt.type == 'messages.new' &&
|
||||
message.senderId != _chatIdentity.id &&
|
||||
ref.read(appLifecycleStateProvider).value !=
|
||||
AppLifecycleState.resumed &&
|
||||
ref.read(appSettingsProvider).soundEffects) {
|
||||
final player = AudioPlayer();
|
||||
await player.setVolume(0.75);
|
||||
await player.setAudioSource(
|
||||
AudioSource.asset('assets/audio/messages.mp3'),
|
||||
);
|
||||
await player.play();
|
||||
player.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ final class ChatSubscribeNotifierProvider
|
||||
}
|
||||
|
||||
String _$chatSubscribeNotifierHash() =>
|
||||
r'b7624ae45ace2944a88f8b4d14ddce556c236371';
|
||||
r'944cb0c1b1805050470d4b79c60937f622d7b716';
|
||||
|
||||
final class ChatSubscribeNotifierFamily extends $Family
|
||||
with
|
||||
|
||||
@@ -50,9 +50,6 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
late Future<SnAccount?> Function(String) _fetchAccount;
|
||||
|
||||
// Disposal handling
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
FutureOr<List<LocalChatMessage>> build(String roomId) async {
|
||||
_apiClient = ref.watch(apiClientProvider);
|
||||
@@ -81,17 +78,11 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
talker.log('MessagesNotifier built for room $roomId');
|
||||
|
||||
// Set up disposal handling
|
||||
ref.onDispose(() {
|
||||
_disposed = true;
|
||||
talker.log('MessagesNotifier disposed for room $roomId');
|
||||
});
|
||||
|
||||
// Only setup sync and lifecycle listeners if user is a member
|
||||
if (identity != null) {
|
||||
ref.listen(appLifecycleStateProvider, (_, next) {
|
||||
next.whenData((state) {
|
||||
if (_disposed) return; // Check disposal before accessing ref
|
||||
if (!ref.mounted) return; // Check disposal before accessing ref
|
||||
if (state == AppLifecycleState.paused) {
|
||||
_lastPauseTime = DateTime.now();
|
||||
talker.log('App paused, recording time');
|
||||
@@ -100,7 +91,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
final diff = DateTime.now().difference(_lastPauseTime!);
|
||||
if (diff > const Duration(minutes: 1)) {
|
||||
talker.log('App resumed after >1 min, syncing messages');
|
||||
if (!_disposed) {
|
||||
if (!!ref.mounted) {
|
||||
syncMessages(); // Check disposal before calling syncMessages
|
||||
}
|
||||
} else {
|
||||
@@ -137,7 +128,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
uniqueMessages.add(message);
|
||||
}
|
||||
}
|
||||
state = AsyncValue.data(uniqueMessages);
|
||||
if (ref.mounted) state = AsyncValue.data(uniqueMessages);
|
||||
} finally {
|
||||
_isUpdatingState = false;
|
||||
}
|
||||
@@ -336,7 +327,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
talker.log('Starting message sync');
|
||||
// Use Future.microtask to set syncing state, but check disposal to avoid errors
|
||||
Future.microtask(() {
|
||||
if (!_disposed) {
|
||||
if (!!ref.mounted) {
|
||||
ref.read(chatSyncingProvider.notifier).set(true);
|
||||
}
|
||||
});
|
||||
@@ -359,7 +350,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
offset: 0,
|
||||
take: _pageSize,
|
||||
);
|
||||
state = AsyncValue.data(newMessages);
|
||||
if (ref.mounted) state = AsyncValue.data(newMessages);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -417,7 +408,9 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
} finally {
|
||||
talker.log('Finished message sync');
|
||||
// Always reset global syncing state, regardless of disposal
|
||||
Future.microtask(() => ref.read(chatSyncingProvider.notifier).set(false));
|
||||
Future.microtask(() {
|
||||
if (ref.mounted) ref.read(chatSyncingProvider.notifier).set(false);
|
||||
});
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
@@ -507,18 +500,18 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
_hasMore = messages.length == _pageSize;
|
||||
|
||||
state = AsyncValue.data(messages);
|
||||
if (ref.mounted) state = AsyncValue.data(messages);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (!_hasMore || state is AsyncLoading) return;
|
||||
talker.log('Loading more messages');
|
||||
|
||||
if (!_disposed) {
|
||||
if (!!ref.mounted) {
|
||||
Future.microtask(() => ref.read(chatSyncingProvider.notifier).set(true));
|
||||
}
|
||||
try {
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final offset = currentMessages.length;
|
||||
|
||||
final newMessages = await listMessages(offset: offset, take: _pageSize);
|
||||
@@ -527,9 +520,11 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_hasMore = false;
|
||||
}
|
||||
|
||||
if (ref.mounted) {
|
||||
state = AsyncValue.data(
|
||||
_sortMessages([...currentMessages, ...newMessages]),
|
||||
);
|
||||
}
|
||||
} catch (err, stackTrace) {
|
||||
talker.log(
|
||||
'Error loading more messages',
|
||||
@@ -540,12 +535,14 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
// Always reset global syncing state, regardless of disposal
|
||||
Future.microtask(() => ref.read(chatSyncingProvider.notifier).set(false));
|
||||
Future.microtask(() {
|
||||
if (ref.mounted) ref.read(chatSyncingProvider.notifier).set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendMessage(
|
||||
WidgetRef ref,
|
||||
WidgetRef outerRef,
|
||||
String content,
|
||||
List<UniversalFile> attachments, {
|
||||
SnPoll? poll,
|
||||
@@ -578,14 +575,14 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_fileUploadProgress[localMessage.id] = {};
|
||||
await _database.saveMessageWithSender(localMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
state = AsyncValue.data([localMessage, ...currentMessages]);
|
||||
|
||||
try {
|
||||
var cloudAttachments = List.empty(growable: true);
|
||||
for (var idx = 0; idx < attachments.length; idx++) {
|
||||
final cloudFile = await FileUploader.createCloudFile(
|
||||
ref: ref,
|
||||
ref: outerRef,
|
||||
fileData: attachments[idx],
|
||||
onProgress: (progress, _) {
|
||||
_fileUploadProgress[localMessage.id]?[idx] = progress ?? 0.0;
|
||||
@@ -628,6 +625,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
await _database.deleteMessage(localMessage.id);
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
if (ref.mounted) {
|
||||
final currentMessages = state.value ?? [];
|
||||
if (editingTo != null) {
|
||||
final newMessages = currentMessages
|
||||
@@ -646,6 +644,8 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
}
|
||||
}
|
||||
|
||||
talker.log('Message with nonce $nonce sent successfully');
|
||||
} catch (e, stackTrace) {
|
||||
talker.log(
|
||||
@@ -660,6 +660,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
localMessage.id,
|
||||
MessageStatus.failed,
|
||||
);
|
||||
if (ref.mounted) {
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == localMessage.id) {
|
||||
return m..status = MessageStatus.failed;
|
||||
@@ -667,6 +668,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
}
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
@@ -707,6 +709,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
await _database.deleteMessage(pendingMessageId);
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
if (ref.mounted) {
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == pendingMessageId) {
|
||||
return updatedMessage;
|
||||
@@ -714,6 +717,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
talker.log(
|
||||
'Failed to retry message $pendingMessageId',
|
||||
@@ -727,6 +731,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
pendingMessageId,
|
||||
MessageStatus.failed,
|
||||
);
|
||||
if (ref.mounted) {
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == pendingMessageId) {
|
||||
return m..status = MessageStatus.failed;
|
||||
@@ -734,6 +739,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(_sortMessages(newMessages));
|
||||
}
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
@@ -765,13 +771,14 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
if (!isSilentMessage) {
|
||||
await _database.saveMessageWithSender(localMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final existingIndex = currentMessages.indexWhere(
|
||||
(m) =>
|
||||
m.id == localMessage.id ||
|
||||
(localMessage.nonce != null && m.nonce == localMessage.nonce),
|
||||
);
|
||||
|
||||
if (ref.mounted) {
|
||||
if (existingIndex >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[existingIndex] = localMessage;
|
||||
@@ -782,6 +789,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (remoteMessage.type) {
|
||||
case "messages.delete":
|
||||
@@ -846,15 +854,17 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
await _database.updateMessage(_database.messageToCompanion(updatedMessage));
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final index = currentMessages.indexWhere((m) => m.id == updatedMessage.id);
|
||||
|
||||
if (ref.mounted) {
|
||||
if (index >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[index] = updatedMessage;
|
||||
state = AsyncValue.data(_sortMessages(newList));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> receiveMessageDeletion(String messageId) async {
|
||||
// Block message deletions during jumps to prevent list resets
|
||||
@@ -866,7 +876,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
talker.log('Received message deletion $messageId');
|
||||
_pendingMessages.remove(messageId);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final messageIndex = currentMessages.indexWhere((m) => m.id == messageId);
|
||||
|
||||
LocalChatMessage? messageToUpdate;
|
||||
@@ -892,12 +902,14 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
await _database.saveMessageWithSender(deletedMessage);
|
||||
|
||||
if (ref.mounted) {
|
||||
if (messageIndex != -1) {
|
||||
final newList = [...currentMessages];
|
||||
newList[messageIndex] = deletedMessage;
|
||||
state = AsyncValue.data(newList);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteMessage(String messageId) async {
|
||||
talker.log('Deleting message $messageId');
|
||||
@@ -916,7 +928,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_pendingMessages.remove(messageId);
|
||||
await _database.deleteMessage(messageId);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final newMessages = currentMessages
|
||||
.where((m) => m.id != messageId)
|
||||
.toList();
|
||||
@@ -1058,7 +1070,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_isJumping = true;
|
||||
|
||||
// Clear flashing messages when starting a new jump
|
||||
if (!_disposed) {
|
||||
if (!!ref.mounted) {
|
||||
ref.read(flashingMessagesProvider.notifier).state = {};
|
||||
}
|
||||
|
||||
@@ -1072,7 +1084,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
// Check if message is already in current state to avoid duplicate loading
|
||||
final currentMessages = state.value ?? [];
|
||||
final currentMessages = (ref.mounted ? state.value : null) ?? [];
|
||||
final existingIndex = currentMessages.indexWhere(
|
||||
(m) => m.id == messageId,
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ final class MessagesNotifierProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$messagesNotifierHash() => r'a721a4b92b48ee7c2289cdcd7130bbf1ca9dcb40';
|
||||
String _$messagesNotifierHash() => r'622fed0908eb04381a971e36540c516743246dff';
|
||||
|
||||
final class MessagesNotifierFamily extends $Family
|
||||
with
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:island/services/analytics_service.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
@@ -256,8 +257,11 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
|
||||
|
||||
void setThemeMode(String value) {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
final oldValue = state.themeMode;
|
||||
prefs.setString(kAppThemeMode, value);
|
||||
state = state.copyWith(themeMode: value);
|
||||
|
||||
AnalyticsService().logThemeChanged(oldValue ?? 'system', value);
|
||||
}
|
||||
|
||||
void setAppTransparentBackground(double value) {
|
||||
|
||||
@@ -65,7 +65,7 @@ final class AppSettingsNotifierProvider
|
||||
}
|
||||
|
||||
String _$appSettingsNotifierHash() =>
|
||||
r'2437c621dcb1625a120ed1f21ab5c29906ba98be';
|
||||
r'64cf6dfda90fc634336d96bd5313b2a07b19eccb';
|
||||
|
||||
abstract class _$AppSettingsNotifier extends $Notifier<AppSettings> {
|
||||
AppSettings build();
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/models/reference.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
|
||||
part 'file_references.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<Reference>> fileReferences(Ref ref, String fileId) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/drive/files/$fileId/references');
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((json) => Reference.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'file_references.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(fileReferences)
|
||||
final fileReferencesProvider = FileReferencesFamily._();
|
||||
|
||||
final class FileReferencesProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<Reference>>,
|
||||
List<Reference>,
|
||||
FutureOr<List<Reference>>
|
||||
>
|
||||
with $FutureModifier<List<Reference>>, $FutureProvider<List<Reference>> {
|
||||
FileReferencesProvider._({
|
||||
required FileReferencesFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'fileReferencesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$fileReferencesHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'fileReferencesProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<Reference>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<Reference>> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return fileReferences(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is FileReferencesProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$fileReferencesHash() => r'd66c678c221f61978bdb242b98e6dbe31d0c204b';
|
||||
|
||||
final class FileReferencesFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<List<Reference>>, String> {
|
||||
FileReferencesFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'fileReferencesProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
FileReferencesProvider call(String fileId) =>
|
||||
FileReferencesProvider._(argument: fileId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'fileReferencesProvider';
|
||||
}
|
||||
@@ -29,30 +29,59 @@ class UploadTasksNotifier extends Notifier<List<DriveTask>> {
|
||||
}
|
||||
|
||||
void _handleWebSocketPacket(dynamic packet) {
|
||||
if (packet.type.startsWith('task.')) {
|
||||
if (packet.type.startsWith('task.') || packet.type == 'upload.completed') {
|
||||
final data = packet.data;
|
||||
if (data == null) return;
|
||||
if (data == null && packet.type != 'upload.completed') return;
|
||||
|
||||
// Debug logging
|
||||
talker.info(
|
||||
'[UploadTasks] Received WebSocket packet: ${packet.type}, data: $data',
|
||||
);
|
||||
|
||||
final taskId = data['task_id'] as String?;
|
||||
if (taskId == null) return;
|
||||
final taskId = data != null ? (data['task_id'] as String?) : null;
|
||||
if (taskId == null && packet.type != 'upload.completed') return;
|
||||
|
||||
switch (packet.type) {
|
||||
case 'task.created':
|
||||
_handleTaskCreated(taskId, data);
|
||||
_handleTaskCreated(taskId!, data);
|
||||
break;
|
||||
case 'task.progress':
|
||||
_handleProgressUpdate(taskId, data);
|
||||
_handleProgressUpdate(taskId!, data);
|
||||
break;
|
||||
case 'task.completed':
|
||||
_handleUploadCompleted(taskId, data);
|
||||
_handleUploadCompleted(taskId!, data);
|
||||
break;
|
||||
case 'upload.completed':
|
||||
// For upload.completed, we need to find the taskId differently
|
||||
// Since data is null, we can't get task_id from data
|
||||
// We'll need to mark all in-progress uploads as completed
|
||||
// For now, assume we need to handle it per task, but since no task_id,
|
||||
// perhaps it's a broadcast or we need to modify the logic
|
||||
// Actually, looking at the logs, upload.completed has data: null, but maybe in real scenario it has task_id?
|
||||
// For now, let's assume it needs task_id, and modify accordingly
|
||||
// But since the original code expects data, perhaps the server sends data with task_id
|
||||
// Wait, in the logs: "upload.completed null" - the null is data, but perhaps in code it's null
|
||||
// To be safe, let's modify to handle upload.completed even with null data, but we need task_id
|
||||
// Perhaps search for in-progress tasks and complete them
|
||||
// But that's risky. Let's see the log again: the previous task.progress had task_id: YvvfVbaWSxj5vUnFnzJDu
|
||||
// So probably upload.completed should have the same task_id
|
||||
// Perhaps the server sends it with data containing task_id
|
||||
// The log says "upload.completed null" meaning data is null
|
||||
// But maybe it's a logging issue. To fix, let's assume data has task_id for upload.completed
|
||||
// If not, we can modify _handleUploadCompleted to accept null data and find the task
|
||||
if (data != null && data['task_id'] != null) {
|
||||
_handleUploadCompleted(data['task_id'], data);
|
||||
} else {
|
||||
// If no data, perhaps complete the most recent in-progress task
|
||||
final inProgressTasks = state.where((task) => task.status == DriveTaskStatus.inProgress).toList();
|
||||
if (inProgressTasks.isNotEmpty) {
|
||||
final task = inProgressTasks.last; // Assume the last one
|
||||
_handleUploadCompleted(task.taskId, {});
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'task.failed':
|
||||
_handleUploadFailed(taskId, data);
|
||||
_handleUploadFailed(taskId!, data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -256,6 +285,21 @@ class UploadTasksNotifier extends Notifier<List<DriveTask>> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void updateUploadProgress(String taskId, int uploadedBytes, int uploadedChunks) {
|
||||
state =
|
||||
state.map((task) {
|
||||
if (task.taskId == taskId) {
|
||||
return task.copyWith(
|
||||
uploadedBytes: uploadedBytes,
|
||||
uploadedChunks: uploadedChunks,
|
||||
status: DriveTaskStatus.inProgress,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
return task;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void updateDownloadProgress(
|
||||
String taskId,
|
||||
int downloadedBytes,
|
||||
@@ -470,6 +514,7 @@ class EnhancedFileUploader extends FileUploader {
|
||||
|
||||
// Step 2: Upload chunks
|
||||
int bytesUploaded = 0;
|
||||
int chunksUploaded = 0;
|
||||
if (fileData is XFile) {
|
||||
// Use stream for XFile
|
||||
final subscription = fileData.openRead().listen(null);
|
||||
@@ -494,6 +539,11 @@ class EnhancedFileUploader extends FileUploader {
|
||||
},
|
||||
);
|
||||
bytesUploaded += chunkData.length;
|
||||
chunksUploaded += 1;
|
||||
// Update upload progress in UI
|
||||
ref
|
||||
.read(uploadTasksProvider.notifier)
|
||||
.updateUploadProgress(taskId, bytesUploaded, chunksUploaded);
|
||||
}
|
||||
subscription.cancel();
|
||||
} else if (fileData is Uint8List) {
|
||||
@@ -521,6 +571,11 @@ class EnhancedFileUploader extends FileUploader {
|
||||
},
|
||||
);
|
||||
bytesUploaded += chunks[i].length;
|
||||
chunksUploaded += 1;
|
||||
// Update upload progress in UI
|
||||
ref
|
||||
.read(uploadTasksProvider.notifier)
|
||||
.updateUploadProgress(taskId, bytesUploaded, chunksUploaded);
|
||||
}
|
||||
} else {
|
||||
throw ArgumentError('Invalid fileData type');
|
||||
|
||||
99
lib/pods/notification.dart
Normal file
99
lib/pods/notification.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
part 'notification.g.dart';
|
||||
|
||||
const kNotificationBaseDuration = Duration(seconds: 5);
|
||||
const kNotificationStackedDuration = Duration(seconds: 1);
|
||||
|
||||
class NotificationItem {
|
||||
final String id;
|
||||
final SnNotification notification;
|
||||
final DateTime createdAt;
|
||||
final int index;
|
||||
final Duration duration;
|
||||
final bool dismissed;
|
||||
|
||||
NotificationItem({
|
||||
String? id,
|
||||
required this.notification,
|
||||
DateTime? createdAt,
|
||||
required this.index,
|
||||
Duration? duration,
|
||||
this.dismissed = false,
|
||||
}) : id = id ?? const Uuid().v4(),
|
||||
createdAt = createdAt ?? DateTime.now(),
|
||||
duration =
|
||||
duration ?? kNotificationBaseDuration + Duration(seconds: index);
|
||||
|
||||
NotificationItem copyWith({
|
||||
String? id,
|
||||
SnNotification? notification,
|
||||
DateTime? createdAt,
|
||||
int? index,
|
||||
Duration? duration,
|
||||
bool? dismissed,
|
||||
}) {
|
||||
return NotificationItem(
|
||||
id: id ?? this.id,
|
||||
notification: notification ?? this.notification,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
index: index ?? this.index,
|
||||
duration: duration ?? this.duration,
|
||||
dismissed: dismissed ?? this.dismissed,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is NotificationItem && other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class NotificationState extends _$NotificationState {
|
||||
final Map<String, Timer> _timers = {};
|
||||
|
||||
@override
|
||||
List<NotificationItem> build() {
|
||||
return [];
|
||||
}
|
||||
|
||||
void add(SnNotification notification, {Duration? duration}) {
|
||||
final newItem = NotificationItem(
|
||||
notification: notification,
|
||||
index: state.length,
|
||||
duration: duration,
|
||||
);
|
||||
state = [...state, newItem];
|
||||
_timers[newItem.id] = Timer(newItem.duration, () => dismiss(newItem.id));
|
||||
}
|
||||
|
||||
void dismiss(String id) {
|
||||
_timers[id]?.cancel();
|
||||
_timers.remove(id);
|
||||
final index = state.indexWhere((item) => item.id == id);
|
||||
if (index != -1) {
|
||||
state = List.from(state)..[index] = state[index].copyWith(dismissed: true);
|
||||
}
|
||||
}
|
||||
|
||||
void remove(String id) {
|
||||
state = state.where((item) => item.id != id).toList();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
for (final timer in _timers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_timers.clear();
|
||||
state = [];
|
||||
}
|
||||
}
|
||||
63
lib/pods/notification.g.dart
Normal file
63
lib/pods/notification.g.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'notification.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(NotificationState)
|
||||
final notificationStateProvider = NotificationStateProvider._();
|
||||
|
||||
final class NotificationStateProvider
|
||||
extends $NotifierProvider<NotificationState, List<NotificationItem>> {
|
||||
NotificationStateProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'notificationStateProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$notificationStateHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
NotificationState create() => NotificationState();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(List<NotificationItem> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<List<NotificationItem>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$notificationStateHash() => r'8625e77d28d71237d86f6d06efab437aa7c09df1';
|
||||
|
||||
abstract class _$NotificationState extends $Notifier<List<NotificationItem>> {
|
||||
List<NotificationItem> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<List<NotificationItem>, List<NotificationItem>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<List<NotificationItem>, List<NotificationItem>>,
|
||||
List<NotificationItem>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -58,11 +58,18 @@ class ActivityListNotifier
|
||||
// Find the latest createdAt timestamp from all items for cursor-based pagination
|
||||
// This ensures we get items created before this timestamp, regardless of sort order
|
||||
if (items.isNotEmpty) {
|
||||
final latestCreatedAt = items
|
||||
final newestCreatedAt = items
|
||||
.where((e) => e.type.startsWith('posts.'))
|
||||
.map((e) => e.createdAt)
|
||||
.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
cursor = latestCreatedAt.toUtc().toIso8601String();
|
||||
if (cursor != null) {
|
||||
final prevCursor = DateTime.tryParse(cursor!);
|
||||
if (prevCursor != null && prevCursor.isAfter(newestCreatedAt)) {
|
||||
cursor = newestCreatedAt.toUtc().toIso8601String();
|
||||
}
|
||||
} else {
|
||||
cursor = newestCreatedAt.toUtc().toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -13,6 +9,7 @@ import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/services/analytics_service.dart';
|
||||
|
||||
class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
|
||||
@override
|
||||
@@ -31,9 +28,7 @@ class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
|
||||
final response = await client.get('/pass/accounts/me');
|
||||
final user = SnAccount.fromJson(response.data);
|
||||
|
||||
if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) {
|
||||
FirebaseAnalytics.instance.setUserId(id: user.id);
|
||||
}
|
||||
AnalyticsService().setUserId(user.id);
|
||||
return user;
|
||||
} catch (error, stackTrace) {
|
||||
if (error is DioException) {
|
||||
@@ -91,9 +86,8 @@ class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
await prefs.remove(kTokenPairStoreKey);
|
||||
ref.invalidate(tokenProvider);
|
||||
if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) {
|
||||
FirebaseAnalytics.instance.setUserId(id: null);
|
||||
}
|
||||
AnalyticsService().setUserId(null);
|
||||
AnalyticsService().logLogout();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class WebFeedNotifier extends AsyncNotifier<SnWebFeed> {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get(
|
||||
'/sphere/publishers/${arg.pubName}/feeds/${arg.feedId}',
|
||||
'/insight/publishers/${arg.pubName}/feeds/${arg.feedId}',
|
||||
);
|
||||
return SnWebFeed.fromJson(response.data);
|
||||
} catch (e) {
|
||||
@@ -47,7 +47,7 @@ class WebFeedNotifier extends AsyncNotifier<SnWebFeed> {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final url = '/sphere/publishers/${feed.publisherId}/feeds';
|
||||
final url = '/insight/publishers/${feed.publisherId}/feeds';
|
||||
|
||||
final response = feed.id.isEmpty
|
||||
? await client.post(url, data: feed.toJson())
|
||||
@@ -67,7 +67,7 @@ class WebFeedNotifier extends AsyncNotifier<SnWebFeed> {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/sphere/publishers/${arg.pubName}/feeds/$feedId');
|
||||
await client.delete('/insight/publishers/${arg.pubName}/feeds/$feedId');
|
||||
state = AsyncValue.data(
|
||||
SnWebFeed(
|
||||
id: '',
|
||||
@@ -93,7 +93,7 @@ class WebFeedNotifier extends AsyncNotifier<SnWebFeed> {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post(
|
||||
'/sphere/publishers/${arg.pubName}/feeds/$feedId/scrap',
|
||||
'/insight/publishers/${arg.pubName}/feeds/$feedId/scrap',
|
||||
options: Options(
|
||||
sendTimeout: const Duration(seconds: 60),
|
||||
receiveTimeout: const Duration(seconds: 180),
|
||||
|
||||
@@ -4,6 +4,7 @@ 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:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/paging.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
@@ -22,7 +23,7 @@ import 'package:island/pods/network.dart';
|
||||
part 'relationship.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<SnRelationship>> sentFriendRequest(Ref ref) async {
|
||||
Future<List<SnRelationship>> friendRequest(Ref ref) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final resp = await client.get('/pass/relationships/requests');
|
||||
return resp.data
|
||||
@@ -31,11 +32,9 @@ Future<List<SnRelationship>> sentFriendRequest(Ref ref) async {
|
||||
.toList();
|
||||
}
|
||||
|
||||
final relationshipListNotifierProvider =
|
||||
AsyncNotifierProvider.autoDispose<
|
||||
RelationshipListNotifier,
|
||||
PaginationState<SnRelationship>
|
||||
>(RelationshipListNotifier.new);
|
||||
final relationshipListNotifierProvider = AsyncNotifierProvider.autoDispose(
|
||||
RelationshipListNotifier.new,
|
||||
);
|
||||
|
||||
class RelationshipListNotifier
|
||||
extends AsyncNotifier<PaginationState<SnRelationship>>
|
||||
@@ -84,6 +83,7 @@ class RelationshipListTile extends StatelessWidget {
|
||||
final String? currentUserId;
|
||||
final bool showRelatedAccount;
|
||||
final Function(SnRelationship, int)? onUpdateStatus;
|
||||
final Function(SnRelationship)? onDelete;
|
||||
|
||||
const RelationshipListTile({
|
||||
super.key,
|
||||
@@ -96,18 +96,19 @@ class RelationshipListTile extends StatelessWidget {
|
||||
required this.currentUserId,
|
||||
this.showRelatedAccount = false,
|
||||
this.onUpdateStatus,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final account = showRelatedAccount
|
||||
? relationship.related
|
||||
: relationship.account;
|
||||
? relationship.related!
|
||||
: relationship.account!;
|
||||
final isPending =
|
||||
relationship.status == 0 && relationship.relatedId == currentUserId;
|
||||
final isWaiting =
|
||||
relationship.status == 0 && relationship.accountId == currentUserId;
|
||||
final isEstablished = relationship.status == 1 || relationship.status == 2;
|
||||
final isEstablished = relationship.status != 0;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 16, right: 12),
|
||||
@@ -185,21 +186,36 @@ class RelationshipListTile extends StatelessWidget {
|
||||
itemBuilder: (context) => [
|
||||
if (relationship.status >= 100) // If friend
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
leading: const Icon(Symbols.block),
|
||||
title: Text('blockUser').tr(),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onTap: () => onUpdateStatus?.call(relationship, -100),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.block),
|
||||
const Gap(12),
|
||||
Text('blockUser').tr(),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (relationship.status <= -100) // If blocked
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
leading: const Icon(Symbols.person_add),
|
||||
title: Text('unblockUser').tr(),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onTap: () => onUpdateStatus?.call(relationship, 100),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.person_add),
|
||||
const Gap(12),
|
||||
Text('unblockUser').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onDelete != null)
|
||||
PopupMenuItem(
|
||||
onTap: () => onDelete?.call(relationship),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.delete),
|
||||
const Gap(12),
|
||||
Text('forgotRelationship').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -230,40 +246,11 @@ class RelationshipScreen extends HookConsumerWidget {
|
||||
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post('/pass/relationships/${result.id}/friends');
|
||||
ref.invalidate(sentFriendRequestProvider);
|
||||
ref.invalidate(friendRequestProvider);
|
||||
}
|
||||
|
||||
final submitting = useState(false);
|
||||
|
||||
Future<void> handleFriendRequest(
|
||||
SnRelationship relationship,
|
||||
bool isAccept,
|
||||
) async {
|
||||
try {
|
||||
submitting.value = true;
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post(
|
||||
'/pass/relationships/${relationship.accountId}/friends/${isAccept ? 'accept' : 'decline'}',
|
||||
);
|
||||
relationshipNotifier.refresh();
|
||||
if (!context.mounted) return;
|
||||
if (isAccept) {
|
||||
showSnackBar(
|
||||
'friendRequestAccepted'.tr(args: ['@${relationship.account.name}']),
|
||||
);
|
||||
} else {
|
||||
showSnackBar(
|
||||
'friendRequestDeclined'.tr(args: ['@${relationship.account.name}']),
|
||||
);
|
||||
}
|
||||
HapticFeedback.lightImpact();
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateRelationship(
|
||||
SnRelationship relationship,
|
||||
int newStatus,
|
||||
@@ -276,8 +263,32 @@ class RelationshipScreen extends HookConsumerWidget {
|
||||
relationshipNotifier.refresh();
|
||||
}
|
||||
|
||||
Future<void> deleteRelationship(SnRelationship relationship) async {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'forgotRelationshipConfirm'.tr(
|
||||
args: ['@${relationship.related!.name}'],
|
||||
),
|
||||
'forgotRelationship'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
if (!context.mounted) return;
|
||||
showLoadingModal(context);
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/pass/relationships/${relationship.relatedId}');
|
||||
relationshipNotifier.refresh();
|
||||
showSnackBar('relationshipDeleted'.tr());
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
|
||||
final user = ref.watch(userInfoProvider);
|
||||
final requests = ref.watch(sentFriendRequestProvider);
|
||||
final requests = ref.watch(friendRequestProvider);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: Text('relationships').tr()),
|
||||
@@ -293,9 +304,9 @@ class RelationshipScreen extends HookConsumerWidget {
|
||||
if (requests.hasValue && requests.value!.isNotEmpty)
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.send),
|
||||
title: Text('friendSentRequest').tr(),
|
||||
title: Text('friendRequests').tr(),
|
||||
subtitle: Text(
|
||||
'friendSentRequestHint'.plural(requests.value!.length),
|
||||
'friendRequestsHint'.plural(requests.value!.length),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: () {
|
||||
@@ -316,11 +327,10 @@ class RelationshipScreen extends HookConsumerWidget {
|
||||
return RelationshipListTile(
|
||||
relationship: relationship,
|
||||
submitting: submitting.value,
|
||||
onAccept: () => handleFriendRequest(relationship, true),
|
||||
onDecline: () => handleFriendRequest(relationship, false),
|
||||
currentUserId: user.value?.id,
|
||||
showRelatedAccount: false,
|
||||
showRelatedAccount: true,
|
||||
onUpdateStatus: updateRelationship,
|
||||
onDelete: deleteRelationship,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -336,19 +346,54 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final requests = ref.watch(sentFriendRequestProvider);
|
||||
final requests = ref.watch(friendRequestProvider);
|
||||
final user = ref.watch(userInfoProvider);
|
||||
|
||||
Future<void> cancelRequest(SnRelationship request) async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/pass/relationships/${request.relatedId}/friends');
|
||||
ref.invalidate(sentFriendRequestProvider);
|
||||
ref.invalidate(friendRequestProvider);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
final submitting = useState(false);
|
||||
|
||||
Future<void> handleFriendRequest(
|
||||
SnRelationship relationship,
|
||||
bool isAccept,
|
||||
) async {
|
||||
try {
|
||||
submitting.value = true;
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post(
|
||||
'/pass/relationships/${relationship.accountId}/friends/${isAccept ? 'accept' : 'decline'}',
|
||||
);
|
||||
ref.invalidate(friendRequestProvider);
|
||||
if (!context.mounted) return;
|
||||
if (isAccept) {
|
||||
showSnackBar(
|
||||
'friendRequestAccepted'.tr(
|
||||
args: ['@${relationship.account!.name}'],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showSnackBar(
|
||||
'friendRequestDeclined'.tr(
|
||||
args: ['@${relationship.account!.name}'],
|
||||
),
|
||||
);
|
||||
}
|
||||
HapticFeedback.lightImpact();
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||
@@ -366,7 +411,7 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'friendSentRequest'.tr(),
|
||||
'friendRequests'.tr(),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
@@ -377,7 +422,7 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
|
||||
icon: const Icon(Symbols.refresh),
|
||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
||||
onPressed: () {
|
||||
ref.invalidate(sentFriendRequestProvider);
|
||||
ref.invalidate(friendRequestProvider);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -394,7 +439,7 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
|
||||
data: (items) => items.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'friendSentRequestEmpty'.tr(),
|
||||
'friendRequestsEmpty'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
@@ -406,6 +451,8 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
|
||||
return RelationshipListTile(
|
||||
relationship: request,
|
||||
onCancel: () => cancelRequest(request),
|
||||
onAccept: () => handleFriendRequest(request, true),
|
||||
onDecline: () => handleFriendRequest(request, false),
|
||||
currentUserId: user.value?.id,
|
||||
showRelatedAccount: true,
|
||||
);
|
||||
|
||||
@@ -9,10 +9,10 @@ part of 'relationship.dart';
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(sentFriendRequest)
|
||||
final sentFriendRequestProvider = SentFriendRequestProvider._();
|
||||
@ProviderFor(friendRequest)
|
||||
final friendRequestProvider = FriendRequestProvider._();
|
||||
|
||||
final class SentFriendRequestProvider
|
||||
final class FriendRequestProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnRelationship>>,
|
||||
@@ -22,19 +22,19 @@ final class SentFriendRequestProvider
|
||||
with
|
||||
$FutureModifier<List<SnRelationship>>,
|
||||
$FutureProvider<List<SnRelationship>> {
|
||||
SentFriendRequestProvider._()
|
||||
FriendRequestProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'sentFriendRequestProvider',
|
||||
name: r'friendRequestProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$sentFriendRequestHash();
|
||||
String debugGetCreateSourceHash() => _$friendRequestHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
@@ -44,8 +44,8 @@ final class SentFriendRequestProvider
|
||||
|
||||
@override
|
||||
FutureOr<List<SnRelationship>> create(Ref ref) {
|
||||
return sentFriendRequest(ref);
|
||||
return friendRequest(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$sentFriendRequestHash() => r'0c52813eb6f86c05f6e0b1e4e840d0d9c350aa9e';
|
||||
String _$friendRequestHash() => r'b066553f9bd0505a7a272cf10cb3ca312152acd6';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/widgets/chat/message_item.dart';
|
||||
|
||||
// Provider to track animated messages to prevent replay
|
||||
final animatedMessagesProvider =
|
||||
NotifierProvider<AnimatedMessagesNotifier, Set<String>>(
|
||||
final animatedMessagesProvider = NotifierProvider.autoDispose(
|
||||
AnimatedMessagesNotifier.new,
|
||||
);
|
||||
);
|
||||
|
||||
class AnimatedMessagesNotifier extends Notifier<Set<String>> {
|
||||
@override
|
||||
@@ -22,7 +22,7 @@ class AnimatedMessagesNotifier extends Notifier<Set<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
class MessageItemWrapper extends ConsumerWidget {
|
||||
class MessageItemWrapper extends HookConsumerWidget {
|
||||
final LocalChatMessage message;
|
||||
final int index;
|
||||
final bool isLastInGroup;
|
||||
@@ -54,51 +54,6 @@ class MessageItemWrapper extends ConsumerWidget {
|
||||
required this.roomOpenTime,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Animation logic
|
||||
final animatedMessages = ref.watch(animatedMessagesProvider);
|
||||
final isNewMessage = message.createdAt.isAfter(roomOpenTime);
|
||||
final hasAnimated = animatedMessages.contains(message.id);
|
||||
|
||||
// Only animate if:
|
||||
// 1. Animation is enabled
|
||||
// 2. Message is new (created after room open)
|
||||
// 3. Has not animated yet
|
||||
final shouldAnimate = !disableAnimation && isNewMessage && !hasAnimated;
|
||||
|
||||
final child = chatIdentity.when(
|
||||
skipError: true,
|
||||
data: (identity) => _buildContent(context, identity),
|
||||
loading: () => _buildLoading(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
|
||||
if (!shouldAnimate) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return TweenAnimationBuilder<double>(
|
||||
key: ValueKey('anim-${message.id}'), // Ensure unique key for animation
|
||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||
duration: Duration(milliseconds: 400 + (index % 5) * 50),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, 20 * (1 - value)),
|
||||
child: Opacity(opacity: value, child: child),
|
||||
);
|
||||
},
|
||||
onEnd: () {
|
||||
// Mark as animated
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(animatedMessagesProvider.notifier).addMessage(message.id);
|
||||
});
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, SnChatMember? identity) {
|
||||
final isSelected = selectedMessages.contains(message.id);
|
||||
final isCurrentUser = identity?.id == message.senderId;
|
||||
@@ -116,11 +71,8 @@ class MessageItemWrapper extends ConsumerWidget {
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
color:
|
||||
isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withOpacity(0.3)
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3)
|
||||
: null,
|
||||
child: Stack(
|
||||
children: [
|
||||
@@ -130,8 +82,7 @@ class MessageItemWrapper extends ConsumerWidget {
|
||||
key: ValueKey('item-${message.id}'),
|
||||
message: message,
|
||||
isCurrentUser: isCurrentUser,
|
||||
onAction:
|
||||
isSelectionMode
|
||||
onAction: isSelectionMode
|
||||
? null
|
||||
: (action) => onMessageAction(action, message),
|
||||
onJump: onJump,
|
||||
@@ -178,4 +129,88 @@ class MessageItemWrapper extends ConsumerWidget {
|
||||
onJump: (_) {},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Animation logic
|
||||
final animatedMessages = ref.watch(animatedMessagesProvider);
|
||||
final isNewMessage = message.createdAt.isAfter(roomOpenTime);
|
||||
final hasAnimated = animatedMessages.contains(message.id);
|
||||
|
||||
// Only animate if:
|
||||
// 1. Animation is enabled
|
||||
// 2. Message is new (created after room open)
|
||||
// 3. Has not animated yet
|
||||
final shouldAnimate = !disableAnimation && isNewMessage && !hasAnimated;
|
||||
|
||||
final child = chatIdentity.when(
|
||||
skipError: true,
|
||||
data: (identity) => _buildContent(context, identity),
|
||||
loading: () => _buildLoading(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
|
||||
final controller = useAnimationController(
|
||||
duration: Duration(milliseconds: 400 + (index % 5) * 50),
|
||||
);
|
||||
|
||||
final hasStarted = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
if (shouldAnimate && !hasStarted.value) {
|
||||
hasStarted.value = true;
|
||||
controller.forward().then((_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(animatedMessagesProvider.notifier).addMessage(message.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [shouldAnimate]);
|
||||
|
||||
if (!shouldAnimate) {
|
||||
return child;
|
||||
}
|
||||
|
||||
final curvedAnimation = useMemoized(
|
||||
() => CurvedAnimation(parent: controller, curve: Curves.easeOutQuart),
|
||||
[controller],
|
||||
);
|
||||
|
||||
final sizeAnimation = useMemoized(
|
||||
() => Tween<double>(begin: 0.0, end: 1.0).animate(curvedAnimation),
|
||||
[curvedAnimation],
|
||||
);
|
||||
|
||||
final slideAnimation = useMemoized(
|
||||
() => Tween<Offset>(
|
||||
begin: const Offset(0, 0.12),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
[curvedAnimation],
|
||||
);
|
||||
|
||||
final fadeAnimation = useMemoized(
|
||||
() => Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: const Interval(0.1, 1.0, curve: Curves.easeOut),
|
||||
),
|
||||
),
|
||||
[controller],
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) => FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: SizeTransition(
|
||||
axis: Axis.vertical,
|
||||
sizeFactor: sizeAnimation,
|
||||
child: SlideTransition(position: slideAnimation, child: child),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +105,9 @@ class WebfeedForm extends HookConsumerWidget {
|
||||
|
||||
return feedAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, _) => ResponseErrorWidget(
|
||||
error: (error, _) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry:
|
||||
() => ref.invalidate(
|
||||
onRetry: () => ref.invalidate(
|
||||
webFeedNotifierProvider((pubName: pubName, feedId: feedId)),
|
||||
),
|
||||
),
|
||||
@@ -140,7 +138,7 @@ class WebfeedForm extends HookConsumerWidget {
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
if (context.mounted) isLoading.value = false;
|
||||
}
|
||||
}, [pubName, feedId, ref, context, isLoading]);
|
||||
|
||||
@@ -160,8 +158,8 @@ class WebfeedForm extends HookConsumerWidget {
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
@@ -184,8 +182,8 @@ class WebfeedForm extends HookConsumerWidget {
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
@@ -197,8 +195,8 @@ class WebfeedForm extends HookConsumerWidget {
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
),
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -256,7 +254,12 @@ class WebfeedForm extends HookConsumerWidget {
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 12);
|
||||
|
||||
return Column(children: [Expanded(child: formWidget), buttonsRow]);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: formWidget),
|
||||
buttonsRow,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ class _DashboardGridNarrow extends HookConsumerWidget {
|
||||
if (userInfo.value != null && userInfo.value?.activatedAt == null)
|
||||
AccountUnactivatedCard(),
|
||||
CheckInWidget(margin: EdgeInsets.zero),
|
||||
FortuneCard(),
|
||||
FortuneCard(unlimited: true),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 400),
|
||||
child: PostFeaturedList(),
|
||||
@@ -304,7 +304,9 @@ class ClockCard extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
spacing: 5,
|
||||
children: [
|
||||
notableDay.when(
|
||||
@@ -318,6 +320,7 @@ class ClockCard extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -528,13 +531,14 @@ class ChatListCard extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
class FortuneCard extends HookConsumerWidget {
|
||||
const FortuneCard({super.key});
|
||||
final bool unlimited;
|
||||
const FortuneCard({super.key, this.unlimited = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fortuneAsync = ref.watch(randomFortuneSayingProvider);
|
||||
|
||||
return Card(
|
||||
final child = Card(
|
||||
margin: EdgeInsets.zero,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
@@ -550,16 +554,19 @@ class FortuneCard extends HookConsumerWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
fortune.content,
|
||||
maxLines: 2,
|
||||
maxLines: unlimited ? null : 2,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
),
|
||||
Text('—— ${fortune.source}').bold(),
|
||||
],
|
||||
).padding(horizontal: 16);
|
||||
).padding(horizontal: 16, vertical: unlimited ? 12 : 0);
|
||||
},
|
||||
),
|
||||
).height(48);
|
||||
);
|
||||
|
||||
if (unlimited) return child;
|
||||
return child.height(48);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,6 +589,7 @@ class _UnauthorizedCard extends HookConsumerWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Gap(16),
|
||||
const SizedBox(width: double.infinity),
|
||||
Icon(
|
||||
Symbols.dashboard_rounded,
|
||||
size: 64,
|
||||
|
||||
@@ -317,7 +317,7 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
// Sliver list cannot provide refresh handled by the pagination list
|
||||
isRefreshable: false,
|
||||
isSliver: true,
|
||||
footerSkeletonChild: const PostItemSkeleton(),
|
||||
footerSkeletonChild: const PostItemSkeleton(maxWidth: double.infinity),
|
||||
contentBuilder: (data, footer) =>
|
||||
_ActivityListView(data: data, isWide: isWide, footer: footer),
|
||||
);
|
||||
|
||||
@@ -2,19 +2,14 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/drive/file_references.dart';
|
||||
import 'package:island/services/file_download.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||
import 'package:island/widgets/content/file_viewer_contents.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class FileDetailScreen extends HookConsumerWidget {
|
||||
final SnCloudFile item;
|
||||
@@ -165,22 +160,6 @@ class FileDetailScreen extends HookConsumerWidget {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add references button
|
||||
actions.add(
|
||||
IconButton(
|
||||
icon: Icon(Icons.link),
|
||||
onPressed: () => showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => SheetScaffold(
|
||||
titleText: 'File References',
|
||||
child: ReferencesList(fileId: item.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Always add info button
|
||||
actions.add(
|
||||
IconButton(icon: Icon(Icons.info_outline), onPressed: showInfoSheet),
|
||||
@@ -206,50 +185,3 @@ class FileDetailScreen extends HookConsumerWidget {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ReferencesList extends ConsumerWidget {
|
||||
const ReferencesList({super.key, required this.fileId});
|
||||
|
||||
final String fileId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncReferences = ref.watch(fileReferencesProvider(fileId));
|
||||
|
||||
return asyncReferences.when(
|
||||
data: (references) => ListView.builder(
|
||||
itemCount: references.length,
|
||||
itemBuilder: (context, index) {
|
||||
final reference = references[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.link),
|
||||
title: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text(
|
||||
reference.usage,
|
||||
style: GoogleFonts.robotoMono(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
Text(reference.id, style: GoogleFonts.robotoMono(fontSize: 13)),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(reference.createdAt.formatRelative(context)),
|
||||
const VerticalDivider(width: 1, thickness: 1).height(12),
|
||||
Text(reference.createdAt.formatSystem()),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Error loading references: $error')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,11 @@ 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/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_attachments.dart';
|
||||
import 'package:island/widgets/post/compose_settings_sheet.dart';
|
||||
import 'package:island/widgets/post/compose_toolbar.dart';
|
||||
import 'package:island/widgets/post/publishers_modal.dart';
|
||||
@@ -88,9 +87,6 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
}, [state]);
|
||||
|
||||
final showPreview = useState(false);
|
||||
final isAttachmentsExpanded = useState(
|
||||
true,
|
||||
); // New state for attachments section
|
||||
|
||||
// Initialize publisher once when data is available
|
||||
useEffect(() {
|
||||
@@ -274,110 +270,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
),
|
||||
|
||||
// Attachments preview
|
||||
ValueListenableBuilder<List<UniversalFile>>(
|
||||
valueListenable: state.attachments,
|
||||
builder: (context, attachments, _) {
|
||||
if (attachments.isEmpty) return const SizedBox.shrink();
|
||||
return Theme(
|
||||
data: Theme.of(
|
||||
context,
|
||||
).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: isAttachmentsExpanded.value,
|
||||
onExpansionChanged: (expanded) {
|
||||
isAttachmentsExpanded.value = expanded;
|
||||
},
|
||||
collapsedBackgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('attachments').tr(),
|
||||
Text(
|
||||
'articleAttachmentHint'.tr(),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
ValueListenableBuilder<Map<int, double?>>(
|
||||
valueListenable: state.attachmentProgress,
|
||||
builder: (context, progressMap, _) {
|
||||
return Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (
|
||||
var idx = 0;
|
||||
idx < attachments.length;
|
||||
idx++
|
||||
)
|
||||
SizedBox(
|
||||
width: 180,
|
||||
height: 180,
|
||||
child: AttachmentPreview(
|
||||
isCompact: true,
|
||||
item: attachments[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,
|
||||
);
|
||||
}
|
||||
},
|
||||
onUpdate: (value) =>
|
||||
ComposeLogic.updateAttachment(
|
||||
state,
|
||||
value,
|
||||
idx,
|
||||
),
|
||||
onDelete: () =>
|
||||
ComposeLogic.deleteAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
),
|
||||
onInsert: () =>
|
||||
ComposeLogic.insertAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
Gap(16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ArticleComposeAttachments(state: state),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,8 +9,10 @@ import 'package:island/models/post.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/screens/posts/compose.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/cloud_file_collection.dart';
|
||||
import 'package:island/widgets/extended_refresh_indicator.dart';
|
||||
import 'package:island/widgets/post/post_award_sheet.dart';
|
||||
import 'package:island/widgets/post/post_item.dart';
|
||||
@@ -19,6 +21,7 @@ import 'package:island/widgets/post/post_pin_sheet.dart';
|
||||
import 'package:island/widgets/post/post_quick_reply.dart';
|
||||
import 'package:island/widgets/post/compose_sheet.dart';
|
||||
import 'package:island/widgets/post/post_replies.dart';
|
||||
import 'package:island/widgets/post/post_shared.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:island/utils/share_utils.dart';
|
||||
import 'package:island/widgets/safety/abuse_report_helper.dart';
|
||||
@@ -62,6 +65,10 @@ class PostState extends Notifier<AsyncValue<SnPost?>> {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isMediaPost(SnPost? post) {
|
||||
return post != null && post.type == 0 && post.attachments.isNotEmpty;
|
||||
}
|
||||
|
||||
class PostActionButtons extends HookConsumerWidget {
|
||||
final SnPost post;
|
||||
final EdgeInsets renderingPadding;
|
||||
@@ -435,6 +442,110 @@ class PostActionButtons extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PostDetailLargeScreenLayout extends StatelessWidget {
|
||||
final SnPost post;
|
||||
final WidgetRef ref;
|
||||
final String postId;
|
||||
final Function(SnPost) onUpdate;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
const _PostDetailLargeScreenLayout({
|
||||
required this.post,
|
||||
required this.ref,
|
||||
required this.postId,
|
||||
required this.onUpdate,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(userInfoProvider);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: CloudFileList(
|
||||
files: post.attachments,
|
||||
disableConstraint: true,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
elevation: 8,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PostHeader(
|
||||
item: post,
|
||||
isFullPost: true,
|
||||
isCompact: false,
|
||||
renderingPadding: EdgeInsets.zero,
|
||||
),
|
||||
const Gap(8),
|
||||
PostBody(
|
||||
item: post,
|
||||
isFullPost: true,
|
||||
isTextSelectable: true,
|
||||
renderingPadding: EdgeInsets.zero,
|
||||
hideAttachments: true,
|
||||
textScale: post.type == 1 ? 1.2 : 1.1,
|
||||
),
|
||||
const Gap(12),
|
||||
PostActionButtons(
|
||||
post: post,
|
||||
renderingPadding: EdgeInsets.zero,
|
||||
onRefresh: onRefresh,
|
||||
onUpdate: onUpdate,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
PostRepliesList(postId: postId, maxWidth: 800),
|
||||
SliverGap(MediaQuery.of(context).padding.bottom + 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (user.value != null)
|
||||
Positioned(
|
||||
bottom: 16 + MediaQuery.of(context).padding.bottom,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 800),
|
||||
child: PostQuickReply(
|
||||
parent: post,
|
||||
onPosted: () {
|
||||
ref.read(postRepliesProvider(postId).notifier).refresh();
|
||||
},
|
||||
),
|
||||
).center(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PostDetailScreen extends HookConsumerWidget {
|
||||
final String id;
|
||||
const PostDetailScreen({super.key, required this.id});
|
||||
@@ -452,6 +563,8 @@ class PostDetailScreen extends HookConsumerWidget {
|
||||
),
|
||||
body: postState.when(
|
||||
data: (post) {
|
||||
final isMediaPostLayout = isWideScreen(context) && _isMediaPost(post);
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -460,7 +573,22 @@ class PostDetailScreen extends HookConsumerWidget {
|
||||
ref.invalidate(postProvider(id));
|
||||
ref.read(postRepliesProvider(id).notifier).refresh();
|
||||
},
|
||||
child: CustomScrollView(
|
||||
child: isMediaPostLayout
|
||||
? _PostDetailLargeScreenLayout(
|
||||
post: post!,
|
||||
ref: ref,
|
||||
postId: id,
|
||||
onUpdate: (newItem) {
|
||||
ref
|
||||
.read(postStateProvider(id).notifier)
|
||||
.updatePost(newItem);
|
||||
},
|
||||
onRefresh: () {
|
||||
ref.invalidate(postProvider(id));
|
||||
ref.read(postRepliesProvider(id).notifier).refresh();
|
||||
},
|
||||
)
|
||||
: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
@@ -473,7 +601,6 @@ class PostDetailScreen extends HookConsumerWidget {
|
||||
isEmbedReply: false,
|
||||
textScale: post.type == 1 ? 1.2 : 1.1,
|
||||
onUpdate: (newItem) {
|
||||
// Update the local state with the new post data
|
||||
ref
|
||||
.read(postStateProvider(id).notifier)
|
||||
.updatePost(newItem);
|
||||
@@ -511,13 +638,13 @@ class PostDetailScreen extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (user.value != null)
|
||||
if (user.value != null && !isMediaPostLayout)
|
||||
Positioned(
|
||||
bottom: 16 + MediaQuery.of(context).padding.bottom,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 660),
|
||||
constraints: BoxConstraints(maxWidth: 800),
|
||||
child: postState.when(
|
||||
data: (post) => PostQuickReply(
|
||||
parent: post!,
|
||||
|
||||
@@ -23,6 +23,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/post/post_item.dart';
|
||||
import 'package:island/widgets/activity_heatmap.dart';
|
||||
import 'package:island/widgets/posts/post_filter.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -32,6 +33,113 @@ import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
part 'publisher_profile.g.dart';
|
||||
|
||||
class _PinnedPostsPageView extends HookConsumerWidget {
|
||||
final String pubName;
|
||||
|
||||
const _PinnedPostsPageView({required this.pubName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider = postListProvider(
|
||||
PostListQueryConfig(
|
||||
id: 'publisher-$pubName-pinned',
|
||||
initialFilter: PostListQuery(pubName: pubName, pinned: true),
|
||||
),
|
||||
);
|
||||
final pinnedPosts = ref.watch(provider);
|
||||
final pageController = usePageController();
|
||||
final currentPage = useState(0);
|
||||
|
||||
useEffect(() {
|
||||
void listener() {
|
||||
currentPage.value = pageController.page?.round() ?? 0;
|
||||
}
|
||||
|
||||
pageController.addListener(listener);
|
||||
return () => pageController.removeListener(listener);
|
||||
}, [pageController]);
|
||||
|
||||
return pinnedPosts.when(
|
||||
data: (data) {
|
||||
if (data.items.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: true,
|
||||
leading: const Icon(Symbols.push_pin),
|
||||
title: Text('pinnedPosts'.tr()),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
collapsedShape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 400,
|
||||
child: Stack(
|
||||
children: [
|
||||
PageView.builder(
|
||||
controller: pageController,
|
||||
itemCount: data.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: SingleChildScrollView(
|
||||
child: Card(
|
||||
child: PostActionableItem(
|
||||
item: data.items[index],
|
||||
borderRadius: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
data.items.length,
|
||||
(index) => AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: EdgeInsets.symmetric(horizontal: 4),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: index == currentPage.value
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PublisherBasisWidget extends StatelessWidget {
|
||||
final SnPublisher data;
|
||||
final AsyncValue<SnPublisherSubscription?> subStatus;
|
||||
@@ -431,7 +539,6 @@ class PublisherProfileScreen extends HookConsumerWidget {
|
||||
final queryState = useState(PostListQuery(pubName: name));
|
||||
|
||||
final subscribing = useState(false);
|
||||
final isPinnedExpanded = useState(true);
|
||||
|
||||
useEffect(() {
|
||||
final index = switch (queryState.value.type) {
|
||||
@@ -510,36 +617,8 @@ class PublisherProfileScreen extends HookConsumerWidget {
|
||||
slivers: [
|
||||
SliverGap(16),
|
||||
SliverToBoxAdapter(
|
||||
child: Card(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
child: _PinnedPostsPageView(pubName: name),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text('pinnedPosts'.tr()),
|
||||
leading: const Icon(Symbols.push_pin),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
isPinnedExpanded.value
|
||||
? Symbols.expand_less
|
||||
: Symbols.expand_more,
|
||||
),
|
||||
onTap: () => isPinnedExpanded.value =
|
||||
!isPinnedExpanded.value,
|
||||
),
|
||||
),
|
||||
),
|
||||
...[
|
||||
if (isPinnedExpanded.value)
|
||||
SliverPostList(
|
||||
query: PostListQuery(pubName: name, pinned: true),
|
||||
queryKey: 'publisher-$name-pinned',
|
||||
),
|
||||
],
|
||||
SliverToBoxAdapter(
|
||||
child: PostFilterWidget(
|
||||
categoryTabController: categoryTabController,
|
||||
@@ -644,27 +723,8 @@ class PublisherProfileScreen extends HookConsumerWidget {
|
||||
).padding(vertical: 4),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: ListTile(
|
||||
title: Text('pinnedPosts'.tr()),
|
||||
trailing: Icon(
|
||||
isPinnedExpanded.value
|
||||
? Symbols.expand_less
|
||||
: Symbols.expand_more,
|
||||
child: _PinnedPostsPageView(pubName: name),
|
||||
),
|
||||
onTap: () =>
|
||||
isPinnedExpanded.value = !isPinnedExpanded.value,
|
||||
),
|
||||
),
|
||||
),
|
||||
...[
|
||||
if (isPinnedExpanded.value)
|
||||
SliverPostList(
|
||||
query: PostListQuery(pubName: name, pinned: true),
|
||||
queryKey: 'publisher-$name-pinned',
|
||||
),
|
||||
],
|
||||
SliverToBoxAdapter(
|
||||
child: PostFilterWidget(
|
||||
categoryTabController: categoryTabController,
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/account/account_pfc.dart';
|
||||
import 'package:island/widgets/account/status.dart';
|
||||
import 'package:island/widgets/post/post_list.dart';
|
||||
import 'package:island/widgets/post/post_item.dart';
|
||||
import 'package:island/services/color_extraction.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
@@ -27,6 +28,113 @@ import 'package:island/pods/paging.dart';
|
||||
import 'package:island/widgets/paging/pagination_list.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class _RealmPinnedPostsPageView extends HookConsumerWidget {
|
||||
final String realmSlug;
|
||||
|
||||
const _RealmPinnedPostsPageView({required this.realmSlug});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider = postListProvider(
|
||||
PostListQueryConfig(
|
||||
id: 'realm-$realmSlug-pinned',
|
||||
initialFilter: PostListQuery(realm: realmSlug, pinned: true),
|
||||
),
|
||||
);
|
||||
final pinnedPosts = ref.watch(provider);
|
||||
final pageController = usePageController();
|
||||
final currentPage = useState(0);
|
||||
|
||||
useEffect(() {
|
||||
void listener() {
|
||||
currentPage.value = pageController.page?.round() ?? 0;
|
||||
}
|
||||
|
||||
pageController.addListener(listener);
|
||||
return () => pageController.removeListener(listener);
|
||||
}, [pageController]);
|
||||
|
||||
return pinnedPosts.when(
|
||||
data: (data) {
|
||||
if (data.items.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: true,
|
||||
leading: const Icon(Symbols.push_pin),
|
||||
title: Text('pinnedPosts'.tr()),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
collapsedShape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 400,
|
||||
child: Stack(
|
||||
children: [
|
||||
PageView.builder(
|
||||
controller: pageController,
|
||||
itemCount: data.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: SingleChildScrollView(
|
||||
child: Card(
|
||||
child: PostActionableItem(
|
||||
item: data.items[index],
|
||||
borderRadius: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
data.items.length,
|
||||
(index) => AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: EdgeInsets.symmetric(horizontal: 4),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: index == currentPage.value
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final realmAppbarForegroundColorProvider = FutureProvider.autoDispose
|
||||
.family<Color?, String>((ref, realmSlug) async {
|
||||
final realm = await ref.watch(realmProvider(realmSlug).future);
|
||||
@@ -235,8 +343,8 @@ class RealmDetailScreen extends HookConsumerWidget {
|
||||
flex: 3,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverPostList(
|
||||
query: PostListQuery(realm: slug, pinned: true),
|
||||
SliverToBoxAdapter(
|
||||
child: _RealmPinnedPostsPageView(realmSlug: slug),
|
||||
),
|
||||
SliverPostList(
|
||||
query: PostListQuery(realm: slug, pinned: false),
|
||||
@@ -338,8 +446,8 @@ class RealmDetailScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: realmChatRoomListWidget(realm)),
|
||||
SliverPostList(
|
||||
query: PostListQuery(realm: slug, pinned: true),
|
||||
SliverToBoxAdapter(
|
||||
child: _RealmPinnedPostsPageView(realmSlug: slug),
|
||||
),
|
||||
SliverPostList(
|
||||
query: PostListQuery(realm: slug, pinned: false),
|
||||
|
||||
@@ -167,7 +167,9 @@ class _PostsSearchTab extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
child: const PostItemSkeleton(),
|
||||
child: const PostItemSkeleton(
|
||||
maxWidth: double.infinity,
|
||||
),
|
||||
),
|
||||
itemBuilder: (context, index, post) {
|
||||
return Card(
|
||||
@@ -320,7 +322,7 @@ class _PostsSearchTab extends HookConsumerWidget {
|
||||
isRefreshable: false,
|
||||
footerSkeletonChild: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: const PostItemSkeleton(),
|
||||
child: const PostItemSkeleton(maxWidth: double.infinity),
|
||||
),
|
||||
itemBuilder: (context, index, post) {
|
||||
return Center(
|
||||
|
||||
536
lib/services/analytics_service.dart
Normal file
536
lib/services/analytics_service.dart
Normal file
@@ -0,0 +1,536 @@
|
||||
import 'dart:io';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
class AnalyticsService {
|
||||
static final AnalyticsService _instance = AnalyticsService._internal();
|
||||
factory AnalyticsService() => _instance;
|
||||
AnalyticsService._internal();
|
||||
|
||||
FirebaseAnalytics? _analytics;
|
||||
bool _enabled = true;
|
||||
|
||||
bool get _supportsAnalytics =>
|
||||
kIsWeb || (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
|
||||
|
||||
void initialize() {
|
||||
if (!_supportsAnalytics) return;
|
||||
try {
|
||||
_analytics = FirebaseAnalytics.instance;
|
||||
} catch (e) {
|
||||
talker.warning('[Analytics] Failed to init: $e');
|
||||
_analytics = null;
|
||||
}
|
||||
}
|
||||
|
||||
void logEvent(String name, Map<String, Object>? parameters) {
|
||||
if (!_enabled || !_supportsAnalytics) return;
|
||||
final analytics = _analytics;
|
||||
if (analytics == null) return;
|
||||
|
||||
try {
|
||||
analytics.logEvent(name: name, parameters: parameters);
|
||||
} catch (e) {
|
||||
talker.warning('[Analytics] Failed to log event $name: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void setEnabled(bool enabled) {
|
||||
_enabled = enabled;
|
||||
}
|
||||
|
||||
void setUserId(String? id) {
|
||||
if (!_supportsAnalytics) return;
|
||||
final analytics = _analytics;
|
||||
if (analytics == null) return;
|
||||
|
||||
try {
|
||||
analytics.setUserId(id: id);
|
||||
} catch (e) {
|
||||
talker.warning('[Analytics] Failed to set user ID: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void logAppOpen() {
|
||||
logEvent('app_open', null);
|
||||
}
|
||||
|
||||
void logLogin(String authMethod) {
|
||||
logEvent('login', {'auth_method': authMethod, 'platform': _getPlatform()});
|
||||
}
|
||||
|
||||
void logLogout() {
|
||||
logEvent('logout', null);
|
||||
}
|
||||
|
||||
void logPostViewed(String postId, String postType, String viewSource) {
|
||||
logEvent('post_viewed', {
|
||||
'post_id': postId,
|
||||
'post_type': postType,
|
||||
'view_source': viewSource,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostCreated(
|
||||
String postType,
|
||||
String visibility,
|
||||
bool hasAttachments,
|
||||
String publisherId,
|
||||
) {
|
||||
logEvent('post_created', {
|
||||
'post_type': postType,
|
||||
'visibility': visibility,
|
||||
'has_attachments': hasAttachments ? 'yes' : 'no',
|
||||
'publisher_id': publisherId,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostReacted(
|
||||
String postId,
|
||||
String reactionSymbol,
|
||||
int attitude,
|
||||
bool isRemoving,
|
||||
) {
|
||||
logEvent('post_reacted', {
|
||||
'post_id': postId,
|
||||
'reaction_symbol': reactionSymbol,
|
||||
'attitude': attitude,
|
||||
'is_removing': isRemoving ? 'yes' : 'no',
|
||||
});
|
||||
}
|
||||
|
||||
void logPostReplied(
|
||||
String postId,
|
||||
String parentId,
|
||||
int characterCount,
|
||||
bool hasAttachments,
|
||||
) {
|
||||
logEvent('post_replied', {
|
||||
'post_id': postId,
|
||||
'parent_id': parentId,
|
||||
'character_count': characterCount,
|
||||
'has_attachments': hasAttachments,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostShared(String postId, String shareMethod, String postType) {
|
||||
logEvent('post_shared', {
|
||||
'post_id': postId,
|
||||
'share_method': shareMethod,
|
||||
'post_type': postType,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostEdited(String postId, int contentChangeDelta) {
|
||||
logEvent('post_edited', {
|
||||
'post_id': postId,
|
||||
'content_change_delta': contentChangeDelta,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostDeleted(String postId, String postType, int timeSinceCreation) {
|
||||
logEvent('post_deleted', {
|
||||
'post_id': postId,
|
||||
'post_type': postType,
|
||||
'time_since_creation': timeSinceCreation,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostPinned(String postId, String pinMode, String realmId) {
|
||||
logEvent('post_pinned', {
|
||||
'post_id': postId,
|
||||
'pin_mode': pinMode,
|
||||
'realm_id': realmId,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostAwarded(
|
||||
String postId,
|
||||
double amount,
|
||||
String attitude,
|
||||
bool hasMessage,
|
||||
) {
|
||||
logEvent('post_awarded', {
|
||||
'post_id': postId,
|
||||
'amount': amount,
|
||||
'attitude': attitude,
|
||||
'has_message': hasMessage ? 'yes' : 'no',
|
||||
'currency': 'NSP',
|
||||
});
|
||||
}
|
||||
|
||||
void logPostTranslated(
|
||||
String postId,
|
||||
String sourceLanguage,
|
||||
String targetLanguage,
|
||||
) {
|
||||
logEvent('post_translated', {
|
||||
'post_id': postId,
|
||||
'source_language': sourceLanguage,
|
||||
'target_language': targetLanguage,
|
||||
});
|
||||
}
|
||||
|
||||
void logPostForwarded(
|
||||
String postId,
|
||||
String originalPostId,
|
||||
String publisherId,
|
||||
) {
|
||||
logEvent('post_forwarded', {
|
||||
'post_id': postId,
|
||||
'original_post_id': originalPostId,
|
||||
'publisher_id': publisherId,
|
||||
});
|
||||
}
|
||||
|
||||
void logMessageSent(
|
||||
String channelId,
|
||||
String messageType,
|
||||
bool hasAttachments,
|
||||
int attachmentCount,
|
||||
) {
|
||||
logEvent('message_sent', {
|
||||
'channel_id': channelId,
|
||||
'message_type': messageType,
|
||||
'has_attachments': hasAttachments,
|
||||
'attachment_count': attachmentCount,
|
||||
});
|
||||
}
|
||||
|
||||
void logMessageReceived(
|
||||
String channelId,
|
||||
String messageType,
|
||||
bool isMentioned,
|
||||
) {
|
||||
logEvent('message_received', {
|
||||
'channel_id': channelId,
|
||||
'message_type': messageType,
|
||||
'is_mentioned': isMentioned,
|
||||
});
|
||||
}
|
||||
|
||||
void logMessageReplied(
|
||||
String channelId,
|
||||
String originalMessageId,
|
||||
int replyDepth,
|
||||
) {
|
||||
logEvent('message_replied', {
|
||||
'channel_id': channelId,
|
||||
'original_message_id': originalMessageId,
|
||||
'reply_depth': replyDepth,
|
||||
});
|
||||
}
|
||||
|
||||
void logMessageEdited(
|
||||
String channelId,
|
||||
String messageId,
|
||||
int contentChangeDelta,
|
||||
) {
|
||||
logEvent('message_edited', {
|
||||
'channel_id': channelId,
|
||||
'message_id': messageId,
|
||||
'content_change_delta': contentChangeDelta,
|
||||
});
|
||||
}
|
||||
|
||||
void logMessageDeleted(
|
||||
String channelId,
|
||||
String messageId,
|
||||
String messageType,
|
||||
bool isOwn,
|
||||
) {
|
||||
logEvent('message_deleted', {
|
||||
'channel_id': channelId,
|
||||
'message_id': messageId,
|
||||
'message_type': messageType,
|
||||
'is_own': isOwn,
|
||||
});
|
||||
}
|
||||
|
||||
void logChatRoomOpened(String channelId, String roomType) {
|
||||
logEvent('chat_room_opened', {
|
||||
'channel_id': channelId,
|
||||
'room_type': roomType,
|
||||
});
|
||||
}
|
||||
|
||||
void logChatJoined(String channelId, String roomType, bool isPublic) {
|
||||
logEvent('chat_joined', {
|
||||
'channel_id': channelId,
|
||||
'room_type': roomType,
|
||||
'is_public': isPublic,
|
||||
});
|
||||
}
|
||||
|
||||
void logChatLeft(String channelId, String roomType) {
|
||||
logEvent('chat_left', {'channel_id': channelId, 'room_type': roomType});
|
||||
}
|
||||
|
||||
void logChatInvited(String channelId, String invitedUserId) {
|
||||
logEvent('chat_invited', {
|
||||
'channel_id': channelId,
|
||||
'invited_user_id': invitedUserId,
|
||||
});
|
||||
}
|
||||
|
||||
void logFileUploaded(
|
||||
String fileType,
|
||||
String fileSizeCategory,
|
||||
String uploadSource,
|
||||
) {
|
||||
logEvent('file_uploaded', {
|
||||
'file_type': fileType,
|
||||
'file_size_category': fileSizeCategory,
|
||||
'upload_source': uploadSource,
|
||||
});
|
||||
}
|
||||
|
||||
void logFileDownloaded(
|
||||
String fileType,
|
||||
String fileSizeCategory,
|
||||
String downloadMethod,
|
||||
) {
|
||||
logEvent('file_downloaded', {
|
||||
'file_type': fileType,
|
||||
'file_size_category': fileSizeCategory,
|
||||
'download_method': downloadMethod,
|
||||
});
|
||||
}
|
||||
|
||||
void logFileDeleted(
|
||||
String fileType,
|
||||
String fileSizeCategory,
|
||||
String deleteSource,
|
||||
bool isBatchDelete,
|
||||
int batchCount,
|
||||
) {
|
||||
logEvent('file_deleted', {
|
||||
'file_type': fileType,
|
||||
'file_size_category': fileSizeCategory,
|
||||
'delete_source': deleteSource,
|
||||
'is_batch_delete': isBatchDelete,
|
||||
'batch_count': batchCount,
|
||||
});
|
||||
}
|
||||
|
||||
void logStickerUsed(String packId, String stickerSlug, String context) {
|
||||
logEvent('sticker_used', {
|
||||
'pack_id': packId,
|
||||
'sticker_slug': stickerSlug,
|
||||
'context': context,
|
||||
});
|
||||
}
|
||||
|
||||
void logStickerPackAdded(String packId, String packName, int stickerCount) {
|
||||
logEvent('sticker_pack_added', {
|
||||
'pack_id': packId,
|
||||
'pack_name': packName,
|
||||
'sticker_count': stickerCount,
|
||||
});
|
||||
}
|
||||
|
||||
void logStickerPackViewed(String packId, int stickerCount, bool isOwned) {
|
||||
logEvent('sticker_pack_viewed', {
|
||||
'pack_id': packId,
|
||||
'sticker_count': stickerCount,
|
||||
'is_owned': isOwned,
|
||||
});
|
||||
}
|
||||
|
||||
void logSearchPerformed(
|
||||
String searchType,
|
||||
String query,
|
||||
int resultCount,
|
||||
bool hasFilters,
|
||||
) {
|
||||
logEvent('search_performed', {
|
||||
'search_type': searchType,
|
||||
'query': query,
|
||||
'result_count': resultCount,
|
||||
'has_filters': hasFilters,
|
||||
});
|
||||
}
|
||||
|
||||
void logFeedSubscribed(String feedId, String feedUrl, int articleCount) {
|
||||
logEvent('feed_subscribed', {
|
||||
'feed_id': feedId,
|
||||
'feed_url': feedUrl,
|
||||
'article_count': articleCount,
|
||||
});
|
||||
}
|
||||
|
||||
void logFeedUnsubscribed(String feedId, String feedUrl) {
|
||||
logEvent('feed_unsubscribed', {'feed_id': feedId, 'feed_url': feedUrl});
|
||||
}
|
||||
|
||||
void logWalletTransfer(
|
||||
double amount,
|
||||
String currency,
|
||||
String payeeId,
|
||||
bool hasRemark,
|
||||
) {
|
||||
logEvent('wallet_transfer', {
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'payee_id': payeeId,
|
||||
'has_remark': hasRemark,
|
||||
});
|
||||
}
|
||||
|
||||
void logWalletBalanceChecked(List<String> currenciesViewed) {
|
||||
logEvent('wallet_balance_checked', {
|
||||
'currencies_viewed': currenciesViewed.join(','),
|
||||
});
|
||||
}
|
||||
|
||||
void logWalletOpened(String activeTab) {
|
||||
logEvent('wallet_opened', {'active_tab': activeTab});
|
||||
}
|
||||
|
||||
void logRealmJoined(String realmSlug, String realmType) {
|
||||
logEvent('realm_joined', {
|
||||
'realm_slug': realmSlug,
|
||||
'realm_type': realmType,
|
||||
});
|
||||
}
|
||||
|
||||
void logRealmLeft(String realmSlug) {
|
||||
logEvent('realm_left', {'realm_slug': realmSlug});
|
||||
}
|
||||
|
||||
void logFriendAdded(String friendId, String pickerMethod) {
|
||||
logEvent('friend_added', {
|
||||
'friend_id': friendId,
|
||||
'picker_method': pickerMethod,
|
||||
});
|
||||
}
|
||||
|
||||
void logFriendRemoved(String relationshipId, String relationshipType) {
|
||||
logEvent('friend_removed', {
|
||||
'relationship_id': relationshipId,
|
||||
'relationship_type': relationshipType,
|
||||
});
|
||||
}
|
||||
|
||||
void logUserBlocked(String blockedUserId, String previousRelationship) {
|
||||
logEvent('user_blocked', {
|
||||
'blocked_user_id': blockedUserId,
|
||||
'previous_relationship': previousRelationship,
|
||||
});
|
||||
}
|
||||
|
||||
void logUserUnblocked(String unblockedUserId) {
|
||||
logEvent('user_unblocked', {'unblocked_user_id': unblockedUserId});
|
||||
}
|
||||
|
||||
void logFriendRequestAccepted(String requesterId) {
|
||||
logEvent('friend_request_accepted', {'requester_id': requesterId});
|
||||
}
|
||||
|
||||
void logFriendRequestDeclined(String requesterId) {
|
||||
logEvent('friend_request_declined', {'requester_id': requesterId});
|
||||
}
|
||||
|
||||
void logThemeChanged(String oldMode, String newMode) {
|
||||
logEvent('theme_changed', {'old_mode': oldMode, 'new_mode': newMode});
|
||||
}
|
||||
|
||||
void logLanguageChanged(String oldLanguage, String newLanguage) {
|
||||
logEvent('language_changed', {
|
||||
'old_language': oldLanguage,
|
||||
'new_language': newLanguage,
|
||||
});
|
||||
}
|
||||
|
||||
void logAiQuerySent(
|
||||
int messageLength,
|
||||
String contextType,
|
||||
int attachedPostsCount,
|
||||
) {
|
||||
logEvent('ai_query_sent', {
|
||||
'message_length': messageLength,
|
||||
'context_type': contextType,
|
||||
'attached_posts_count': attachedPostsCount,
|
||||
});
|
||||
}
|
||||
|
||||
void logAiResponseReceived(int responseThoughtCount, String sequenceId) {
|
||||
logEvent('ai_response_received', {
|
||||
'response_thought_count': responseThoughtCount,
|
||||
'sequence_id': sequenceId,
|
||||
});
|
||||
}
|
||||
|
||||
void logShuffleViewed(int postIndex, int totalPostsLoaded) {
|
||||
logEvent('shuffle_viewed', {
|
||||
'post_index': postIndex,
|
||||
'total_posts_loaded': totalPostsLoaded,
|
||||
});
|
||||
}
|
||||
|
||||
void logDraftSaved(String draftId, String postType, bool hasContent) {
|
||||
logEvent('draft_saved', {
|
||||
'draft_id': draftId,
|
||||
'post_type': postType,
|
||||
'has_content': hasContent,
|
||||
});
|
||||
}
|
||||
|
||||
void logDraftDeleted(String draftId, String postType) {
|
||||
logEvent('draft_deleted', {'draft_id': draftId, 'post_type': postType});
|
||||
}
|
||||
|
||||
void logCategoryViewed(String categorySlug, String categoryId) {
|
||||
logEvent('category_viewed', {
|
||||
'category_slug': categorySlug,
|
||||
'category_id': categoryId,
|
||||
});
|
||||
}
|
||||
|
||||
void logTagViewed(String tagSlug, String tagId) {
|
||||
logEvent('tag_viewed', {'tag_slug': tagSlug, 'tag_id': tagId});
|
||||
}
|
||||
|
||||
void logCategorySubscribed(String categorySlug, String categoryId) {
|
||||
logEvent('category_subscribed', {
|
||||
'category_slug': categorySlug,
|
||||
'category_id': categoryId,
|
||||
});
|
||||
}
|
||||
|
||||
void logTagSubscribed(String tagSlug, String tagId) {
|
||||
logEvent('tag_subscribed', {'tag_slug': tagSlug, 'tag_id': tagId});
|
||||
}
|
||||
|
||||
void logNotificationViewed() {
|
||||
logEvent('notification_viewed', null);
|
||||
}
|
||||
|
||||
void logNotificationActioned(String actionType, String notificationType) {
|
||||
logEvent('notification_actioned', {
|
||||
'action_type': actionType,
|
||||
'notification_type': notificationType,
|
||||
});
|
||||
}
|
||||
|
||||
void logProfileUpdated(List<String> fieldsUpdated) {
|
||||
logEvent('profile_updated', {'fields_updated': fieldsUpdated.join(',')});
|
||||
}
|
||||
|
||||
void logAvatarChanged(String imageSource, bool isCropped) {
|
||||
logEvent('avatar_changed', {
|
||||
'image_source': imageSource,
|
||||
'is_cropped': isCropped,
|
||||
});
|
||||
}
|
||||
|
||||
String _getPlatform() {
|
||||
if (Platform.isAndroid) return 'android';
|
||||
if (Platform.isIOS) return 'ios';
|
||||
if (Platform.isMacOS) return 'macos';
|
||||
if (Platform.isWindows) return 'windows';
|
||||
if (Platform.isLinux) return 'linux';
|
||||
return 'web';
|
||||
}
|
||||
}
|
||||
1
lib/services/app_intents.dart
Normal file
1
lib/services/app_intents.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'app_intents/ios.dart';
|
||||
778
lib/services/app_intents/ios.dart
Normal file
778
lib/services/app_intents/ios.dart
Normal file
@@ -0,0 +1,778 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_app_intents/flutter_app_intents.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:island/models/auth.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:island/services/event_bus.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/route.dart';
|
||||
|
||||
class AppIntentsService {
|
||||
static final AppIntentsService _instance = AppIntentsService._internal();
|
||||
factory AppIntentsService() => _instance;
|
||||
AppIntentsService._internal();
|
||||
|
||||
FlutterAppIntentsClient? _client;
|
||||
bool _initialized = false;
|
||||
Dio? _dio;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (kIsWeb || !Platform.isIOS) {
|
||||
talker.warning('[AppIntents] App Intents only supported on iOS');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_initialized) {
|
||||
talker.info('[AppIntents] Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
talker.info('[AppIntents] Initializing App Intents client...');
|
||||
_client = FlutterAppIntentsClient.instance;
|
||||
|
||||
// Initialize Dio for API calls
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final serverUrl =
|
||||
prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
|
||||
final tokenString = prefs.getString(kTokenPairStoreKey);
|
||||
|
||||
final headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (tokenString != null) {
|
||||
try {
|
||||
final token = AppToken.fromJson(jsonDecode(tokenString));
|
||||
headers['Authorization'] = 'AtField ${token.token}';
|
||||
} catch (e) {
|
||||
talker.warning('[AppIntents] Failed to parse token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: serverUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: headers,
|
||||
),
|
||||
);
|
||||
|
||||
await _registerIntents();
|
||||
_initialized = true;
|
||||
talker.info('[AppIntents] All intents registered successfully');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Initialization failed', e, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registerIntents() async {
|
||||
if (_client == null) {
|
||||
throw StateError('Client not initialized');
|
||||
}
|
||||
|
||||
// Navigation Intents
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('open_chat')
|
||||
.title('Open Chat')
|
||||
.description('Open a specific chat room')
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'channelId',
|
||||
title: 'Channel ID',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.build(),
|
||||
_handleOpenChatIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('open_post')
|
||||
.title('Open Post')
|
||||
.description('Open a specific post')
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'postId',
|
||||
title: 'Post ID',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.build(),
|
||||
_handleOpenPostIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('open_compose')
|
||||
.title('Open Compose')
|
||||
.description('Open compose post screen')
|
||||
.build(),
|
||||
_handleOpenComposeIntent,
|
||||
);
|
||||
|
||||
// Action Intent
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('compose_post')
|
||||
.title('Compose Post')
|
||||
.description('Create a new post')
|
||||
.build(),
|
||||
_handleComposePostIntent,
|
||||
);
|
||||
|
||||
// Query Intents
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('search_content')
|
||||
.title('Search Content')
|
||||
.description('Search for content')
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'query',
|
||||
title: 'Search Query',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.build(),
|
||||
_handleSearchContentIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('view_notifications')
|
||||
.title('View Notifications')
|
||||
.description('View notifications')
|
||||
.build(),
|
||||
_handleViewNotificationsIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('check_notifications')
|
||||
.title('Check Notifications')
|
||||
.description('Check notification count')
|
||||
.build(),
|
||||
_handleCheckNotificationsIntent,
|
||||
);
|
||||
|
||||
// Message Intents
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('send_message')
|
||||
.title('Send Message')
|
||||
.description('Send a message to a chat channel')
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'channelId',
|
||||
title: 'Channel ID',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'content',
|
||||
title: 'Message Content',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.build(),
|
||||
_handleSendMessageIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('read_messages')
|
||||
.title('Read Messages')
|
||||
.description('Read recent messages from a chat channel')
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'channelId',
|
||||
title: 'Channel ID',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: false,
|
||||
),
|
||||
)
|
||||
.parameter(
|
||||
const AppIntentParameter(
|
||||
name: 'limit',
|
||||
title: 'Number of Messages',
|
||||
type: AppIntentParameterType.string,
|
||||
isOptional: true,
|
||||
),
|
||||
)
|
||||
.build(),
|
||||
_handleReadMessagesIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('check_unread_chats')
|
||||
.title('Check Unread Chats')
|
||||
.description('Check number of unread chat messages')
|
||||
.build(),
|
||||
_handleCheckUnreadChatsIntent,
|
||||
);
|
||||
|
||||
await _client!.registerIntent(
|
||||
AppIntentBuilder()
|
||||
.identifier('mark_notifications_read')
|
||||
.title('Mark Notifications Read')
|
||||
.description('Mark all notifications as read')
|
||||
.build(),
|
||||
_handleMarkNotificationsReadIntent,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_client = null;
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleOpenChatIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
final channelId = parameters['channelId'] as String?;
|
||||
if (channelId == null) {
|
||||
throw ArgumentError('channelId is required');
|
||||
}
|
||||
|
||||
talker.info('[AppIntents] Opening chat: $channelId');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
rootNavigatorKey.currentContext!.push('/chat/$channelId');
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Opening chat $channelId',
|
||||
needsToContinueInApp: true,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to open chat', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to open chat: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleOpenPostIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
final postId = parameters['postId'] as String?;
|
||||
if (postId == null) {
|
||||
throw ArgumentError('postId is required');
|
||||
}
|
||||
|
||||
talker.info('[AppIntents] Opening post: $postId');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
rootNavigatorKey.currentContext!.push('/posts/$postId');
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Opening post $postId',
|
||||
needsToContinueInApp: true,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to open post', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to open post: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleOpenComposeIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Opening compose screen');
|
||||
|
||||
eventBus.fire(ShowComposeSheetEvent());
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Opening compose screen',
|
||||
needsToContinueInApp: true,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to open compose', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to open compose: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleComposePostIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Composing new post');
|
||||
|
||||
eventBus.fire(ShowComposeSheetEvent());
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Opening compose screen',
|
||||
needsToContinueInApp: true,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to compose post', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to compose post: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleSearchContentIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
final query = parameters['query'] as String?;
|
||||
if (query == null) {
|
||||
throw ArgumentError('query is required');
|
||||
}
|
||||
|
||||
talker.info('[AppIntents] Searching for: $query');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
rootNavigatorKey.currentContext!.push('/search?q=$query');
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: 'Searching for "$query"',
|
||||
needsToContinueInApp: true,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to search', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to search: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleViewNotificationsIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Opening notifications');
|
||||
|
||||
if (rootNavigatorKey.currentContext == null) {
|
||||
return AppIntentResult.failed(error: 'App context not available');
|
||||
}
|
||||
|
||||
// Note: You may need to adjust the route based on your actual notifications route
|
||||
// This is a common pattern - check your route.dart for exact path
|
||||
// If you don't have a dedicated notifications route, you might need to add one
|
||||
return AppIntentResult.failed(
|
||||
error: 'Notifications route not implemented',
|
||||
);
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to view notifications', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to view notifications: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleCheckNotificationsIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Checking notifications count');
|
||||
|
||||
if (_dio == null) {
|
||||
return AppIntentResult.failed(error: 'API client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio!.get('/ring/notifications/count');
|
||||
final count = (response.data as num).toInt();
|
||||
final countValue = count;
|
||||
|
||||
String message;
|
||||
if (countValue == 0) {
|
||||
message = 'You have no new notifications';
|
||||
} else if (countValue == 1) {
|
||||
message = 'You have 1 new notification';
|
||||
} else {
|
||||
message = 'You have $countValue new notifications';
|
||||
}
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: message,
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
talker.error('[AppIntents] API error checking notifications', e);
|
||||
return AppIntentResult.failed(
|
||||
error:
|
||||
'Failed to fetch notifications: ${e.message ?? 'Network error'}',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to check notifications', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to check notifications: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleSendMessageIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
final channelId = parameters['channelId'] as String?;
|
||||
final content = parameters['content'] as String?;
|
||||
|
||||
if (channelId == null) {
|
||||
throw ArgumentError('channelId is required');
|
||||
}
|
||||
if (content == null || content.isEmpty) {
|
||||
throw ArgumentError('content is required');
|
||||
}
|
||||
|
||||
talker.info('[AppIntents] Sending message to $channelId: $content');
|
||||
|
||||
if (_dio == null) {
|
||||
return AppIntentResult.failed(error: 'API client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
final nonce = _generateNonce();
|
||||
|
||||
await _dio!.post(
|
||||
'/messager/chat/$channelId/messages',
|
||||
data: {'content': content, 'nonce': nonce},
|
||||
);
|
||||
|
||||
talker.info('[AppIntents] Message sent successfully');
|
||||
return AppIntentResult.successful(
|
||||
value: 'Message sent to channel $channelId',
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
talker.error('[AppIntents] API error sending message', e);
|
||||
return AppIntentResult.failed(
|
||||
error: 'Failed to send message: ${e.message ?? 'Network error'}',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to send message', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to send message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleReadMessagesIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
final channelId = parameters['channelId'] as String?;
|
||||
final limitParam = parameters['limit'] as String?;
|
||||
final limit = limitParam != null ? int.tryParse(limitParam) ?? 5 : 5;
|
||||
|
||||
if (channelId == null) {
|
||||
throw ArgumentError('channelId is required');
|
||||
}
|
||||
if (limit < 1 || limit > 20) {
|
||||
return AppIntentResult.failed(error: 'limit must be between 1 and 20');
|
||||
}
|
||||
|
||||
talker.info('[AppIntents] Reading $limit messages from $channelId');
|
||||
|
||||
if (_dio == null) {
|
||||
return AppIntentResult.failed(error: 'API client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio!.get(
|
||||
'/messager/chat/$channelId/messages',
|
||||
queryParameters: {'offset': 0, 'take': limit},
|
||||
);
|
||||
|
||||
final messages = response.data as List;
|
||||
if (messages.isEmpty) {
|
||||
return AppIntentResult.successful(
|
||||
value: 'No messages found in channel $channelId',
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
}
|
||||
|
||||
final formattedMessages = messages
|
||||
.map((msg) {
|
||||
final senderName =
|
||||
msg['sender']?['account']?['name'] ?? 'Unknown';
|
||||
final messageContent = msg['content'] ?? '';
|
||||
return '$senderName: $messageContent';
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
talker.info('[AppIntents] Retrieved ${messages.length} messages');
|
||||
return AppIntentResult.successful(
|
||||
value: formattedMessages,
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
talker.error('[AppIntents] API error reading messages', e);
|
||||
return AppIntentResult.failed(
|
||||
error: 'Failed to read messages: ${e.message ?? 'Network error'}',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to read messages', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to read messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _generateNonce() {
|
||||
return '${DateTime.now().millisecondsSinceEpoch}-${DateTime.now().microsecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
void _logDonation(String eventName, Map<String, Object> parameters) {
|
||||
try {
|
||||
FirebaseAnalytics.instance.logEvent(
|
||||
name: eventName,
|
||||
parameters: parameters.isEmpty ? null : parameters,
|
||||
);
|
||||
} catch (e) {
|
||||
talker.warning('[AppIntents] Failed to log analytics: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleCheckUnreadChatsIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Checking unread chats count');
|
||||
|
||||
if (_dio == null) {
|
||||
return AppIntentResult.failed(error: 'API client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio!.get('/messager/chat/unread');
|
||||
final count = response.data as int? ?? 0;
|
||||
|
||||
String message;
|
||||
if (count == 0) {
|
||||
message = 'You have no unread messages';
|
||||
} else if (count == 1) {
|
||||
message = 'You have 1 unread message';
|
||||
} else {
|
||||
message = 'You have $count unread messages';
|
||||
}
|
||||
|
||||
return AppIntentResult.successful(
|
||||
value: message,
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
talker.error('[AppIntents] API error checking unread chats', e);
|
||||
return AppIntentResult.failed(
|
||||
error:
|
||||
'Failed to fetch unread chats: ${e.message ?? 'Network error'}',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to check unread chats', e, stack);
|
||||
return AppIntentResult.failed(error: 'Failed to check unread chats: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppIntentResult> _handleMarkNotificationsReadIntent(
|
||||
Map<String, dynamic> parameters,
|
||||
) async {
|
||||
try {
|
||||
talker.info('[AppIntents] Marking all notifications as read');
|
||||
|
||||
if (_dio == null) {
|
||||
return AppIntentResult.failed(error: 'API client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
await _dio!.post('/ring/notifications/all/read');
|
||||
|
||||
talker.info('[AppIntents] Notifications marked as read');
|
||||
return AppIntentResult.successful(
|
||||
value: 'All notifications marked as read',
|
||||
needsToContinueInApp: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
talker.error('[AppIntents] API error marking notifications read', e);
|
||||
return AppIntentResult.failed(
|
||||
error:
|
||||
'Failed to mark notifications: ${e.message ?? 'Network error'}',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to mark notifications read', e, stack);
|
||||
return AppIntentResult.failed(
|
||||
error: 'Failed to mark notifications read: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Donation Methods - to be called manually from your app code
|
||||
|
||||
Future<void> donateOpenChat(String channelId) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'open_chat',
|
||||
{'channelId': channelId},
|
||||
relevanceScore: 0.8,
|
||||
context: {'feature': 'chat', 'userAction': true},
|
||||
);
|
||||
_logDonation('open_chat', {'channel_id': channelId});
|
||||
talker.info('[AppIntents] Donated open_chat intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate open_chat', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateOpenPost(String postId) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'open_post',
|
||||
{'postId': postId},
|
||||
relevanceScore: 0.8,
|
||||
context: {'feature': 'posts', 'userAction': true},
|
||||
);
|
||||
_logDonation('open_post', {'post_id': postId});
|
||||
talker.info('[AppIntents] Donated open_post intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate open_post', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateCompose() async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'open_compose',
|
||||
{},
|
||||
relevanceScore: 0.9,
|
||||
context: {'feature': 'compose', 'userAction': true},
|
||||
);
|
||||
_logDonation('open_compose', {});
|
||||
talker.info('[AppIntents] Donated compose intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate compose', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateSearch(String query) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'search_content',
|
||||
{'query': query},
|
||||
relevanceScore: 0.7,
|
||||
context: {'feature': 'search', 'userAction': true},
|
||||
);
|
||||
_logDonation('search_content', {'query': query});
|
||||
talker.info('[AppIntents] Donated search intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate search', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateCheckNotifications() async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'check_notifications',
|
||||
{},
|
||||
relevanceScore: 0.6,
|
||||
context: {'feature': 'notifications', 'userAction': true},
|
||||
);
|
||||
_logDonation('check_notifications', {});
|
||||
talker.info('[AppIntents] Donated check_notifications intent');
|
||||
} catch (e, stack) {
|
||||
talker.error(
|
||||
'[AppIntents] Failed to donate check_notifications',
|
||||
e,
|
||||
stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateSendMessage(String channelId, String content) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'send_message',
|
||||
{'channelId': channelId, 'content': content},
|
||||
relevanceScore: 0.8,
|
||||
context: {'feature': 'chat', 'userAction': true},
|
||||
);
|
||||
_logDonation('send_message', {'channel_id': channelId});
|
||||
talker.info('[AppIntents] Donated send_message intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate send_message', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateReadMessages(String channelId) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'read_messages',
|
||||
{'channelId': channelId},
|
||||
relevanceScore: 0.7,
|
||||
context: {'feature': 'chat', 'userAction': true},
|
||||
);
|
||||
_logDonation('read_messages', {'channel_id': channelId});
|
||||
talker.info('[AppIntents] Donated read_messages intent');
|
||||
} catch (e, stack) {
|
||||
talker.error('[AppIntents] Failed to donate read_messages', e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateCheckUnreadChats() async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'check_unread_chats',
|
||||
{},
|
||||
relevanceScore: 0.7,
|
||||
context: {'feature': 'chat', 'userAction': true},
|
||||
);
|
||||
_logDonation('check_unread_chats', {});
|
||||
talker.info('[AppIntents] Donated check_unread_chats intent');
|
||||
} catch (e, stack) {
|
||||
talker.error(
|
||||
'[AppIntents] Failed to donate check_unread_chats',
|
||||
e,
|
||||
stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> donateMarkNotificationsRead() async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await FlutterAppIntentsService.donateIntentWithMetadata(
|
||||
'mark_notifications_read',
|
||||
{},
|
||||
relevanceScore: 0.6,
|
||||
context: {'feature': 'notifications', 'userAction': true},
|
||||
);
|
||||
_logDonation('mark_notifications_read', {});
|
||||
talker.info('[AppIntents] Donated mark_notifications_read intent');
|
||||
} catch (e, stack) {
|
||||
talker.error(
|
||||
'[AppIntents] Failed to donate mark_notifications_read',
|
||||
e,
|
||||
stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,13 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:island/main.dart';
|
||||
import 'package:island/pods/audio.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/notification.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/widgets/app_notification.dart';
|
||||
import 'package:top_snackbar_flutter/top_snack_bar.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||
@@ -97,39 +96,14 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
if (pkt.type == "notifications.new") {
|
||||
final notification = SnNotification.fromJson(pkt.data!);
|
||||
if (_appLifecycleState == AppLifecycleState.resumed) {
|
||||
// App is focused, show in-app notification
|
||||
talker.info(
|
||||
'[Notification] Showing in-app notification: ${notification.title}',
|
||||
);
|
||||
if (settings.notifyWithHaptic) {
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
showTopSnackBar(
|
||||
globalOverlay.currentState!,
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: NotificationCard(notification: notification),
|
||||
),
|
||||
),
|
||||
onDismissed: () {},
|
||||
dismissType: DismissType.onSwipe,
|
||||
displayDuration: const Duration(seconds: 5),
|
||||
snackBarPosition: SnackBarPosition.top,
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top:
|
||||
(!kIsWeb &&
|
||||
(Platform.isMacOS ||
|
||||
Platform.isWindows ||
|
||||
Platform.isLinux))
|
||||
? 28
|
||||
// ignore: use_build_context_synchronously
|
||||
: MediaQuery.of(context).padding.top + 16,
|
||||
bottom: 16,
|
||||
),
|
||||
);
|
||||
playNotificationSfx(ref);
|
||||
ref.read(notificationStateProvider.notifier).add(notification);
|
||||
} else {
|
||||
// App is in background, show system notification (only on supported platforms)
|
||||
if (!kIsWeb && !Platform.isIOS) {
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:island/main.dart';
|
||||
import 'package:island/pods/audio.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/pods/notification.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/widgets/app_notification.dart';
|
||||
import 'package:top_snackbar_flutter/top_snack_bar.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:windows_notification/windows_notification.dart' as winty;
|
||||
import 'package:windows_notification/notification_message.dart';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
// Windows notification instance
|
||||
winty.WindowsNotification? windowsNotification;
|
||||
|
||||
@@ -31,9 +27,7 @@ void _onAppLifecycleChanged(AppLifecycleState state) {
|
||||
|
||||
Future<void> initializeLocalNotifications() async {
|
||||
// Initialize Windows notification for Windows platform
|
||||
windowsNotification = winty.WindowsNotification(
|
||||
applicationId: "Solian",
|
||||
);
|
||||
windowsNotification = winty.WindowsNotification(applicationId: "Solian");
|
||||
|
||||
WidgetsBinding.instance.addObserver(
|
||||
LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged),
|
||||
@@ -55,48 +49,20 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
final ws = ref.watch(websocketProvider);
|
||||
return ws.dataStream.listen((pkt) async {
|
||||
if (pkt.type == "notifications.new") {
|
||||
final notification = SnNotification.fromJson(pkt.data!);
|
||||
if (_appLifecycleState == AppLifecycleState.resumed) {
|
||||
// App is focused, show in-app notification
|
||||
talker.info(
|
||||
'[Notification] Showing in-app notification: ${notification.title}',
|
||||
);
|
||||
showTopSnackBar(
|
||||
globalOverlay.currentState!,
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: NotificationCard(notification: notification),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (notification.meta['action_uri'] != null) {
|
||||
var uri = notification.meta['action_uri'] as String;
|
||||
if (uri.startsWith('/')) {
|
||||
// In-app routes
|
||||
rootNavigatorKey.currentContext?.push(
|
||||
notification.meta['action_uri'],
|
||||
);
|
||||
} else {
|
||||
// External URLs
|
||||
launchUrlString(uri);
|
||||
if (settings.notifyWithHaptic) {
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismissed: () {},
|
||||
dismissType: DismissType.onSwipe,
|
||||
displayDuration: const Duration(seconds: 5),
|
||||
snackBarPosition: SnackBarPosition.top,
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 28, // Windows specific padding
|
||||
bottom: 16,
|
||||
),
|
||||
);
|
||||
playNotificationSfx(ref);
|
||||
ref.read(notificationStateProvider.notifier).add(notification);
|
||||
} else {
|
||||
// App is in background, show Windows system notification
|
||||
talker.info(
|
||||
@@ -139,7 +105,10 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
final notificationMessage = NotificationMessage.fromPluginTemplate(
|
||||
notification.id, // unique id
|
||||
notification.title,
|
||||
[notification.subtitle, notification.content].where((e) => e.isNotEmpty).join('\n'),
|
||||
[
|
||||
notification.subtitle,
|
||||
notification.content,
|
||||
].where((e) => e.isNotEmpty).join('\n'),
|
||||
group: notification.topic,
|
||||
image: imagePath,
|
||||
largeImage: largeImagePath,
|
||||
|
||||
89
lib/services/quick_actions.dart
Normal file
89
lib/services/quick_actions.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/services/event_bus.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:quick_actions/quick_actions.dart';
|
||||
|
||||
class QuickActionsService {
|
||||
static final QuickActionsService _instance = QuickActionsService._internal();
|
||||
factory QuickActionsService() => _instance;
|
||||
QuickActionsService._internal();
|
||||
|
||||
final QuickActions _quickActions = const QuickActions();
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
|
||||
talker.warning(
|
||||
'[QuickActions] Quick Actions only supported on Android and iOS',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_initialized) {
|
||||
talker.info('[QuickActions] Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
talker.info('[QuickActions] Initializing Quick Actions...');
|
||||
|
||||
// TODO Add icons for these
|
||||
final shortcuts = <ShortcutItem>[
|
||||
const ShortcutItem(type: 'compose_post', localizedTitle: 'New Post'),
|
||||
const ShortcutItem(type: 'explore', localizedTitle: 'Explore'),
|
||||
const ShortcutItem(type: 'chats', localizedTitle: 'Chats'),
|
||||
const ShortcutItem(
|
||||
type: 'notifications',
|
||||
localizedTitle: 'Notifications',
|
||||
),
|
||||
];
|
||||
|
||||
await _quickActions.initialize(_handleShortcut);
|
||||
await _quickActions.setShortcutItems(shortcuts);
|
||||
|
||||
_initialized = true;
|
||||
talker.info('[QuickActions] Quick Actions initialized successfully');
|
||||
} catch (e, stack) {
|
||||
talker.error('[QuickActions] Initialization failed', e, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleShortcut(String type) {
|
||||
talker.info('[QuickActions] Shortcut tapped: $type');
|
||||
|
||||
final context = rootNavigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
talker.warning('[QuickActions] Context not available, skipping action');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'compose_post':
|
||||
eventBus.fire(const ShowComposeSheetEvent());
|
||||
break;
|
||||
|
||||
case 'explore':
|
||||
context.go('/explore');
|
||||
break;
|
||||
|
||||
case 'chats':
|
||||
context.go('/chat');
|
||||
break;
|
||||
|
||||
case 'notifications':
|
||||
context.go('/notifications');
|
||||
break;
|
||||
|
||||
default:
|
||||
talker.warning('[QuickActions] Unknown shortcut type: $type');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'package:island/widgets/post/post_shared.dart';
|
||||
import 'package:path_provider/path_provider.dart' show getTemporaryDirectory;
|
||||
import 'package:screenshot/screenshot.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:island/services/analytics_service.dart';
|
||||
|
||||
/// Shares a post as a screenshot image
|
||||
Future<void> sharePostAsScreenshot(
|
||||
@@ -62,5 +63,9 @@ Future<void> sharePostAsScreenshot(
|
||||
.catchError((err) {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
showErrorAlert(err);
|
||||
})
|
||||
.whenComplete(() {
|
||||
final postTypeStr = post.type == 0 ? 'regular' : 'article';
|
||||
AnalyticsService().logPostShared(post.id, 'screenshot', postTypeStr);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,16 +27,12 @@ class AccountProfileCard extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final account = ref.watch(accountProvider(uname));
|
||||
final width =
|
||||
math.min(MediaQuery.of(context).size.width - 80, 360).toDouble();
|
||||
return PopupCard(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: account.when(
|
||||
data:
|
||||
(data) => Column(
|
||||
final width = math
|
||||
.min(MediaQuery.of(context).size.width - 80, 360)
|
||||
.toDouble();
|
||||
|
||||
final child = account.when(
|
||||
data: (data) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -64,12 +60,9 @@ class AccountProfileCard extends HookConsumerWidget {
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
offset: Offset(4, 28),
|
||||
child: ProfilePictureWidget(
|
||||
file: data.profile.picture,
|
||||
),
|
||||
child: ProfilePictureWidget(file: data.profile.picture),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
@@ -95,10 +88,7 @@ class AccountProfileCard extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
AccountStatusWidget(
|
||||
uname: data.name,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
AccountStatusWidget(uname: data.name, padding: EdgeInsets.zero),
|
||||
Tooltip(
|
||||
message: 'creditsStatus'.tr(),
|
||||
child: Row(
|
||||
@@ -149,9 +139,7 @@ class AccountProfileCard extends HookConsumerWidget {
|
||||
Text(
|
||||
tzInfo.$2.formatCustomGlobal('HH:mm'),
|
||||
).fontSize(12),
|
||||
Text(
|
||||
tzInfo.$1.formatOffsetLocal(),
|
||||
).fontSize(12),
|
||||
Text(tzInfo.$1.formatOffsetLocal()).fontSize(12),
|
||||
],
|
||||
).padding(top: 2);
|
||||
} catch (e) {
|
||||
@@ -171,11 +159,7 @@ class AccountProfileCard extends HookConsumerWidget {
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.stairs,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Icon(Symbols.stairs, size: 17, fill: 1).padding(right: 2),
|
||||
Text(
|
||||
'levelingProgressLevel'.tr(
|
||||
args: [data.profile.level.toString()],
|
||||
@@ -206,21 +190,35 @@ class AccountProfileCard extends HookConsumerWidget {
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
],
|
||||
),
|
||||
error:
|
||||
(err, _) => ResponseErrorWidget(
|
||||
error: (err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => ref.invalidate(accountProvider(uname)),
|
||||
),
|
||||
loading:
|
||||
() => SizedBox(
|
||||
loading: () => SizedBox(
|
||||
width: width,
|
||||
height: width,
|
||||
child:
|
||||
Padding(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(),
|
||||
).center(),
|
||||
),
|
||||
);
|
||||
|
||||
return PopupCard(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/main.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/notification.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
@@ -163,7 +165,6 @@ String _parseRemoteError(DioException err) {
|
||||
return message ?? err.toString();
|
||||
}
|
||||
|
||||
// Track active overlay dialogs for dismissal
|
||||
final List<void Function()> _activeOverlayDialogs = [];
|
||||
|
||||
Future<T?> showOverlayDialog<T>({
|
||||
@@ -229,7 +230,6 @@ Future<T?> showOverlayDialog<T>({
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
// Close the topmost overlay dialog if any exists
|
||||
bool closeTopmostOverlayDialog() {
|
||||
if (_activeOverlayDialogs.isNotEmpty) {
|
||||
final closeFunc = _activeOverlayDialogs.last;
|
||||
@@ -259,7 +259,8 @@ void showErrorAlert(dynamic err, {IconData? icon}) {
|
||||
title: null,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
content: Column(
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -274,10 +275,11 @@ void showErrorAlert(dynamic err, {IconData? icon}) {
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(text),
|
||||
SelectableText(text),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(null),
|
||||
@@ -376,6 +378,34 @@ Future<bool> showConfirmAlert(
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
void showNotification({
|
||||
required String title,
|
||||
String content = '',
|
||||
String subtitle = '',
|
||||
Map<String, dynamic> meta = const {},
|
||||
Duration? duration,
|
||||
}) {
|
||||
final context = globalOverlay.currentState!.context;
|
||||
final ref = ProviderScope.containerOf(context);
|
||||
final notification = SnNotification(
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
deletedAt: null,
|
||||
id: 'local_${DateTime.now().millisecondsSinceEpoch}',
|
||||
topic: 'local',
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
content: content,
|
||||
meta: meta,
|
||||
priority: 0,
|
||||
viewedAt: null,
|
||||
accountId: 'local',
|
||||
);
|
||||
ref
|
||||
.read(notificationStateProvider.notifier)
|
||||
.add(notification, duration: duration);
|
||||
}
|
||||
|
||||
Future<void> openExternalLink(Uri url, WidgetRef ref) async {
|
||||
final whitelistDomains = ['solian.app', 'solsynth.dev'];
|
||||
if (whitelistDomains.any(
|
||||
|
||||
@@ -17,7 +17,8 @@ import 'package:island/services/event_bus.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/cmp/pattle.dart';
|
||||
import 'package:island/widgets/upload_overlay.dart';
|
||||
import 'package:island/widgets/notification_overlay.dart';
|
||||
import 'package:island/widgets/task_overlay.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shake/shake.dart';
|
||||
@@ -125,6 +126,8 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (kIsWeb) return null;
|
||||
|
||||
hotKeyManager.register(
|
||||
popHotKey,
|
||||
keyDownHandler: (_) {
|
||||
@@ -146,16 +149,19 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
},
|
||||
);
|
||||
|
||||
ShakeDetector detector = ShakeDetector.autoStart(
|
||||
ShakeDetector? detactor;
|
||||
if (!kIsWeb && (Platform.isIOS && Platform.isAndroid)) {
|
||||
detactor = ShakeDetector.autoStart(
|
||||
onPhoneShake: (_) {
|
||||
showPalette.value = true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return () {
|
||||
hotKeyManager.unregister(popHotKey);
|
||||
hotKeyManager.unregister(cmpHotKey);
|
||||
detector.stopListening();
|
||||
detactor?.stopListening();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -255,7 +261,8 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
_WebSocketIndicator(),
|
||||
const UploadOverlay(),
|
||||
const TaskOverlay(),
|
||||
const NotificationOverlay(),
|
||||
if (showPalette.value)
|
||||
CommandPattleWidget(onDismiss: () => showPalette.value = false),
|
||||
],
|
||||
@@ -268,7 +275,8 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
children: [
|
||||
Positioned.fill(child: child),
|
||||
_WebSocketIndicator(),
|
||||
const UploadOverlay(),
|
||||
const TaskOverlay(),
|
||||
const NotificationOverlay(),
|
||||
if (showPalette.value)
|
||||
CommandPattleWidget(onDismiss: () => showPalette.value = false),
|
||||
],
|
||||
@@ -357,21 +365,12 @@ class AppScaffold extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final focusNode = useFocusNode();
|
||||
|
||||
useEffect(() {
|
||||
focusNode.requestFocus();
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
final appBarHeight = appBar?.preferredSize.height ?? 0;
|
||||
final safeTop = MediaQuery.of(context).padding.top;
|
||||
|
||||
final noBackground = isNoBackground ?? isWideScreen(context);
|
||||
|
||||
final builtWidget = Focus(
|
||||
focusNode: focusNode,
|
||||
child: Scaffold(
|
||||
final builtWidget = Scaffold(
|
||||
extendBody: extendBody ?? true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -394,7 +393,6 @@ class AppScaffold extends HookConsumerWidget {
|
||||
floatingActionButtonAnimator: floatingActionButtonAnimator,
|
||||
onDrawerChanged: onDrawerChanged,
|
||||
onEndDrawerChanged: onEndDrawerChanged,
|
||||
),
|
||||
);
|
||||
|
||||
return noBackground
|
||||
|
||||
@@ -162,6 +162,7 @@ class AppWrapper extends HookConsumerWidget {
|
||||
(now.day >= 22 && now.day <= 28);
|
||||
|
||||
useEffect(() {
|
||||
Future(() {
|
||||
final now = DateTime.now();
|
||||
if (doesShowSnow) {
|
||||
isShowSnow.value = true;
|
||||
@@ -192,6 +193,7 @@ class AppWrapper extends HookConsumerWidget {
|
||||
settingsNotifier.setAskedReview(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
@@ -284,7 +284,12 @@ class ChatInput extends HookConsumerWidget {
|
||||
onAttachmentsChanged([
|
||||
...attachments,
|
||||
UniversalFile(
|
||||
data: XFile.fromData(image, mimeType: "image/jpeg"),
|
||||
displayName: 'image.jpeg',
|
||||
data: XFile.fromData(
|
||||
image,
|
||||
mimeType: "image/jpeg",
|
||||
name: 'image.jpeg',
|
||||
),
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
]);
|
||||
@@ -458,6 +463,10 @@ class ChatInput extends HookConsumerWidget {
|
||||
item: attachments[idx],
|
||||
progress:
|
||||
attachmentProgress['chat-upload']?[idx],
|
||||
isUploading:
|
||||
attachmentProgress['chat-upload']
|
||||
?.containsKey(idx) ??
|
||||
false,
|
||||
onRequestUpload: () => onUploadAttachment(idx),
|
||||
onDelete: () => onDeleteAttachment(idx),
|
||||
onUpdate: (value) {
|
||||
|
||||
129
lib/widgets/chat/room_app_bar.dart
Normal file
129
lib/widgets/chat/room_app_bar.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
List<SnChatMember> getValidMembers(List<SnChatMember> members, String? userId) {
|
||||
return members.where((member) => member.accountId != userId).toList();
|
||||
}
|
||||
|
||||
class RoomAppBar extends ConsumerWidget {
|
||||
final SnChatRoom room;
|
||||
final int onlineCount;
|
||||
final bool compact;
|
||||
|
||||
const RoomAppBar({
|
||||
super.key,
|
||||
required this.room,
|
||||
required this.onlineCount,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
final validMembers = getValidMembers(
|
||||
room.members ?? [],
|
||||
userInfo.value?.id,
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return Row(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
_OnlineCountBadge(
|
||||
onlineCount: onlineCount,
|
||||
child: _RoomAvatar(
|
||||
room: room,
|
||||
validMembers: validMembers,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? validMembers.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(19),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
spacing: 4,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_OnlineCountBadge(
|
||||
onlineCount: onlineCount,
|
||||
child: _RoomAvatar(room: room, validMembers: validMembers, size: 26),
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? validMembers.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(15),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OnlineCountBadge extends StatelessWidget {
|
||||
final int onlineCount;
|
||||
final Widget child;
|
||||
|
||||
const _OnlineCountBadge({required this.onlineCount, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Badge(
|
||||
isLabelVisible: onlineCount > 1,
|
||||
label: Text('$onlineCount'),
|
||||
textStyle: GoogleFonts.robotoMono(fontSize: 10),
|
||||
textColor: Colors.white,
|
||||
backgroundColor: onlineCount > 1 ? Colors.green : Colors.grey,
|
||||
offset: const Offset(6, 14),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomAvatar extends StatelessWidget {
|
||||
final SnChatRoom room;
|
||||
final List<SnChatMember> validMembers;
|
||||
final double size;
|
||||
|
||||
const _RoomAvatar({
|
||||
required this.room,
|
||||
required this.validMembers,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: size,
|
||||
width: size,
|
||||
child: (room.type == 1 && room.picture == null)
|
||||
? SplitAvatarWidget(
|
||||
files: validMembers
|
||||
.map((e) => e.account.profile.picture)
|
||||
.toList(),
|
||||
)
|
||||
: room.picture != null
|
||||
? ProfilePictureWidget(file: room.picture, fallbackIcon: Symbols.chat)
|
||||
: CircleAvatar(
|
||||
child: Text(
|
||||
room.name![0].toUpperCase(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/widgets/chat/room_message_list.dart
Normal file
170
lib/widgets/chat/room_message_list.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/screens/chat/widgets/message_item_wrapper.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
class RoomMessageList extends HookConsumerWidget {
|
||||
final List<LocalChatMessage> messages;
|
||||
final AsyncValue<SnChatRoom?> roomAsync;
|
||||
final AsyncValue<SnChatMember?> chatIdentity;
|
||||
final ScrollController scrollController;
|
||||
final ListController listController;
|
||||
final bool isSelectionMode;
|
||||
final Set<String> selectedMessages;
|
||||
final VoidCallback toggleSelectionMode;
|
||||
final void Function(String) toggleMessageSelection;
|
||||
final void Function(String action, LocalChatMessage message) onMessageAction;
|
||||
final void Function(String messageId) onJump;
|
||||
final Map<String, Map<int, double?>> attachmentProgress;
|
||||
final bool disableAnimation;
|
||||
final DateTime roomOpenTime;
|
||||
final double inputHeight;
|
||||
final double? previousInputHeight;
|
||||
|
||||
const RoomMessageList({
|
||||
super.key,
|
||||
required this.messages,
|
||||
required this.roomAsync,
|
||||
required this.chatIdentity,
|
||||
required this.scrollController,
|
||||
required this.listController,
|
||||
required this.isSelectionMode,
|
||||
required this.selectedMessages,
|
||||
required this.toggleSelectionMode,
|
||||
required this.toggleMessageSelection,
|
||||
required this.onMessageAction,
|
||||
required this.onJump,
|
||||
required this.attachmentProgress,
|
||||
required this.disableAnimation,
|
||||
required this.roomOpenTime,
|
||||
required this.inputHeight,
|
||||
this.previousInputHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
const messageKeyPrefix = 'message-';
|
||||
|
||||
final bottomPadding =
|
||||
inputHeight + MediaQuery.of(context).padding.bottom + 8;
|
||||
|
||||
final listWidget =
|
||||
previousInputHeight != null && previousInputHeight != inputHeight
|
||||
? TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: previousInputHeight, end: inputHeight),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, height, child) => SuperListView.builder(
|
||||
listController: listController,
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
padding: EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: height + MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
itemCount: messages.length,
|
||||
findChildIndexCallback: (key) {
|
||||
if (key is! ValueKey<String>) return null;
|
||||
final messageId = key.value.substring(messageKeyPrefix.length);
|
||||
final index = messages.indexWhere(
|
||||
(m) => (m.nonce ?? m.id) == messageId,
|
||||
);
|
||||
return index >= 0 ? index : null;
|
||||
},
|
||||
extentEstimation: (_, _) => 40,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final nextMessage = index < messages.length - 1
|
||||
? messages[index + 1]
|
||||
: null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
|
||||
final key = Key(
|
||||
'$messageKeyPrefix${message.nonce ?? message.id}',
|
||||
);
|
||||
|
||||
return MessageItemWrapper(
|
||||
key: key,
|
||||
message: message,
|
||||
index: index,
|
||||
isLastInGroup: isLastInGroup,
|
||||
isSelectionMode: isSelectionMode,
|
||||
selectedMessages: selectedMessages,
|
||||
chatIdentity: chatIdentity,
|
||||
toggleSelectionMode: toggleSelectionMode,
|
||||
toggleMessageSelection: toggleMessageSelection,
|
||||
onMessageAction: onMessageAction,
|
||||
onJump: onJump,
|
||||
attachmentProgress: attachmentProgress,
|
||||
disableAnimation: settings.disableAnimation,
|
||||
roomOpenTime: roomOpenTime,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: SuperListView.builder(
|
||||
listController: listController,
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
padding: EdgeInsets.only(top: 8, bottom: bottomPadding),
|
||||
itemCount: messages.length,
|
||||
findChildIndexCallback: (key) {
|
||||
if (key is! ValueKey<String>) return null;
|
||||
final messageId = key.value.substring(messageKeyPrefix.length);
|
||||
final index = messages.indexWhere(
|
||||
(m) => (m.nonce ?? m.id) == messageId,
|
||||
);
|
||||
return index >= 0 ? index : null;
|
||||
},
|
||||
extentEstimation: (_, _) => 40,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final nextMessage = index < messages.length - 1
|
||||
? messages[index + 1]
|
||||
: null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
|
||||
final key = Key(
|
||||
'$messageKeyPrefix${message.nonce ?? message.id}',
|
||||
);
|
||||
|
||||
return MessageItemWrapper(
|
||||
key: key,
|
||||
message: message,
|
||||
index: index,
|
||||
isLastInGroup: isLastInGroup,
|
||||
isSelectionMode: isSelectionMode,
|
||||
selectedMessages: selectedMessages,
|
||||
chatIdentity: chatIdentity,
|
||||
toggleSelectionMode: toggleSelectionMode,
|
||||
toggleMessageSelection: toggleMessageSelection,
|
||||
onMessageAction: onMessageAction,
|
||||
onJump: onJump,
|
||||
attachmentProgress: attachmentProgress,
|
||||
disableAnimation: settings.disableAnimation,
|
||||
roomOpenTime: roomOpenTime,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return listWidget;
|
||||
}
|
||||
}
|
||||
103
lib/widgets/chat/room_overlays.dart
Normal file
103
lib/widgets/chat/room_overlays.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/widgets/chat/call_overlay.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class RoomOverlays extends ConsumerWidget {
|
||||
final AsyncValue<SnChatRoom?> roomAsync;
|
||||
final bool isSyncing;
|
||||
final bool showGradient;
|
||||
final double bottomGradientOpacity;
|
||||
final double inputHeight;
|
||||
|
||||
const RoomOverlays({
|
||||
super.key,
|
||||
required this.roomAsync,
|
||||
required this.isSyncing,
|
||||
required this.showGradient,
|
||||
required this.bottomGradientOpacity,
|
||||
required this.inputHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: roomAsync.when(
|
||||
data: (data) => data != null
|
||||
? CallOverlayBar(room: data).padding(horizontal: 8, top: 12)
|
||||
: const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (isSyncing)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8,
|
||||
8,
|
||||
8,
|
||||
8 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).scaffoldBackgroundColor.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Syncing...',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showGradient)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Opacity(
|
||||
opacity: bottomGradientOpacity,
|
||||
child: Container(
|
||||
height: math.min(MediaQuery.of(context).size.height * 0.1, 128),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer.withOpacity(0.8),
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/widgets/chat/room_selection_mode.dart
Normal file
54
lib/widgets/chat/room_selection_mode.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class RoomSelectionMode extends StatelessWidget {
|
||||
final bool visible;
|
||||
final int selectedCount;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onAIThink;
|
||||
|
||||
const RoomSelectionMode({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.selectedCount,
|
||||
required this.onClose,
|
||||
required this.onAIThink,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!visible) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 8,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: onClose,
|
||||
tooltip: 'Cancel selection',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$selectedCount selected',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
if (selectedCount > 0)
|
||||
FilledButton.icon(
|
||||
onPressed: onAIThink,
|
||||
icon: const Icon(Symbols.smart_toy),
|
||||
label: const Text('AI Think'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:video_thumbnail/video_thumbnail.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -14,13 +16,12 @@ import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/utils/format.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sensitive.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_context_menu/super_context_menu.dart';
|
||||
|
||||
import 'sensitive.dart';
|
||||
|
||||
class SensitiveMarksSelector extends StatefulWidget {
|
||||
final List<int> initial;
|
||||
final ValueChanged<List<int>>? onChanged;
|
||||
@@ -85,23 +86,29 @@ class SensitiveMarksSelectorState extends State<SensitiveMarksSelector> {
|
||||
class AttachmentPreview extends HookConsumerWidget {
|
||||
final UniversalFile item;
|
||||
final double? progress;
|
||||
final bool isUploading;
|
||||
final Function(int)? onMove;
|
||||
final Function? onDelete;
|
||||
final Function? onInsert;
|
||||
final Function(UniversalFile)? onUpdate;
|
||||
final Function? onRequestUpload;
|
||||
final bool isCompact;
|
||||
final String? thumbnailId;
|
||||
final Function(String?)? onSetThumbnail;
|
||||
|
||||
const AttachmentPreview({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.progress,
|
||||
this.isUploading = false,
|
||||
this.onRequestUpload,
|
||||
this.onMove,
|
||||
this.onDelete,
|
||||
this.onUpdate,
|
||||
this.onInsert,
|
||||
this.isCompact = false,
|
||||
this.thumbnailId,
|
||||
this.onSetThumbnail,
|
||||
});
|
||||
|
||||
// GlobalKey for selector
|
||||
@@ -125,8 +132,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
builder: (context) => SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'rename'.tr(),
|
||||
child: Column(
|
||||
@@ -134,10 +140,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 24,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
@@ -174,10 +177,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
final newData = item.data;
|
||||
newData.name = newName;
|
||||
onUpdate?.call(
|
||||
item.copyWith(
|
||||
data: newData,
|
||||
displayName: newName,
|
||||
),
|
||||
item.copyWith(data: newData, displayName: newName),
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (err) {
|
||||
@@ -205,8 +205,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
builder: (context) => SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'markAsSensitive'.tr(),
|
||||
child: Column(
|
||||
@@ -214,17 +213,13 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 24,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
// Sensitive categories checklist
|
||||
SensitiveMarksSelector(
|
||||
key: _sensitiveSelectorKey,
|
||||
initial:
|
||||
(item.data.sensitiveMarks ?? [])
|
||||
initial: (item.data.sensitiveMarks ?? [])
|
||||
.map((e) => e as int)
|
||||
.cast<int>()
|
||||
.toList(),
|
||||
@@ -253,8 +248,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
// Use the current selections from stateful selector via GlobalKey
|
||||
final selectorState =
|
||||
_sensitiveSelectorKey.currentState;
|
||||
final selectorState = _sensitiveSelectorKey.currentState;
|
||||
final marks = selectorState?.current ?? <int>[];
|
||||
await apiClient.put(
|
||||
'/drive/files/${item.data.id}/marks',
|
||||
@@ -284,26 +278,16 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var ratio =
|
||||
item.isOnCloud
|
||||
var ratio = item.isOnCloud
|
||||
? (item.data.fileMeta?['ratio'] is num
|
||||
? item.data.fileMeta!['ratio'].toDouble()
|
||||
: 1.0)
|
||||
: 1.0;
|
||||
if (ratio == 0) ratio = 1.0;
|
||||
: null)
|
||||
: null;
|
||||
|
||||
final contentWidget = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: ratio,
|
||||
child: Stack(
|
||||
final innerContentWidget = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Builder(
|
||||
HookBuilder(
|
||||
key: ValueKey(item.hashCode),
|
||||
builder: (context) {
|
||||
final fallbackIcon = switch (item.type) {
|
||||
@@ -326,9 +310,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
if (snapshot.hasData) {
|
||||
return Image.memory(snapshot.data!);
|
||||
}
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -338,7 +320,52 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
return kIsWeb
|
||||
? Image.network(file.path)
|
||||
: Image.file(File(file.path));
|
||||
case UniversalFileType.video:
|
||||
if (!kIsWeb) {
|
||||
final thumbnailFuture = useMemoized(
|
||||
() => VideoThumbnail.thumbnailData(
|
||||
video: file.path,
|
||||
imageFormat: ImageFormat.JPEG,
|
||||
maxWidth: 320,
|
||||
quality: 50,
|
||||
),
|
||||
[file.path],
|
||||
);
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: thumbnailFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data != null) {
|
||||
return Stack(
|
||||
children: [
|
||||
Image.memory(snapshot.data!),
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Symbols.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -347,6 +374,8 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
Text(
|
||||
_getDisplayName(),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
@@ -355,16 +384,13 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final size = snapshot.data as int;
|
||||
return Text(
|
||||
formatFileSize(size),
|
||||
).fontSize(11);
|
||||
return Text(formatFileSize(size)).fontSize(11);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
).padding(vertical: 32);
|
||||
} else if (item is List<int> || item is Uint8List) {
|
||||
switch (item.type) {
|
||||
case UniversalFileType.image:
|
||||
@@ -377,9 +403,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
const Gap(6),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
Text(
|
||||
formatFileSize(item.data.length),
|
||||
).fontSize(11),
|
||||
Text(formatFileSize(item.data.length)).fontSize(11),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -387,39 +411,101 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
return Placeholder();
|
||||
},
|
||||
),
|
||||
if (progress != null)
|
||||
if (isUploading && progress != null && (progress ?? 0) > 0)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 40,
|
||||
vertical: 16,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (progress != null)
|
||||
Text(
|
||||
'${(progress! * 100).toStringAsFixed(2)}%',
|
||||
style: TextStyle(color: Colors.white),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'uploading'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Gap(6),
|
||||
Center(
|
||||
child: LinearProgressIndicator(value: progress),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: 0.0, end: progress),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
builder: (context, value, child) =>
|
||||
LinearProgressIndicator(value: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUploading && (progress == null || progress == 0))
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'processing'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Gap(6),
|
||||
Center(child: LinearProgressIndicator(value: null)),
|
||||
],
|
||||
),
|
||||
).center(),
|
||||
),
|
||||
),
|
||||
if (thumbnailId != null &&
|
||||
item.isOnCloud &&
|
||||
(item.data as SnCloudFile).id == thumbnailId)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (thumbnailId != null &&
|
||||
item.isOnCloud &&
|
||||
(item.data as SnCloudFile).id == thumbnailId)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Symbols.image,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final contentWidget = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (ratio != null)
|
||||
AspectRatio(
|
||||
aspectRatio: ratio,
|
||||
child: innerContentWidget,
|
||||
).center()
|
||||
else
|
||||
IntrinsicHeight(child: innerContentWidget).center(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -497,8 +583,9 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
if (onRequestUpload != null)
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap:
|
||||
item.isOnCloud ? null : () => onRequestUpload?.call(),
|
||||
onTap: item.isOnCloud
|
||||
? null
|
||||
: () => onRequestUpload?.call(),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
@@ -507,8 +594,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child:
|
||||
(item.isOnCloud)
|
||||
child: (item.isOnCloud)
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -552,8 +638,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
return ContextMenuWidget(
|
||||
menuProvider:
|
||||
(MenuRequest request) => Menu(
|
||||
menuProvider: (MenuRequest request) => Menu(
|
||||
children: [
|
||||
if (item.isOnDevice && item.type == UniversalFileType.image)
|
||||
MenuAction(
|
||||
@@ -593,6 +678,24 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
await _showSensitiveDialog(context, ref);
|
||||
},
|
||||
),
|
||||
if (item.isOnCloud &&
|
||||
item.type == UniversalFileType.image &&
|
||||
onSetThumbnail != null)
|
||||
MenuAction(
|
||||
title: thumbnailId == (item.data as SnCloudFile).id
|
||||
? 'unsetAsThumbnail'.tr()
|
||||
: 'setAsThumbnail'.tr(),
|
||||
image: MenuImage.icon(Symbols.image),
|
||||
callback: () {
|
||||
final isCurrentlyThumbnail =
|
||||
thumbnailId == (item.data as SnCloudFile).id;
|
||||
if (isCurrentlyThumbnail) {
|
||||
onSetThumbnail?.call(null);
|
||||
} else {
|
||||
onSetThumbnail?.call((item.data as SnCloudFile).id);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
child: contentWidget,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/talker.dart';
|
||||
@@ -57,10 +58,14 @@ class _UniversalAudioState extends ConsumerState<UniversalAudio> {
|
||||
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
|
||||
if (inCacheInfo == null) {
|
||||
talker.info('[MediaPlayer] Miss cache: $url');
|
||||
final token = ref.watch(tokenProvider)?.token;
|
||||
final serverUrl = ref.read(serverUrlProvider);
|
||||
final token = ref.read(tokenProvider);
|
||||
final authHeaders = url.startsWith(serverUrl) && token != null
|
||||
? {'Authorization': 'AtField ${token.token}'}
|
||||
: null;
|
||||
DefaultCacheManager().downloadFile(
|
||||
url,
|
||||
authHeaders: {'Authorization': 'AtField $token'},
|
||||
authHeaders: authHeaders,
|
||||
);
|
||||
uri = url;
|
||||
} else {
|
||||
@@ -68,7 +73,13 @@ class _UniversalAudioState extends ConsumerState<UniversalAudio> {
|
||||
talker.info('[MediaPlayer] Hit cache: $url');
|
||||
}
|
||||
|
||||
_player!.open(Media(uri), play: widget.autoplay);
|
||||
final serverUrl = ref.read(serverUrlProvider);
|
||||
final token = ref.read(tokenProvider);
|
||||
final Map<String, String>? httpHeaders = uri.startsWith(serverUrl) && token != null
|
||||
? {'Authorization': 'AtField ${token.token}'}
|
||||
: null;
|
||||
|
||||
_player!.open(Media(uri, httpHeaders: httpHeaders), play: widget.autoplay);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -244,8 +244,7 @@ class CloudFileList extends HookConsumerWidget {
|
||||
minWidth: minWidth ?? 0,
|
||||
maxWidth: files.length == 1 ? maxWidth : double.infinity,
|
||||
),
|
||||
child:
|
||||
(ratio == null && isImage)
|
||||
child: (ratio == null && isImage)
|
||||
? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
|
||||
: (ratio == null && isAudio)
|
||||
? IntrinsicHeight(child: widgetItem)
|
||||
@@ -256,8 +255,7 @@ class CloudFileList extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final allImages =
|
||||
!files.any(
|
||||
final allImages = !files.any(
|
||||
(e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
|
||||
);
|
||||
|
||||
@@ -270,8 +268,7 @@ class CloudFileList extends HookConsumerWidget {
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth =
|
||||
constraints.maxWidth.isFinite
|
||||
final availableWidth = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.of(context).size.width;
|
||||
final itemExtent = math.min(
|
||||
@@ -339,8 +336,7 @@ class CloudFileList extends HookConsumerWidget {
|
||||
padding: padding,
|
||||
itemBuilder: (context, index) {
|
||||
return AspectRatio(
|
||||
aspectRatio:
|
||||
files[index].fileMeta?['ratio'] is num
|
||||
aspectRatio: files[index].fileMeta?['ratio'] is num
|
||||
? files[index].fileMeta!['ratio'].toDouble()
|
||||
: 1.0,
|
||||
child: Stack(
|
||||
@@ -440,8 +436,7 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final bool fullyUnlocked = !lockedByDS && !lockedByMature;
|
||||
Widget fg =
|
||||
fullyUnlocked
|
||||
Widget fg = fullyUnlocked
|
||||
? (isImage
|
||||
? CloudFileWidget(
|
||||
item: file,
|
||||
@@ -458,22 +453,51 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
||||
))
|
||||
: const SizedBox.shrink();
|
||||
|
||||
Widget overlays;
|
||||
if (lockedByDS) {
|
||||
overlays = _DataSavingOverlay();
|
||||
} else if (file.sensitiveMarks.isNotEmpty) {
|
||||
overlays = _SensitiveOverlay(
|
||||
Widget overlays = AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: lockedByDS
|
||||
? _DataSavingOverlay(key: const ValueKey('ds'))
|
||||
: (file.sensitiveMarks.isNotEmpty && !showMature.value
|
||||
? _SensitiveOverlay(
|
||||
key: const ValueKey('sensitive-blur'),
|
||||
file: file,
|
||||
isRevealed: showMature.value,
|
||||
onHide: () => showMature.value = false,
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('none'))),
|
||||
);
|
||||
|
||||
Widget hideButton = const SizedBox.shrink();
|
||||
if (file.sensitiveMarks.isNotEmpty && showMature.value) {
|
||||
hideButton = Positioned(
|
||||
top: 3,
|
||||
left: 4,
|
||||
child: IconButton(
|
||||
iconSize: 16,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(
|
||||
Icons.visibility_off,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
blurRadius: 5.0,
|
||||
offset: Offset(1.0, 1.0),
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip: 'Blur content',
|
||||
onPressed: () => showMature.value = false,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
overlays = const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final content = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [if (isImage) Positioned.fill(child: bg), fg, overlays],
|
||||
children: [
|
||||
if (isImage) Positioned.fill(child: bg),
|
||||
fg,
|
||||
overlays,
|
||||
hideButton,
|
||||
],
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
@@ -494,41 +518,11 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
||||
|
||||
class _SensitiveOverlay extends StatelessWidget {
|
||||
final SnCloudFile file;
|
||||
final VoidCallback? onHide;
|
||||
final bool isRevealed;
|
||||
|
||||
const _SensitiveOverlay({
|
||||
required this.file,
|
||||
this.onHide,
|
||||
this.isRevealed = false,
|
||||
});
|
||||
const _SensitiveOverlay({required this.file, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isRevealed) {
|
||||
return Positioned(
|
||||
top: 3,
|
||||
left: 4,
|
||||
child: IconButton(
|
||||
iconSize: 16,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(
|
||||
Icons.visibility_off,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black,
|
||||
blurRadius: 5.0,
|
||||
offset: Offset(1.0, 1.0),
|
||||
),
|
||||
],
|
||||
),
|
||||
tooltip: 'Blur content',
|
||||
onPressed: onHide,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 64, sigmaY: 64),
|
||||
child: Container(
|
||||
@@ -549,6 +543,8 @@ class _SensitiveOverlay extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _DataSavingOverlay extends StatelessWidget {
|
||||
const _DataSavingOverlay({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
|
||||
@@ -759,7 +759,6 @@ class SplitAvatarWidget extends ConsumerWidget {
|
||||
if (file == null) {
|
||||
return Container(
|
||||
width: radius,
|
||||
height: radius,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
fallbackIcon,
|
||||
@@ -775,7 +774,6 @@ class SplitAvatarWidget extends ConsumerWidget {
|
||||
|
||||
return SizedBox(
|
||||
width: radius,
|
||||
height: radius,
|
||||
child: UniversalImage(uri: uri, fit: BoxFit.cover),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,9 +202,9 @@ class _EmbedLinkWidgetState extends State<EmbedLinkWidget> {
|
||||
const Gap(8),
|
||||
|
||||
// Title
|
||||
if (widget.link.title.isNotEmpty) ...[
|
||||
if (widget.link.title?.isNotEmpty ?? false) ...[
|
||||
Text(
|
||||
widget.link.title,
|
||||
widget.link.title!,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
|
||||
@@ -124,24 +124,6 @@ class FileInfoSheet extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (item.pool != null)
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.calendar_today),
|
||||
title: Text('File Pool').tr(),
|
||||
subtitle: Text(
|
||||
item.pool!.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: item.pool!.id));
|
||||
showSnackBar('fileNameCopied'.tr());
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.launch),
|
||||
title: Text('openInBrowser').tr(),
|
||||
@@ -176,8 +158,7 @@ class FileInfoSheet extends StatelessWidget {
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title:
|
||||
Text(
|
||||
title: Text(
|
||||
entry.key.contains('-')
|
||||
? entry.key.split('-').last
|
||||
: entry.key,
|
||||
@@ -227,8 +208,7 @@ class FileInfoSheet extends StatelessWidget {
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title:
|
||||
Text(
|
||||
title: Text(
|
||||
entry.key,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -276,8 +256,7 @@ class FileInfoSheet extends StatelessWidget {
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title:
|
||||
Text(
|
||||
title: Text(
|
||||
entry.key,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
|
||||
class UniversalImage extends HookWidget {
|
||||
class UniversalImage extends HookConsumerWidget {
|
||||
final String uri;
|
||||
final String? blurHash;
|
||||
final BoxFit fit;
|
||||
@@ -27,10 +33,26 @@ class UniversalImage extends HookWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final loaded = useState(false);
|
||||
final isCached = useState<bool?>(null);
|
||||
final isSvgImage = isSvg || uri.toLowerCase().endsWith('.svg');
|
||||
|
||||
final serverUrl = ref.watch(serverUrlProvider);
|
||||
final token = ref.watch(tokenProvider);
|
||||
|
||||
final Map<String, String>? httpHeaders =
|
||||
uri.startsWith(serverUrl) && token != null
|
||||
? {'Authorization': 'AtField ${token.token}'}
|
||||
: null;
|
||||
|
||||
useEffect(() {
|
||||
DefaultCacheManager().getFileFromCache(uri).then((fileInfo) {
|
||||
isCached.value = fileInfo != null;
|
||||
});
|
||||
return null;
|
||||
}, [uri]);
|
||||
|
||||
if (isSvgImage) {
|
||||
return SvgPicture.network(
|
||||
uri,
|
||||
@@ -59,8 +81,35 @@ class UniversalImage extends HookWidget {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (blurHash != null) BlurHash(hash: blurHash!),
|
||||
if (isCached.value == null)
|
||||
Center(child: CircularProgressIndicator())
|
||||
else if (isCached.value!)
|
||||
CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
httpHeaders: httpHeaders,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
memCacheHeight: cacheHeight,
|
||||
memCacheWidth: cacheWidth,
|
||||
imageBuilder: (context, imageProvider) => Image(
|
||||
image: imageProvider,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
),
|
||||
errorWidget: (context, url, error) => CachedImageErrorWidget(
|
||||
useFallbackImage: useFallbackImage,
|
||||
uri: uri,
|
||||
blurHash: blurHash,
|
||||
error: error,
|
||||
debug: true,
|
||||
),
|
||||
)
|
||||
else
|
||||
CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
httpHeaders: httpHeaders,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
@@ -75,7 +124,9 @@ class UniversalImage extends HookWidget {
|
||||
);
|
||||
},
|
||||
imageBuilder: (context, imageProvider) {
|
||||
Future(() => loaded.value = true);
|
||||
Future(() {
|
||||
if (context.mounted) loaded.value = true;
|
||||
});
|
||||
return AnimatedOpacity(
|
||||
opacity: loaded.value ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
@@ -87,13 +138,13 @@ class UniversalImage extends HookWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) => useFallbackImage
|
||||
? Image.asset(
|
||||
'assets/images/media-offline.jpg',
|
||||
fit: BoxFit.cover,
|
||||
key: Key('image-broke-$uri'),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
errorWidget: (context, url, error) => CachedImageErrorWidget(
|
||||
useFallbackImage: useFallbackImage,
|
||||
uri: uri,
|
||||
blurHash: blurHash,
|
||||
error: error,
|
||||
debug: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -101,6 +152,135 @@ class UniversalImage extends HookWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class CachedImageErrorWidget extends StatelessWidget {
|
||||
final bool useFallbackImage;
|
||||
final String uri;
|
||||
final String? blurHash;
|
||||
final dynamic error;
|
||||
final bool debug;
|
||||
|
||||
const CachedImageErrorWidget({
|
||||
super.key,
|
||||
required this.useFallbackImage,
|
||||
required this.uri,
|
||||
this.blurHash,
|
||||
this.error,
|
||||
this.debug = false,
|
||||
});
|
||||
|
||||
int? _extractStatusCode(dynamic error) {
|
||||
if (error == null) return null;
|
||||
final errorString = error.toString();
|
||||
// Check for HttpException with status code
|
||||
final httpExceptionRegex = RegExp(r'Invalid statusCode: (\d+)');
|
||||
final match = httpExceptionRegex.firstMatch(errorString);
|
||||
if (match != null) {
|
||||
return int.tryParse(match.group(1) ?? '');
|
||||
}
|
||||
// Check if error has statusCode property (like DioError)
|
||||
if (error.response?.statusCode != null) {
|
||||
return error.response.statusCode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (debug && error != null) {
|
||||
debugPrint('Image load error for $uri: $error');
|
||||
}
|
||||
|
||||
if (!useFallbackImage) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
|
||||
final statusCode = _extractStatusCode(error);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final minDimension = constraints.maxWidth < constraints.maxHeight
|
||||
? constraints.maxWidth
|
||||
: constraints.maxHeight;
|
||||
final iconSize = math.max(
|
||||
minDimension * 0.3,
|
||||
28,
|
||||
); // 30% of the smaller dimension
|
||||
final hasEnoughSpace = minDimension > 40;
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (blurHash != null)
|
||||
BlurHash(hash: blurHash!)
|
||||
else
|
||||
Image.asset(
|
||||
'assets/images/media-offline.jpg',
|
||||
fit: BoxFit.cover,
|
||||
key: Key('-$uri'),
|
||||
),
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getErrorIcon(statusCode),
|
||||
color: Colors.white,
|
||||
size: iconSize * 0.5,
|
||||
shadows: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasEnoughSpace && statusCode != null) ...[
|
||||
SizedBox(height: iconSize * 0.1),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: iconSize * 0.15,
|
||||
vertical: iconSize * 0.05,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(iconSize * 0.1),
|
||||
),
|
||||
child: Text(
|
||||
statusCode.toString(),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: iconSize * 0.15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getErrorIcon(int? statusCode) {
|
||||
switch (statusCode) {
|
||||
case 403:
|
||||
case 401:
|
||||
return Icons.lock_rounded;
|
||||
case 404:
|
||||
return Icons.broken_image_rounded;
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
return Icons.error_rounded;
|
||||
default:
|
||||
return Icons.broken_image_rounded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AnimatedCircularProgressIndicator extends HookWidget {
|
||||
final double? value;
|
||||
final Color? color;
|
||||
|
||||
@@ -2,14 +2,16 @@ import 'package:collection/collection.dart';
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_highlight/themes/a11y-dark.dart';
|
||||
import 'package:flutter_highlight/themes/a11y-light.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/publisher.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/screens/account/profile.dart';
|
||||
import 'package:island/screens/posts/publisher_profile.dart';
|
||||
@@ -246,7 +248,7 @@ class MarkdownTextContent extends HookConsumerWidget {
|
||||
return MarkdownGenerator(
|
||||
generators: [latexGenerator, ...generators],
|
||||
inlineSyntaxList: [
|
||||
_MetionInlineSyntax(),
|
||||
_MentionInlineSyntax(),
|
||||
_HighlightInlineSyntax(),
|
||||
_SpoilerInlineSyntax(),
|
||||
_StickerInlineSyntax(),
|
||||
@@ -259,19 +261,24 @@ class MarkdownTextContent extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MetionInlineSyntax extends markdown.InlineSyntax {
|
||||
_MetionInlineSyntax() : super(r'@[-a-zA-Z0-9_./]+');
|
||||
class _MentionInlineSyntax extends markdown.InlineSyntax {
|
||||
_MentionInlineSyntax() : super(r'(^|[^A-Za-z0-9._%+\-])(@[-A-Za-z0-9_./]+)');
|
||||
|
||||
@override
|
||||
bool onMatch(markdown.InlineParser parser, Match match) {
|
||||
final alias = match[0]!;
|
||||
final prefix = match[1] ?? '';
|
||||
final alias = match[2]!;
|
||||
|
||||
if (prefix.isNotEmpty) {
|
||||
parser.addNode(markdown.Text(prefix));
|
||||
}
|
||||
|
||||
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)])
|
||||
@@ -374,31 +381,77 @@ class MentionChipGenerator extends SpanNodeGeneratorWithTag {
|
||||
);
|
||||
}
|
||||
|
||||
class MentionChipSpanNode extends SpanNode {
|
||||
final Map<String, String> attributes;
|
||||
class _MentionChipContent extends HookConsumerWidget {
|
||||
final String mentionType;
|
||||
final String id;
|
||||
final String alias;
|
||||
final Color backgroundColor;
|
||||
final Color foregroundColor;
|
||||
final void Function(String type, String id) onTap;
|
||||
final VoidCallback onTap;
|
||||
|
||||
MentionChipSpanNode({
|
||||
required this.attributes,
|
||||
const _MentionChipContent({
|
||||
required this.mentionType,
|
||||
required this.id,
|
||||
required this.alias,
|
||||
required this.backgroundColor,
|
||||
required this.foregroundColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
InlineSpan build() {
|
||||
final alias = attributes['alias'] ?? '';
|
||||
final type = attributes['type'] ?? '';
|
||||
final id = attributes['id'] ?? '';
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isHovered = useState(false);
|
||||
|
||||
final parts = alias.substring(1).split('/');
|
||||
if (mentionType == 'accounts' || mentionType == 'publishers') {
|
||||
final data = mentionType == 'accounts'
|
||||
? ref.watch(accountProvider(id))
|
||||
: ref.watch(publisherProvider(id));
|
||||
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: InkWell(
|
||||
onTap: () => onTap(type, id),
|
||||
return data.when(
|
||||
data: (profile) {
|
||||
final picture = mentionType == 'accounts'
|
||||
? (profile as SnAccount).profile.picture
|
||||
: (profile as SnPublisher).picture;
|
||||
final icon = mentionType == 'accounts'
|
||||
? Symbols.person_rounded
|
||||
: Symbols.design_services_rounded;
|
||||
|
||||
return _buildChip(
|
||||
ProfilePictureWidget(file: picture, fallbackIcon: icon, radius: 9),
|
||||
id,
|
||||
isHovered,
|
||||
);
|
||||
},
|
||||
error: (_, _) => Text(
|
||||
alias,
|
||||
style: TextStyle(
|
||||
color: backgroundColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
loading: () => Text(
|
||||
alias,
|
||||
style: TextStyle(
|
||||
color: backgroundColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildStaticChip(mentionType, id);
|
||||
}
|
||||
|
||||
Widget _buildChip(
|
||||
Widget avatar,
|
||||
String displayName,
|
||||
ValueNotifier<bool> isHovered,
|
||||
) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
onHover: (value) => isHovered.value = value,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(
|
||||
@@ -421,61 +474,10 @@ class MentionChipSpanNode extends SpanNode {
|
||||
color: backgroundColor.withOpacity(0.5),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||
),
|
||||
child: switch (parts.length == 1 ? 'u' : parts.first) {
|
||||
'u' => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final userData = ref.watch(accountProvider(parts.last));
|
||||
return userData.when(
|
||||
data: (data) => ProfilePictureWidget(
|
||||
file: data.profile.picture,
|
||||
fallbackIcon: Symbols.person_rounded,
|
||||
radius: 9,
|
||||
),
|
||||
error: (_, _) => const Icon(Symbols.close, size: 20),
|
||||
loading: () => const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
'p' => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final pubData = ref.watch(publisherProvider(parts.last));
|
||||
return pubData.when(
|
||||
data: (data) => ProfilePictureWidget(
|
||||
file: data.picture,
|
||||
fallbackIcon: Symbols.design_services_rounded,
|
||||
radius: 9,
|
||||
),
|
||||
error: (_, _) => const Icon(Symbols.close, size: 20),
|
||||
loading: () => const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_ => Icon(
|
||||
(switch (parts.length == 1 ? 'u' : parts.first) {
|
||||
'c' => Symbols.forum_rounded,
|
||||
'r' => Symbols.group_rounded,
|
||||
_ => Symbols.person_rounded,
|
||||
}),
|
||||
size: 14,
|
||||
color: foregroundColor,
|
||||
fill: 1,
|
||||
).padding(all: 2),
|
||||
},
|
||||
child: avatar,
|
||||
),
|
||||
Text(
|
||||
parts.last,
|
||||
displayName,
|
||||
style: TextStyle(
|
||||
color: backgroundColor,
|
||||
fontSize: 14,
|
||||
@@ -485,6 +487,52 @@ class MentionChipSpanNode extends SpanNode {
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStaticChip(String type, String id) {
|
||||
final icon = switch (type) {
|
||||
'chat' => Symbols.forum_rounded,
|
||||
'realms' => Symbols.group_rounded,
|
||||
_ => Symbols.person_rounded,
|
||||
};
|
||||
|
||||
return _buildChip(
|
||||
Icon(icon, size: 14, color: foregroundColor, fill: 1).padding(all: 2),
|
||||
id,
|
||||
useState(false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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'] ?? '';
|
||||
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: _MentionChipContent(
|
||||
mentionType: type,
|
||||
id: id,
|
||||
alias: alias,
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
onTap: () => onTap(type, id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
@@ -30,7 +32,14 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
|
||||
_player = Player();
|
||||
_videoController = VideoController(_player!);
|
||||
|
||||
_player!.open(Media(widget.uri), play: widget.autoplay);
|
||||
final serverUrl = ref.read(serverUrlProvider);
|
||||
final token = ref.read(tokenProvider);
|
||||
final Map<String, String>? httpHeaders =
|
||||
widget.uri.startsWith(serverUrl) && token != null
|
||||
? {'Authorization': 'AtField ${token.token}'}
|
||||
: null;
|
||||
|
||||
_player!.open(Media(widget.uri, httpHeaders: httpHeaders), play: widget.autoplay);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
|
||||
typedef WidgetBuilder0 = Widget Function();
|
||||
typedef WidgetBuilder = Widget Function();
|
||||
|
||||
class DataSavingGate extends ConsumerWidget {
|
||||
final bool bypass;
|
||||
final WidgetBuilder0 content;
|
||||
final WidgetBuilder content;
|
||||
final Widget placeholder;
|
||||
|
||||
const DataSavingGate({
|
||||
|
||||
@@ -38,6 +38,8 @@ class ExtendedRefreshIndicator extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (kIsWeb) return null;
|
||||
|
||||
hotKeyManager.register(
|
||||
refreshHotKey,
|
||||
keyDownHandler: (_) {
|
||||
|
||||
150
lib/widgets/notification_item.dart
Normal file
150
lib/widgets/notification_item.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/notification.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
const double kNotificationBorderRadius = 8;
|
||||
|
||||
class NotificationItemWidget extends HookConsumerWidget {
|
||||
final NotificationItem item;
|
||||
final VoidCallback onDismiss;
|
||||
final bool isDesktop;
|
||||
final Animation<double> progress;
|
||||
|
||||
const NotificationItemWidget({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onDismiss,
|
||||
required this.isDesktop,
|
||||
required this.progress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (item.notification.meta['action_uri'] != null) {
|
||||
var uri = item.notification.meta['action_uri'] as String;
|
||||
if (uri.startsWith('solian://')) {
|
||||
uri = uri.replaceFirst('solian://', '');
|
||||
}
|
||||
if (uri.startsWith('/')) {
|
||||
rootNavigatorKey.currentContext?.push(
|
||||
item.notification.meta['action_uri'],
|
||||
);
|
||||
} else {
|
||||
launchUrlString(uri);
|
||||
}
|
||||
}
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (details.primaryVelocity! > 100) {
|
||||
onDismiss();
|
||||
}
|
||||
},
|
||||
onVerticalDragEnd: !isDesktop
|
||||
? (details) {
|
||||
if (details.primaryVelocity! < -100) {
|
||||
onDismiss();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: double.infinity),
|
||||
child: Stack(
|
||||
children: [
|
||||
Card(
|
||||
elevation: 4,
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(kNotificationBorderRadius),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (item.notification.meta['pfp'] != null)
|
||||
ProfilePictureWidget(
|
||||
fileId: item.notification.meta['pfp'],
|
||||
radius: 12,
|
||||
).padding(right: 12, top: 2)
|
||||
else
|
||||
Icon(
|
||||
Symbols.info,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 24,
|
||||
).padding(right: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.notification.title,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (item.notification.content.isNotEmpty)
|
||||
Text(
|
||||
item.notification.content,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
if (item.notification.subtitle.isNotEmpty)
|
||||
Text(
|
||||
item.notification.subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: progress,
|
||||
builder: (context, child) => LinearProgressIndicator(
|
||||
value: progress.value,
|
||||
minHeight: 2,
|
||||
backgroundColor: Colors.transparent,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Symbols.close,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPressed: onDismiss,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
),
|
||||
],
|
||||
).clipRRect(all: kNotificationBorderRadius),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user