Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
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.
File diff suppressed because it is too large
Load Diff
@@ -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 */
|
||||
@@ -216,6 +216,8 @@
|
||||
};
|
||||
7310A7D52EB10962002C0FD3 /* Solian Watch App */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = "Solian Watch App";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -757,14 +759,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 +820,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 +874,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 +1096,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 +1597,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 +1642,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 +1684,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 +1950,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 +1981,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>
|
||||
|
||||
@@ -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()
|
||||
attachments.append(attachment)
|
||||
lock.unlock()
|
||||
queue.async {
|
||||
attachments.append(attachment)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed to write media to temporary file: \(error.localizedDescription)")
|
||||
|
||||
BIN
ios/SolianNotificationService/SfxMessage.caf
Normal file
BIN
ios/SolianNotificationService/SfxMessage.caf
Normal file
Binary file not shown.
BIN
ios/SolianNotificationService/SfxNotification.caf
Normal file
BIN
ios/SolianNotificationService/SfxNotification.caf
Normal file
Binary file not shown.
@@ -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,40 +306,57 @@ struct NotificationWidgetEntryView: View {
|
||||
Link(destination: URL(string: "solian://notifications")!) {
|
||||
if isCompact {
|
||||
if isAccessory {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
if isCircular {
|
||||
ZStack {
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.orange)
|
||||
.padding(.leading, 1.5)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(unreadCount > 0 ? .orange : .gray)
|
||||
|
||||
Text(NSLocalizedString("notifications", comment: "Notifications"))
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if unreadCount > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Text("\(unreadCount)")
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
.padding(.horizontal, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.blue.opacity(0.5))
|
||||
)
|
||||
|
||||
Text(NSLocalizedString("unread", comment: "unread"))
|
||||
.font(.caption2)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Text("on the Solar Network")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 1.5)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "bell.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.orange)
|
||||
.padding(.leading, 1.5)
|
||||
|
||||
Text(NSLocalizedString("notifications", comment: "Notifications"))
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if unreadCount > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Text("\(unreadCount)")
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
.padding(.horizontal, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.blue.opacity(0.5))
|
||||
)
|
||||
|
||||
Text(NSLocalizedString("unread", comment: "unread"))
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
|
||||
Text("on the Solar Network")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 1.5)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -490,79 +528,100 @@ struct NotificationWidgetEntryView: View {
|
||||
@ViewBuilder
|
||||
private func EmptyView() -> some View {
|
||||
Link(destination: URL(string: "solian://notifications")!) {
|
||||
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
|
||||
HStack(spacing: 6) {
|
||||
if isCircular {
|
||||
ZStack {
|
||||
Image(systemName: "bell")
|
||||
.font(isAccessory ? .caption : .title3)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(NSLocalizedString("notifications", comment: "Notifications"))
|
||||
.font(isAccessory ? .caption2 : .headline)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
if !isAccessory {
|
||||
Text(NSLocalizedString("noNotifications", comment: "No notifications yet"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "bell")
|
||||
.font(isAccessory ? .caption : .title3)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(NSLocalizedString("notifications", comment: "Notifications"))
|
||||
.font(isAccessory ? .caption2 : .headline)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
if !isAccessory {
|
||||
Text(NSLocalizedString("noNotifications", comment: "No notifications yet"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(isAccessory ? 4 : 12)
|
||||
}
|
||||
.padding(isAccessory ? 4 : 12)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func LoadingView() -> some View {
|
||||
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.scaleEffect(isAccessory ? 0.6 : 0.8)
|
||||
Text(NSLocalizedString("loading", comment: "Loading..."))
|
||||
.font(isAccessory ? .caption2 : .caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if !isAccessory {
|
||||
Spacer()
|
||||
if isCircular {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.scaleEffect(isAccessory ? 0.6 : 0.8)
|
||||
Text(NSLocalizedString("loading", comment: "Loading..."))
|
||||
.font(isAccessory ? .caption2 : .caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if !isAccessory {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(isAccessory ? 4 : 12)
|
||||
}
|
||||
.padding(isAccessory ? 4 : 12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func ErrorView(error: String) -> some View {
|
||||
Link(destination: URL(string: "solian://notifications")!) {
|
||||
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
|
||||
HStack(spacing: 6) {
|
||||
if isCircular {
|
||||
ZStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.secondary)
|
||||
.font(isAccessory ? .caption : .title3)
|
||||
|
||||
Text(NSLocalizedString("error", comment: "Error"))
|
||||
.font(isAccessory ? .caption2 : .headline)
|
||||
Spacer()
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
if !isAccessory {
|
||||
Text(NSLocalizedString("openAppToRefresh", comment: "Open app to refresh"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.secondary)
|
||||
.font(isAccessory ? .caption : .title3)
|
||||
|
||||
Text(NSLocalizedString("error", comment: "Error"))
|
||||
.font(isAccessory ? .caption2 : .headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(nil)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Spacer()
|
||||
if !isAccessory {
|
||||
Text(NSLocalizedString("openAppToRefresh", comment: "Open app to refresh"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(nil)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(isAccessory ? 4 : 12)
|
||||
}
|
||||
.padding(isAccessory ? 4 : 12)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
231
lib/hooks/use_room_input.dart
Normal file
231
lib/hooks/use_room_input.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
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(
|
||||
data: XFile.fromData(image, mimeType: "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';
|
||||
@@ -22,6 +23,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 +101,28 @@ 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",
|
||||
);
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) {
|
||||
@@ -170,6 +195,12 @@ void main() async {
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
retry: (retryCount, error) {
|
||||
if (error is DioException && error.response?.statusCode == 404) {
|
||||
return null;
|
||||
}
|
||||
return const Duration(milliseconds: 300);
|
||||
},
|
||||
observers: [
|
||||
TalkerRiverpodObserver(
|
||||
talker: talker,
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
return $SnAccountCopyWith<$Res>(_self.account, (value) {
|
||||
$SnAccountCopyWith<$Res>? get account {
|
||||
if (_self.account == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
return $SnAccountCopyWith<$Res>(_self.related, (value) {
|
||||
$SnAccountCopyWith<$Res>? get related {
|
||||
if (_self.related == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
return $SnAccountCopyWith<$Res>(_self.account, (value) {
|
||||
$SnAccountCopyWith<$Res>? get account {
|
||||
if (_self.account == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
return $SnAccountCopyWith<$Res>(_self.related, (value) {
|
||||
$SnAccountCopyWith<$Res>? get related {
|
||||
if (_self.related == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -238,6 +240,17 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,4 +288,4 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
|
||||
_typingCooldownTimer = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
state = AsyncValue.data(
|
||||
_sortMessages([...currentMessages, ...newMessages]),
|
||||
);
|
||||
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,24 +625,27 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
await _database.deleteMessage(localMessage.id);
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
if (editingTo != null) {
|
||||
final newMessages = currentMessages
|
||||
.where((m) => m.id != localMessage.id) // remove pending message
|
||||
.map(
|
||||
(m) => m.id == editingTo.id ? updatedMessage : m,
|
||||
) // update original message
|
||||
.toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
} else {
|
||||
final newMessages = currentMessages.map((m) {
|
||||
if (m.id == localMessage.id) {
|
||||
return updatedMessage;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
if (ref.mounted) {
|
||||
final currentMessages = state.value ?? [];
|
||||
if (editingTo != null) {
|
||||
final newMessages = currentMessages
|
||||
.where((m) => m.id != localMessage.id) // remove pending message
|
||||
.map(
|
||||
(m) => m.id == editingTo.id ? updatedMessage : m,
|
||||
) // update original message
|
||||
.toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
} else {
|
||||
final newMessages = currentMessages.map((m) {
|
||||
if (m.id == localMessage.id) {
|
||||
return updatedMessage;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
}
|
||||
}
|
||||
|
||||
talker.log('Message with nonce $nonce sent successfully');
|
||||
} catch (e, stackTrace) {
|
||||
talker.log(
|
||||
@@ -660,13 +660,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
localMessage.id,
|
||||
MessageStatus.failed,
|
||||
);
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == localMessage.id) {
|
||||
return m..status = MessageStatus.failed;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
if (ref.mounted) {
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == localMessage.id) {
|
||||
return m..status = MessageStatus.failed;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
}
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
@@ -707,13 +709,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
await _database.deleteMessage(pendingMessageId);
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == pendingMessageId) {
|
||||
return updatedMessage;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
if (ref.mounted) {
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == pendingMessageId) {
|
||||
return updatedMessage;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
talker.log(
|
||||
'Failed to retry message $pendingMessageId',
|
||||
@@ -727,13 +731,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
pendingMessageId,
|
||||
MessageStatus.failed,
|
||||
);
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == pendingMessageId) {
|
||||
return m..status = MessageStatus.failed;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(_sortMessages(newMessages));
|
||||
if (ref.mounted) {
|
||||
final newMessages = (state.value ?? []).map((m) {
|
||||
if (m.id == pendingMessageId) {
|
||||
return m..status = MessageStatus.failed;
|
||||
}
|
||||
return m;
|
||||
}).toList();
|
||||
state = AsyncValue.data(_sortMessages(newMessages));
|
||||
}
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
@@ -765,21 +771,23 @@ 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 (existingIndex >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[existingIndex] = localMessage;
|
||||
state = AsyncValue.data(_sortMessages(newList));
|
||||
} else {
|
||||
state = AsyncValue.data(
|
||||
_sortMessages([localMessage, ...currentMessages]),
|
||||
);
|
||||
if (ref.mounted) {
|
||||
if (existingIndex >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[existingIndex] = localMessage;
|
||||
state = AsyncValue.data(_sortMessages(newList));
|
||||
} else {
|
||||
state = AsyncValue.data(
|
||||
_sortMessages([localMessage, ...currentMessages]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,13 +854,15 @@ 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 (index >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[index] = updatedMessage;
|
||||
state = AsyncValue.data(_sortMessages(newList));
|
||||
if (ref.mounted) {
|
||||
if (index >= 0) {
|
||||
final newList = [...currentMessages];
|
||||
newList[index] = updatedMessage;
|
||||
state = AsyncValue.data(_sortMessages(newList));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +902,12 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
await _database.saveMessageWithSender(deletedMessage);
|
||||
|
||||
if (messageIndex != -1) {
|
||||
final newList = [...currentMessages];
|
||||
newList[messageIndex] = deletedMessage;
|
||||
state = AsyncValue.data(newList);
|
||||
if (ref.mounted) {
|
||||
if (messageIndex != -1) {
|
||||
final newList = [...currentMessages];
|
||||
newList[messageIndex] = deletedMessage;
|
||||
state = AsyncValue.data(newList);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -530,4 +585,4 @@ class EnhancedFileUploader extends FileUploader {
|
||||
onProgress?.call(null, Duration.zero);
|
||||
return await completeUpload(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>>(
|
||||
AnimatedMessagesNotifier.new,
|
||||
);
|
||||
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,12 +71,9 @@ class MessageItemWrapper extends ConsumerWidget {
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
color:
|
||||
isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withOpacity(0.3)
|
||||
: null,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3)
|
||||
: null,
|
||||
child: Stack(
|
||||
children: [
|
||||
MessageItem(
|
||||
@@ -130,10 +82,9 @@ class MessageItemWrapper extends ConsumerWidget {
|
||||
key: ValueKey('item-${message.id}'),
|
||||
message: message,
|
||||
isCurrentUser: isCurrentUser,
|
||||
onAction:
|
||||
isSelectionMode
|
||||
? null
|
||||
: (action) => onMessageAction(action, message),
|
||||
onAction: isSelectionMode
|
||||
? null
|
||||
: (action) => onMessageAction(action, message),
|
||||
onJump: onJump,
|
||||
progress: attachmentProgress[message.id],
|
||||
showAvatar: isLastInGroup,
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +325,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
isCompact: true,
|
||||
item: attachments[idx],
|
||||
progress: progressMap[idx],
|
||||
isUploading: progressMap.containsKey(idx),
|
||||
onRequestUpload: () async {
|
||||
final config =
|
||||
await showModalBottomSheet<
|
||||
|
||||
@@ -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,64 +573,78 @@ class PostDetailScreen extends HookConsumerWidget {
|
||||
ref.invalidate(postProvider(id));
|
||||
ref.read(postRepliesProvider(id).notifier).refresh();
|
||||
},
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 800),
|
||||
child: PostItem(
|
||||
item: post!,
|
||||
isFullPost: true,
|
||||
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);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 800),
|
||||
child: PostActionButtons(
|
||||
post: post,
|
||||
renderingPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
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(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 800),
|
||||
child: PostItem(
|
||||
item: post!,
|
||||
isFullPost: true,
|
||||
isEmbedReply: false,
|
||||
textScale: post.type == 1 ? 1.2 : 1.1,
|
||||
onUpdate: (newItem) {
|
||||
ref
|
||||
.read(postStateProvider(id).notifier)
|
||||
.updatePost(newItem);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
onRefresh: () {
|
||||
ref.invalidate(postProvider(id));
|
||||
ref
|
||||
.read(postRepliesProvider(id).notifier)
|
||||
.refresh();
|
||||
},
|
||||
onUpdate: (newItem) {
|
||||
ref
|
||||
.read(postStateProvider(id).notifier)
|
||||
.updatePost(newItem);
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 800),
|
||||
child: PostActionButtons(
|
||||
post: post,
|
||||
renderingPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
onRefresh: () {
|
||||
ref.invalidate(postProvider(id));
|
||||
ref
|
||||
.read(postRepliesProvider(id).notifier)
|
||||
.refresh();
|
||||
},
|
||||
onUpdate: (newItem) {
|
||||
ref
|
||||
.read(postStateProvider(id).notifier)
|
||||
.updatePost(newItem);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PostRepliesList(postId: id, maxWidth: 800),
|
||||
SliverGap(MediaQuery.of(context).padding.bottom + 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
PostRepliesList(postId: id, maxWidth: 800),
|
||||
SliverGap(MediaQuery.of(context).padding.bottom + 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
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!,
|
||||
@@ -541,4 +668,4 @@ class PostDetailScreen extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: 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,
|
||||
),
|
||||
),
|
||||
child: _PinnedPostsPageView(pubName: name),
|
||||
),
|
||||
...[
|
||||
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,
|
||||
),
|
||||
onTap: () =>
|
||||
isPinnedExpanded.value = !isPinnedExpanded.value,
|
||||
),
|
||||
),
|
||||
child: _PinnedPostsPageView(pubName: name),
|
||||
),
|
||||
...[
|
||||
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(
|
||||
|
||||
537
lib/services/analytics_service.dart
Normal file
537
lib/services/analytics_service.dart
Normal file
@@ -0,0 +1,537 @@
|
||||
import 'dart:io';
|
||||
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
class AnalyticsService {
|
||||
static final AnalyticsService _instance = AnalyticsService._internal();
|
||||
factory AnalyticsService() => _instance;
|
||||
AnalyticsService._internal() {
|
||||
_init();
|
||||
}
|
||||
|
||||
FirebaseAnalytics? _analytics;
|
||||
bool _enabled = true;
|
||||
|
||||
bool get _supportsAnalytics =>
|
||||
Platform.isAndroid || Platform.isIOS || Platform.isMacOS;
|
||||
|
||||
void _init() {
|
||||
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';
|
||||
777
lib/services/app_intents/ios.dart
Normal file
777
lib/services/app_intents/ios.dart
Normal file
@@ -0,0 +1,777 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_analytics/firebase_analytics.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 (!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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -104,6 +105,13 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
if (settings.notifyWithHaptic) {
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
if (settings.soundEffects) {
|
||||
final player = AudioPlayer();
|
||||
await player.setVolume(0.75);
|
||||
await player.setAudioSource(AudioSource.asset('assets/audio/notification.mp3'));
|
||||
await player.play();
|
||||
player.dispose();
|
||||
}
|
||||
showTopSnackBar(
|
||||
globalOverlay.currentState!,
|
||||
Center(
|
||||
@@ -220,4 +228,4 @@ Future<void> _putTokenToRemote(
|
||||
"/ring/notifications/subscription",
|
||||
data: {"provider": provider, "device_token": token},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:just_audio/just_audio.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';
|
||||
@@ -31,9 +33,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,6 +55,7 @@ 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") {
|
||||
@@ -64,6 +65,16 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
talker.info(
|
||||
'[Notification] Showing in-app notification: ${notification.title}',
|
||||
);
|
||||
if (settings.notifyWithHaptic) {
|
||||
HapticFeedback.heavyImpact();
|
||||
}
|
||||
if (settings.soundEffects) {
|
||||
final player = AudioPlayer();
|
||||
await player.setVolume(0.75);
|
||||
await player.setAudioSource(AudioSource.asset('assets/audio/notification.mp3'));
|
||||
await player.play();
|
||||
player.dispose();
|
||||
}
|
||||
showTopSnackBar(
|
||||
globalOverlay.currentState!,
|
||||
Center(
|
||||
@@ -139,7 +150,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,
|
||||
@@ -207,4 +221,4 @@ Future<void> _putTokenToRemote(
|
||||
"/ring/notifications/subscription",
|
||||
data: {"provider": provider, "device_token": token},
|
||||
);
|
||||
}
|
||||
}
|
||||
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,200 +27,198 @@ 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();
|
||||
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: [
|
||||
if (data.profile.background != null)
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: CloudImageWidget(file: data.profile.background),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
child: Badge(
|
||||
isLabelVisible: true,
|
||||
padding: EdgeInsets.all(2),
|
||||
label: Icon(
|
||||
Symbols.launch,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
offset: Offset(4, 28),
|
||||
child: ProfilePictureWidget(file: data.profile.picture),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.pushNamed(
|
||||
'accountProfile',
|
||||
pathParameters: {'name': data.name},
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountName(
|
||||
account: data,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text('@${data.name}').fontSize(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
AccountStatusWidget(uname: data.name, padding: EdgeInsets.zero),
|
||||
Tooltip(
|
||||
message: 'creditsStatus'.tr(),
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.attribution,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text(
|
||||
'${data.profile.socialCredits.toStringAsFixed(2)} pts',
|
||||
).fontSize(12),
|
||||
switch (data.profile.socialCreditsLevel) {
|
||||
-1 => Text('socialCreditsLevelPoor').tr(),
|
||||
0 => Text('socialCreditsLevelNormal').tr(),
|
||||
1 => Text('socialCreditsLevelGood').tr(),
|
||||
2 => Text('socialCreditsLevelExcellent').tr(),
|
||||
_ => Text('unknown').tr(),
|
||||
}.fontSize(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (data.automatedId != null)
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.smart_toy,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text('accountAutomated').tr().fontSize(12),
|
||||
],
|
||||
),
|
||||
if (data.profile.timeZone.isNotEmpty && !kIsWeb)
|
||||
() {
|
||||
try {
|
||||
final tzInfo = getTzInfo(data.profile.timeZone);
|
||||
return Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.alarm,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text(
|
||||
tzInfo.$2.formatCustomGlobal('HH:mm'),
|
||||
).fontSize(12),
|
||||
Text(tzInfo.$1.formatOffsetLocal()).fontSize(12),
|
||||
],
|
||||
).padding(top: 2);
|
||||
} catch (e) {
|
||||
return Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.alarm,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text('timezoneNotFound'.tr()).fontSize(12),
|
||||
],
|
||||
).padding(top: 2);
|
||||
}
|
||||
}(),
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(Symbols.stairs, size: 17, fill: 1).padding(right: 2),
|
||||
Text(
|
||||
'levelingProgressLevel'.tr(
|
||||
args: [data.profile.level.toString()],
|
||||
),
|
||||
).fontSize(12),
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message:
|
||||
'${(data.profile.levelingProgress * 100).toStringAsFixed(2)}%',
|
||||
child: LinearProgressIndicator(
|
||||
value: data.profile.levelingProgress,
|
||||
stopIndicatorRadius: 0,
|
||||
trackGap: 0,
|
||||
minHeight: 4,
|
||||
).padding(top: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(top: 2),
|
||||
if (data.badges.isNotEmpty)
|
||||
BadgeList(badges: data.badges).padding(top: 12),
|
||||
ActivityPresenceWidget(
|
||||
uname: uname,
|
||||
isCompact: true,
|
||||
compactPadding: const EdgeInsets.only(top: 12),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
],
|
||||
),
|
||||
error: (err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => ref.invalidate(accountProvider(uname)),
|
||||
),
|
||||
loading: () => SizedBox(
|
||||
width: width,
|
||||
height: width,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(),
|
||||
).center(),
|
||||
),
|
||||
);
|
||||
|
||||
return PopupCard(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: account.when(
|
||||
data:
|
||||
(data) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (data.profile.background != null)
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: CloudImageWidget(file: data.profile.background),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
child: Badge(
|
||||
isLabelVisible: true,
|
||||
padding: EdgeInsets.all(2),
|
||||
label: Icon(
|
||||
Symbols.launch,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
offset: Offset(4, 28),
|
||||
child: ProfilePictureWidget(
|
||||
file: data.profile.picture,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.pushNamed(
|
||||
'accountProfile',
|
||||
pathParameters: {'name': data.name},
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountName(
|
||||
account: data,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text('@${data.name}').fontSize(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
AccountStatusWidget(
|
||||
uname: data.name,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
Tooltip(
|
||||
message: 'creditsStatus'.tr(),
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.attribution,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text(
|
||||
'${data.profile.socialCredits.toStringAsFixed(2)} pts',
|
||||
).fontSize(12),
|
||||
switch (data.profile.socialCreditsLevel) {
|
||||
-1 => Text('socialCreditsLevelPoor').tr(),
|
||||
0 => Text('socialCreditsLevelNormal').tr(),
|
||||
1 => Text('socialCreditsLevelGood').tr(),
|
||||
2 => Text('socialCreditsLevelExcellent').tr(),
|
||||
_ => Text('unknown').tr(),
|
||||
}.fontSize(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (data.automatedId != null)
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.smart_toy,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text('accountAutomated').tr().fontSize(12),
|
||||
],
|
||||
),
|
||||
if (data.profile.timeZone.isNotEmpty && !kIsWeb)
|
||||
() {
|
||||
try {
|
||||
final tzInfo = getTzInfo(data.profile.timeZone);
|
||||
return Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.alarm,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text(
|
||||
tzInfo.$2.formatCustomGlobal('HH:mm'),
|
||||
).fontSize(12),
|
||||
Text(
|
||||
tzInfo.$1.formatOffsetLocal(),
|
||||
).fontSize(12),
|
||||
],
|
||||
).padding(top: 2);
|
||||
} catch (e) {
|
||||
return Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.alarm,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text('timezoneNotFound'.tr()).fontSize(12),
|
||||
],
|
||||
).padding(top: 2);
|
||||
}
|
||||
}(),
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.stairs,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text(
|
||||
'levelingProgressLevel'.tr(
|
||||
args: [data.profile.level.toString()],
|
||||
),
|
||||
).fontSize(12),
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message:
|
||||
'${(data.profile.levelingProgress * 100).toStringAsFixed(2)}%',
|
||||
child: LinearProgressIndicator(
|
||||
value: data.profile.levelingProgress,
|
||||
stopIndicatorRadius: 0,
|
||||
trackGap: 0,
|
||||
minHeight: 4,
|
||||
).padding(top: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(top: 2),
|
||||
if (data.badges.isNotEmpty)
|
||||
BadgeList(badges: data.badges).padding(top: 12),
|
||||
ActivityPresenceWidget(
|
||||
uname: uname,
|
||||
isCompact: true,
|
||||
compactPadding: const EdgeInsets.only(top: 12),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
],
|
||||
),
|
||||
error:
|
||||
(err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => ref.invalidate(accountProvider(uname)),
|
||||
),
|
||||
loading:
|
||||
() => SizedBox(
|
||||
width: width,
|
||||
height: width,
|
||||
child:
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(),
|
||||
).center(),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -259,24 +259,26 @@ void showErrorAlert(dynamic err, {IconData? icon}) {
|
||||
title: null,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.error_outline_rounded,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'somethingWentWrong'.tr(),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(text),
|
||||
const Gap(8),
|
||||
],
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.error_outline_rounded,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'somethingWentWrong'.tr(),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Gap(8),
|
||||
SelectableText(text),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
||||
@@ -17,7 +17,7 @@ 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/task_overlay.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shake/shake.dart';
|
||||
@@ -146,16 +146,19 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
},
|
||||
);
|
||||
|
||||
ShakeDetector detector = ShakeDetector.autoStart(
|
||||
onPhoneShake: (_) {
|
||||
showPalette.value = true;
|
||||
},
|
||||
);
|
||||
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 +258,7 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
_WebSocketIndicator(),
|
||||
const UploadOverlay(),
|
||||
const TaskOverlay(),
|
||||
if (showPalette.value)
|
||||
CommandPattleWidget(onDismiss: () => showPalette.value = false),
|
||||
],
|
||||
@@ -268,7 +271,7 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
children: [
|
||||
Positioned.fill(child: child),
|
||||
_WebSocketIndicator(),
|
||||
const UploadOverlay(),
|
||||
const TaskOverlay(),
|
||||
if (showPalette.value)
|
||||
CommandPattleWidget(onDismiss: () => showPalette.value = false),
|
||||
],
|
||||
@@ -357,44 +360,34 @@ 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(
|
||||
extendBody: extendBody ?? true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Column(
|
||||
children: [
|
||||
IgnorePointer(
|
||||
child: SizedBox(
|
||||
height: appBar != null ? appBarHeight + safeTop : 0,
|
||||
),
|
||||
final builtWidget = Scaffold(
|
||||
extendBody: extendBody ?? true,
|
||||
extendBodyBehindAppBar: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Column(
|
||||
children: [
|
||||
IgnorePointer(
|
||||
child: SizedBox(
|
||||
height: appBar != null ? appBarHeight + safeTop : 0,
|
||||
),
|
||||
if (body != null) Expanded(child: body!),
|
||||
],
|
||||
),
|
||||
appBar: appBar,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
bottomSheet: bottomSheet,
|
||||
drawer: drawer,
|
||||
endDrawer: endDrawer,
|
||||
floatingActionButton: floatingActionButton,
|
||||
floatingActionButtonAnimator: floatingActionButtonAnimator,
|
||||
onDrawerChanged: onDrawerChanged,
|
||||
onEndDrawerChanged: onEndDrawerChanged,
|
||||
),
|
||||
if (body != null) Expanded(child: body!),
|
||||
],
|
||||
),
|
||||
appBar: appBar,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
bottomSheet: bottomSheet,
|
||||
drawer: drawer,
|
||||
endDrawer: endDrawer,
|
||||
floatingActionButton: floatingActionButton,
|
||||
floatingActionButtonAnimator: floatingActionButtonAnimator,
|
||||
onDrawerChanged: onDrawerChanged,
|
||||
onEndDrawerChanged: onEndDrawerChanged,
|
||||
);
|
||||
|
||||
return noBackground
|
||||
@@ -639,4 +632,4 @@ class _WebSocketIndicator extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,6 +458,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,6 +86,7 @@ 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;
|
||||
@@ -96,6 +98,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
super.key,
|
||||
required this.item,
|
||||
this.progress,
|
||||
this.isUploading = false,
|
||||
this.onRequestUpload,
|
||||
this.onMove,
|
||||
this.onDelete,
|
||||
@@ -125,79 +128,72 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'rename'.tr(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 24,
|
||||
),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fileName'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: errorMessage,
|
||||
),
|
||||
),
|
||||
builder: (context) => SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'rename'.tr(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fileName'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: errorMessage,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final newName = nameController.text.trim();
|
||||
if (newName.isEmpty) {
|
||||
errorMessage = 'fieldCannotBeEmpty'.tr();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.isOnCloud) {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.patch(
|
||||
'/drive/files/${item.data.id}/name',
|
||||
data: jsonEncode(newName),
|
||||
);
|
||||
final newData = item.data;
|
||||
newData.name = newName;
|
||||
onUpdate?.call(
|
||||
item.copyWith(
|
||||
data: newData,
|
||||
displayName: newName,
|
||||
),
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
} else {
|
||||
// Local file rename
|
||||
onUpdate?.call(item.copyWith(displayName: newName));
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Text('rename'.tr()),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final newName = nameController.text.trim();
|
||||
if (newName.isEmpty) {
|
||||
errorMessage = 'fieldCannotBeEmpty'.tr();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.isOnCloud) {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.patch(
|
||||
'/drive/files/${item.data.id}/name',
|
||||
data: jsonEncode(newName),
|
||||
);
|
||||
final newData = item.data;
|
||||
newData.name = newName;
|
||||
onUpdate?.call(
|
||||
item.copyWith(data: newData, displayName: newName),
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
} else {
|
||||
// Local file rename
|
||||
onUpdate?.call(item.copyWith(displayName: newName));
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Text('rename'.tr()),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,92 +201,260 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'markAsSensitive'.tr(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 24,
|
||||
builder: (context) => SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'markAsSensitive'.tr(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
// Sensitive categories checklist
|
||||
SensitiveMarksSelector(
|
||||
key: _sensitiveSelectorKey,
|
||||
initial: (item.data.sensitiveMarks ?? [])
|
||||
.map((e) => e as int)
|
||||
.cast<int>()
|
||||
.toList(),
|
||||
onChanged: (marks) {
|
||||
// Update local data immediately (optimistic)
|
||||
final newData = item.data;
|
||||
newData.sensitiveMarks = marks;
|
||||
final updatedFile = item.copyWith(data: newData);
|
||||
onUpdate?.call(item.copyWith(data: updatedFile));
|
||||
},
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Sensitive categories checklist
|
||||
SensitiveMarksSelector(
|
||||
key: _sensitiveSelectorKey,
|
||||
initial:
|
||||
(item.data.sensitiveMarks ?? [])
|
||||
.map((e) => e as int)
|
||||
.cast<int>()
|
||||
.toList(),
|
||||
onChanged: (marks) {
|
||||
// Update local data immediately (optimistic)
|
||||
final newData = item.data;
|
||||
newData.sensitiveMarks = marks;
|
||||
final updatedFile = item.copyWith(data: newData);
|
||||
onUpdate?.call(item.copyWith(data: updatedFile));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
// Use the current selections from stateful selector via GlobalKey
|
||||
final selectorState =
|
||||
_sensitiveSelectorKey.currentState;
|
||||
final marks = selectorState?.current ?? <int>[];
|
||||
await apiClient.put(
|
||||
'/drive/files/${item.data.id}/marks',
|
||||
data: jsonEncode({'sensitive_marks': marks}),
|
||||
);
|
||||
final newData = item.data as SnCloudFile;
|
||||
final updatedFile = item.copyWith(
|
||||
data: newData.copyWith(sensitiveMarks: marks),
|
||||
);
|
||||
onUpdate?.call(updatedFile);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
},
|
||||
child: Text('confirm'.tr()),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
// Use the current selections from stateful selector via GlobalKey
|
||||
final selectorState = _sensitiveSelectorKey.currentState;
|
||||
final marks = selectorState?.current ?? <int>[];
|
||||
await apiClient.put(
|
||||
'/drive/files/${item.data.id}/marks',
|
||||
data: jsonEncode({'sensitive_marks': marks}),
|
||||
);
|
||||
final newData = item.data as SnCloudFile;
|
||||
final updatedFile = item.copyWith(
|
||||
data: newData.copyWith(sensitiveMarks: marks),
|
||||
);
|
||||
onUpdate?.call(updatedFile);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
},
|
||||
child: Text('confirm'.tr()),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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;
|
||||
var ratio = item.isOnCloud
|
||||
? (item.data.fileMeta?['ratio'] is num
|
||||
? item.data.fileMeta!['ratio'].toDouble()
|
||||
: null)
|
||||
: null;
|
||||
|
||||
final innerContentWidget = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
HookBuilder(
|
||||
key: ValueKey(item.hashCode),
|
||||
builder: (context) {
|
||||
final fallbackIcon = switch (item.type) {
|
||||
UniversalFileType.video => Symbols.video_file,
|
||||
UniversalFileType.audio => Symbols.audio_file,
|
||||
UniversalFileType.image => Symbols.image,
|
||||
_ => Symbols.insert_drive_file,
|
||||
};
|
||||
|
||||
final mimeType = FileUploader.getMimeType(item);
|
||||
|
||||
if (item.isOnCloud) {
|
||||
return CloudFileWidget(item: item.data);
|
||||
} else if (item.data is XFile) {
|
||||
final file = item.data as XFile;
|
||||
if (file.path.isEmpty) {
|
||||
return FutureBuilder<Uint8List>(
|
||||
future: file.readAsBytes(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Image.memory(snapshot.data!);
|
||||
}
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
switch (item.type) {
|
||||
case UniversalFileType.image:
|
||||
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: [
|
||||
Icon(fallbackIcon),
|
||||
const Gap(6),
|
||||
Text(
|
||||
_getDisplayName(),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
FutureBuilder(
|
||||
future: file.length(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final size = snapshot.data as int;
|
||||
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:
|
||||
return Image.memory(item.data);
|
||||
default:
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(fallbackIcon),
|
||||
const Gap(6),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
Text(formatFileSize(item.data.length)).fontSize(11),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
return Placeholder();
|
||||
},
|
||||
),
|
||||
if (isUploading && progress != null && (progress ?? 0) > 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(
|
||||
'${(progress! * 100).toStringAsFixed(2)}%',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Gap(6),
|
||||
Center(
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final contentWidget = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -298,128 +462,13 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Stack(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: ratio,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Builder(
|
||||
key: ValueKey(item.hashCode),
|
||||
builder: (context) {
|
||||
final fallbackIcon = switch (item.type) {
|
||||
UniversalFileType.video => Symbols.video_file,
|
||||
UniversalFileType.audio => Symbols.audio_file,
|
||||
UniversalFileType.image => Symbols.image,
|
||||
_ => Symbols.insert_drive_file,
|
||||
};
|
||||
|
||||
final mimeType = FileUploader.getMimeType(item);
|
||||
|
||||
if (item.isOnCloud) {
|
||||
return CloudFileWidget(item: item.data);
|
||||
} else if (item.data is XFile) {
|
||||
final file = item.data as XFile;
|
||||
if (file.path.isEmpty) {
|
||||
return FutureBuilder<Uint8List>(
|
||||
future: file.readAsBytes(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Image.memory(snapshot.data!);
|
||||
}
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
switch (item.type) {
|
||||
case UniversalFileType.image:
|
||||
return kIsWeb
|
||||
? Image.network(file.path)
|
||||
: Image.file(File(file.path));
|
||||
default:
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(fallbackIcon),
|
||||
const Gap(6),
|
||||
Text(
|
||||
_getDisplayName(),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
FutureBuilder(
|
||||
future: file.length(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final size = snapshot.data as int;
|
||||
return Text(
|
||||
formatFileSize(size),
|
||||
).fontSize(11);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
} else if (item is List<int> || item is Uint8List) {
|
||||
switch (item.type) {
|
||||
case UniversalFileType.image:
|
||||
return Image.memory(item.data);
|
||||
default:
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(fallbackIcon),
|
||||
const Gap(6),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
Text(
|
||||
formatFileSize(item.data.length),
|
||||
).fontSize(11),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
return Placeholder();
|
||||
},
|
||||
),
|
||||
if (progress != null)
|
||||
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: [
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).center(),
|
||||
if (ratio != null)
|
||||
AspectRatio(
|
||||
aspectRatio: ratio,
|
||||
child: innerContentWidget,
|
||||
).center()
|
||||
else
|
||||
IntrinsicHeight(child: innerContentWidget),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -497,8 +546,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,40 +557,39 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child:
|
||||
(item.isOnCloud)
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.cloud,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
child: (item.isOnCloud)
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.cloud,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
if (!isCompact) const Gap(8),
|
||||
if (!isCompact)
|
||||
Text(
|
||||
'attachmentOnCloud'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
if (!isCompact) const Gap(8),
|
||||
if (!isCompact)
|
||||
Text(
|
||||
'attachmentOnCloud'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.cloud_off,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.cloud_off,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
if (!isCompact) const Gap(8),
|
||||
if (!isCompact)
|
||||
Text(
|
||||
'attachmentOnDevice'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
if (!isCompact) const Gap(8),
|
||||
if (!isCompact)
|
||||
Text(
|
||||
'attachmentOnDevice'.tr(),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -552,49 +601,48 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
return ContextMenuWidget(
|
||||
menuProvider:
|
||||
(MenuRequest request) => Menu(
|
||||
children: [
|
||||
if (item.isOnDevice && item.type == UniversalFileType.image)
|
||||
MenuAction(
|
||||
title: 'crop'.tr(),
|
||||
image: MenuImage.icon(Symbols.crop),
|
||||
callback: () async {
|
||||
final result = await cropImage(
|
||||
context,
|
||||
image: item.data,
|
||||
replacePath: true,
|
||||
);
|
||||
if (result == null) return;
|
||||
onUpdate?.call(item.copyWith(data: result));
|
||||
},
|
||||
),
|
||||
if (item.isOnDevice)
|
||||
MenuAction(
|
||||
title: 'rename'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () async {
|
||||
await _showRenameSheet(context, ref);
|
||||
},
|
||||
),
|
||||
if (item.isOnCloud)
|
||||
MenuAction(
|
||||
title: 'rename'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () async {
|
||||
await _showRenameSheet(context, ref);
|
||||
},
|
||||
),
|
||||
if (item.isOnCloud)
|
||||
MenuAction(
|
||||
title: 'markAsSensitive'.tr(),
|
||||
image: MenuImage.icon(Symbols.no_adult_content),
|
||||
callback: () async {
|
||||
await _showSensitiveDialog(context, ref);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
menuProvider: (MenuRequest request) => Menu(
|
||||
children: [
|
||||
if (item.isOnDevice && item.type == UniversalFileType.image)
|
||||
MenuAction(
|
||||
title: 'crop'.tr(),
|
||||
image: MenuImage.icon(Symbols.crop),
|
||||
callback: () async {
|
||||
final result = await cropImage(
|
||||
context,
|
||||
image: item.data,
|
||||
replacePath: true,
|
||||
);
|
||||
if (result == null) return;
|
||||
onUpdate?.call(item.copyWith(data: result));
|
||||
},
|
||||
),
|
||||
if (item.isOnDevice)
|
||||
MenuAction(
|
||||
title: 'rename'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () async {
|
||||
await _showRenameSheet(context, ref);
|
||||
},
|
||||
),
|
||||
if (item.isOnCloud)
|
||||
MenuAction(
|
||||
title: 'rename'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () async {
|
||||
await _showRenameSheet(context, ref);
|
||||
},
|
||||
),
|
||||
if (item.isOnCloud)
|
||||
MenuAction(
|
||||
title: 'markAsSensitive'.tr(),
|
||||
image: MenuImage.icon(Symbols.no_adult_content),
|
||||
callback: () async {
|
||||
await _showSensitiveDialog(context, ref);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
child: contentWidget,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,22 +244,20 @@ class CloudFileList extends HookConsumerWidget {
|
||||
minWidth: minWidth ?? 0,
|
||||
maxWidth: files.length == 1 ? maxWidth : double.infinity,
|
||||
),
|
||||
child:
|
||||
(ratio == null && isImage)
|
||||
? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
|
||||
: (ratio == null && isAudio)
|
||||
? IntrinsicHeight(child: widgetItem)
|
||||
: AspectRatio(
|
||||
aspectRatio: ratio?.toDouble() ?? 1,
|
||||
child: widgetItem,
|
||||
),
|
||||
child: (ratio == null && isImage)
|
||||
? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
|
||||
: (ratio == null && isAudio)
|
||||
? IntrinsicHeight(child: widgetItem)
|
||||
: AspectRatio(
|
||||
aspectRatio: ratio?.toDouble() ?? 1,
|
||||
child: widgetItem,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final allImages =
|
||||
!files.any(
|
||||
(e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
|
||||
);
|
||||
final allImages = !files.any(
|
||||
(e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
|
||||
);
|
||||
|
||||
if (allImages) {
|
||||
return ConstrainedBox(
|
||||
@@ -270,10 +268,9 @@ class CloudFileList extends HookConsumerWidget {
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth =
|
||||
constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.of(context).size.width;
|
||||
final availableWidth = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.of(context).size.width;
|
||||
final itemExtent = math.min(
|
||||
math.min(availableWidth * 0.75, maxWidth * 0.75).toDouble(),
|
||||
640.0,
|
||||
@@ -339,10 +336,9 @@ class CloudFileList extends HookConsumerWidget {
|
||||
padding: padding,
|
||||
itemBuilder: (context, index) {
|
||||
return AspectRatio(
|
||||
aspectRatio:
|
||||
files[index].fileMeta?['ratio'] is num
|
||||
? files[index].fileMeta!['ratio'].toDouble()
|
||||
: 1.0,
|
||||
aspectRatio: files[index].fileMeta?['ratio'] is num
|
||||
? files[index].fileMeta!['ratio'].toDouble()
|
||||
: 1.0,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
@@ -440,40 +436,68 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final bool fullyUnlocked = !lockedByDS && !lockedByMature;
|
||||
Widget fg =
|
||||
fullyUnlocked
|
||||
? (isImage
|
||||
? CloudFileWidget(
|
||||
Widget fg = fullyUnlocked
|
||||
? (isImage
|
||||
? CloudFileWidget(
|
||||
item: file,
|
||||
heroTag: heroTag,
|
||||
noBlurhash: true,
|
||||
fit: fit,
|
||||
useInternalGate: false,
|
||||
)
|
||||
: CloudFileWidget(
|
||||
: CloudFileWidget(
|
||||
item: file,
|
||||
heroTag: heroTag,
|
||||
fit: fit,
|
||||
useInternalGate: false,
|
||||
))
|
||||
: const SizedBox.shrink();
|
||||
: const SizedBox.shrink();
|
||||
|
||||
Widget overlays;
|
||||
if (lockedByDS) {
|
||||
overlays = _DataSavingOverlay();
|
||||
} else if (file.sensitiveMarks.isNotEmpty) {
|
||||
overlays = _SensitiveOverlay(
|
||||
file: file,
|
||||
isRevealed: showMature.value,
|
||||
onHide: () => showMature.value = false,
|
||||
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,
|
||||
)
|
||||
: 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,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -29,8 +30,16 @@ class UniversalImage extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loaded = useState(false);
|
||||
final isCached = useState<bool?>(null);
|
||||
final isSvgImage = isSvg || uri.toLowerCase().endsWith('.svg');
|
||||
|
||||
useEffect(() {
|
||||
DefaultCacheManager().getFileFromCache(uri).then((fileInfo) {
|
||||
isCached.value = fileInfo != null;
|
||||
});
|
||||
return null;
|
||||
}, [uri]);
|
||||
|
||||
if (isSvgImage) {
|
||||
return SvgPicture.network(
|
||||
uri,
|
||||
@@ -59,42 +68,69 @@ class UniversalImage extends HookWidget {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (blurHash != null) BlurHash(hash: blurHash!),
|
||||
CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
memCacheHeight: cacheHeight,
|
||||
memCacheWidth: cacheWidth,
|
||||
progressIndicatorBuilder: (context, url, progress) {
|
||||
return Center(
|
||||
child: AnimatedCircularProgressIndicator(
|
||||
value: progress.progress,
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
),
|
||||
);
|
||||
},
|
||||
imageBuilder: (context, imageProvider) {
|
||||
Future(() => loaded.value = true);
|
||||
return AnimatedOpacity(
|
||||
opacity: loaded.value ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Image(
|
||||
image: imageProvider,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) => useFallbackImage
|
||||
? Image.asset(
|
||||
'assets/images/media-offline.jpg',
|
||||
fit: BoxFit.cover,
|
||||
key: Key('image-broke-$uri'),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
),
|
||||
if (isCached.value == null)
|
||||
Center(child: CircularProgressIndicator())
|
||||
else if (isCached.value!)
|
||||
CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
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) => useFallbackImage
|
||||
? Image.asset(
|
||||
'assets/images/media-offline.jpg',
|
||||
fit: BoxFit.cover,
|
||||
key: Key('image-broke-$uri'),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
)
|
||||
else
|
||||
CachedNetworkImage(
|
||||
imageUrl: uri,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
memCacheHeight: cacheHeight,
|
||||
memCacheWidth: cacheWidth,
|
||||
progressIndicatorBuilder: (context, url, progress) {
|
||||
return Center(
|
||||
child: AnimatedCircularProgressIndicator(
|
||||
value: progress.progress,
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
),
|
||||
);
|
||||
},
|
||||
imageBuilder: (context, imageProvider) {
|
||||
Future(() {
|
||||
if (context.mounted) loaded.value = true;
|
||||
});
|
||||
return AnimatedOpacity(
|
||||
opacity: loaded.value ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Image(
|
||||
image: imageProvider,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) => useFallbackImage
|
||||
? Image.asset(
|
||||
'assets/images/media-offline.jpg',
|
||||
fit: BoxFit.cover,
|
||||
key: Key('image-broke-$uri'),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -136,4 +172,4 @@ class AnimatedCircularProgressIndicator extends HookWidget {
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,6 +381,130 @@ class MentionChipGenerator extends SpanNodeGeneratorWithTag {
|
||||
);
|
||||
}
|
||||
|
||||
class _MentionChipContent extends HookConsumerWidget {
|
||||
final String mentionType;
|
||||
final String id;
|
||||
final String alias;
|
||||
final Color backgroundColor;
|
||||
final Color foregroundColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _MentionChipContent({
|
||||
required this.mentionType,
|
||||
required this.id,
|
||||
required this.alias,
|
||||
required this.backgroundColor,
|
||||
required this.foregroundColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isHovered = useState(false);
|
||||
|
||||
if (mentionType == 'accounts' || mentionType == 'publishers') {
|
||||
final data = mentionType == 'accounts'
|
||||
? ref.watch(accountProvider(id))
|
||||
: ref.watch(publisherProvider(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(
|
||||
left: 5,
|
||||
right: 7,
|
||||
top: 2.5,
|
||||
bottom: 2.5,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 6,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withOpacity(0.5),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||
),
|
||||
child: avatar,
|
||||
),
|
||||
Text(
|
||||
displayName,
|
||||
style: TextStyle(
|
||||
color: backgroundColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -393,98 +524,15 @@ class MentionChipSpanNode extends SpanNode {
|
||||
final type = attributes['type'] ?? '';
|
||||
final id = attributes['id'] ?? '';
|
||||
|
||||
final parts = alias.substring(1).split('/');
|
||||
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: InkWell(
|
||||
child: _MentionChipContent(
|
||||
mentionType: type,
|
||||
id: id,
|
||||
alias: alias,
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
onTap: () => onTap(type, id),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 5,
|
||||
right: 7,
|
||||
top: 2.5,
|
||||
bottom: 2.5,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 6,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withOpacity(0.5),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||
),
|
||||
child: 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),
|
||||
},
|
||||
),
|
||||
Text(
|
||||
parts.last,
|
||||
style: TextStyle(
|
||||
color: backgroundColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -71,14 +71,14 @@ class ComposeAttachments extends ConsumerWidget {
|
||||
isCompact: isCompact,
|
||||
item: state.attachments.value[idx],
|
||||
progress: progressMap[idx],
|
||||
isUploading: progressMap.containsKey(idx),
|
||||
onRequestUpload: () async {
|
||||
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||
context: ref.context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder:
|
||||
(context) =>
|
||||
AttachmentUploaderSheet(ref: ref, state: state, index: idx),
|
||||
builder: (context) =>
|
||||
AttachmentUploaderSheet(ref: ref, state: state, index: idx),
|
||||
);
|
||||
if (config != null) {
|
||||
await ComposeLogic.uploadAttachment(
|
||||
@@ -146,19 +146,21 @@ class ArticleComposeAttachments extends ConsumerWidget {
|
||||
isCompact: true,
|
||||
item: attachments[idx],
|
||||
progress: progressMap[idx],
|
||||
isUploading: progressMap.containsKey(idx),
|
||||
onRequestUpload: () async {
|
||||
final config = await showModalBottomSheet<
|
||||
AttachmentUploadConfig
|
||||
>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
state: state,
|
||||
index: idx,
|
||||
),
|
||||
);
|
||||
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,
|
||||
@@ -168,24 +170,15 @@ class ArticleComposeAttachments extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
},
|
||||
onUpdate:
|
||||
(value) => ComposeLogic.updateAttachment(
|
||||
state,
|
||||
value,
|
||||
idx,
|
||||
),
|
||||
onDelete:
|
||||
() => ComposeLogic.deleteAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
),
|
||||
onInsert:
|
||||
() => ComposeLogic.insertAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
),
|
||||
onUpdate: (value) => ComposeLogic.updateAttachment(
|
||||
state,
|
||||
value,
|
||||
idx,
|
||||
),
|
||||
onDelete: () =>
|
||||
ComposeLogic.deleteAttachment(ref, state, idx),
|
||||
onInsert: () =>
|
||||
ComposeLogic.insertAttachment(ref, state, idx),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -27,6 +27,7 @@ import 'package:island/widgets/post/compose_recorder.dart';
|
||||
import 'package:island/pods/drive/file_pool.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/services/analytics_service.dart';
|
||||
|
||||
class ComposeState {
|
||||
final TextEditingController titleController;
|
||||
@@ -738,6 +739,17 @@ class ComposeLogic {
|
||||
onSuccess();
|
||||
eventBus.fire(PostCreatedEvent());
|
||||
|
||||
final postTypeStr = state.postType == 0 ? 'regular' : 'article';
|
||||
final visibilityStr = state.visibility.value.toString();
|
||||
final publisherId = state.currentPublisher.value?.id ?? 'unknown';
|
||||
|
||||
AnalyticsService().logPostCreated(
|
||||
postTypeStr,
|
||||
visibilityStr,
|
||||
state.attachments.value.isNotEmpty,
|
||||
publisherId,
|
||||
);
|
||||
|
||||
return post;
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
|
||||
@@ -28,6 +28,7 @@ import 'package:island/widgets/post/compose_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 'package:island/services/analytics_service.dart';
|
||||
|
||||
const kAvailableStickers = {
|
||||
'angry',
|
||||
@@ -67,6 +68,7 @@ class PostActionableItem extends HookConsumerWidget {
|
||||
final bool isEmbedReply;
|
||||
final bool isEmbedOpenable;
|
||||
final bool isCompact;
|
||||
final bool hideAttachments;
|
||||
final double? borderRadius;
|
||||
final VoidCallback? onRefresh;
|
||||
final Function(SnPost)? onUpdate;
|
||||
@@ -80,6 +82,7 @@ class PostActionableItem extends HookConsumerWidget {
|
||||
this.isEmbedReply = true,
|
||||
this.isEmbedOpenable = false,
|
||||
this.isCompact = false,
|
||||
this.hideAttachments = false,
|
||||
this.borderRadius,
|
||||
this.onRefresh,
|
||||
this.onUpdate,
|
||||
@@ -110,6 +113,7 @@ class PostActionableItem extends HookConsumerWidget {
|
||||
isEmbedOpenable: isEmbedOpenable,
|
||||
isTextSelectable: false,
|
||||
isCompact: isCompact,
|
||||
hideAttachments: hideAttachments,
|
||||
onRefresh: onRefresh,
|
||||
onUpdate: onUpdate,
|
||||
onOpen: onOpen,
|
||||
@@ -308,6 +312,7 @@ class PostItem extends HookConsumerWidget {
|
||||
final bool isTextSelectable;
|
||||
final bool isTranslatable;
|
||||
final bool isCompact;
|
||||
final bool hideAttachments;
|
||||
final double? textScale;
|
||||
final VoidCallback? onRefresh;
|
||||
final Function(SnPost)? onUpdate;
|
||||
@@ -323,6 +328,7 @@ class PostItem extends HookConsumerWidget {
|
||||
this.isTextSelectable = true,
|
||||
this.isTranslatable = true,
|
||||
this.isCompact = false,
|
||||
this.hideAttachments = false,
|
||||
this.textScale,
|
||||
this.onRefresh,
|
||||
this.onUpdate,
|
||||
@@ -362,7 +368,15 @@ class PostItem extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
HapticFeedback.heavyImpact();
|
||||
|
||||
AnalyticsService().logPostReacted(
|
||||
item.id,
|
||||
symbol,
|
||||
attitude,
|
||||
isRemoving,
|
||||
);
|
||||
});
|
||||
|
||||
reacting.value = false;
|
||||
}
|
||||
|
||||
@@ -569,6 +583,7 @@ class PostItem extends HookConsumerWidget {
|
||||
isTextSelectable: isTextSelectable,
|
||||
translationSection: translationSection,
|
||||
renderingPadding: renderingPadding,
|
||||
hideAttachments: hideAttachments,
|
||||
),
|
||||
if (item.embedView != null)
|
||||
EmbedViewRenderer(
|
||||
|
||||
@@ -10,6 +10,7 @@ class PostItemSkeleton extends StatelessWidget {
|
||||
final bool isEmbedReply;
|
||||
final bool isCompact;
|
||||
final double? borderRadius;
|
||||
final double maxWidth;
|
||||
|
||||
const PostItemSkeleton({
|
||||
super.key,
|
||||
@@ -19,6 +20,7 @@ class PostItemSkeleton extends StatelessWidget {
|
||||
this.isEmbedReply = false,
|
||||
this.isCompact = false,
|
||||
this.borderRadius,
|
||||
this.maxWidth = 640,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -28,7 +30,7 @@ class PostItemSkeleton extends StatelessWidget {
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Card(
|
||||
margin: EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
|
||||
@@ -69,7 +69,7 @@ class SliverPostList extends HookConsumerWidget {
|
||||
isSliver: true,
|
||||
footerSkeletonChild: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: const PostItemSkeleton(),
|
||||
child: PostItemSkeleton(maxWidth: maxWidth ?? double.infinity),
|
||||
),
|
||||
itemBuilder: (context, index, post) {
|
||||
if (maxWidth != null) {
|
||||
|
||||
@@ -50,7 +50,7 @@ class PostRepliesList extends HookConsumerWidget {
|
||||
|
||||
final skeletonItem = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: const PostItemSkeleton(),
|
||||
child: PostItemSkeleton(maxWidth: maxWidth ?? double.infinity),
|
||||
);
|
||||
|
||||
return PaginationList(
|
||||
|
||||
@@ -870,6 +870,7 @@ class PostBody extends ConsumerWidget {
|
||||
final EdgeInsets renderingPadding;
|
||||
final bool isRelativeTime;
|
||||
final bool hideOverlay;
|
||||
final bool hideAttachments;
|
||||
final double? textScale;
|
||||
|
||||
const PostBody({
|
||||
@@ -882,6 +883,7 @@ class PostBody extends ConsumerWidget {
|
||||
this.renderingPadding = EdgeInsets.zero,
|
||||
this.isRelativeTime = true,
|
||||
this.hideOverlay = false,
|
||||
this.hideAttachments = false,
|
||||
this.textScale,
|
||||
});
|
||||
|
||||
@@ -1140,7 +1142,7 @@ class PostBody extends ConsumerWidget {
|
||||
right: renderingPadding.horizontal,
|
||||
),
|
||||
),
|
||||
if (item.attachments.isNotEmpty && item.type != 1)
|
||||
if (item.attachments.isNotEmpty && item.type != 1 && !hideAttachments)
|
||||
CloudFileList(
|
||||
files: item.attachments,
|
||||
isColumn: !isInteractive,
|
||||
|
||||
@@ -998,14 +998,15 @@ class _LinkPreview extends ConsumerWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// Title
|
||||
Text(
|
||||
embed.title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
if (embed.title != null)
|
||||
Text(
|
||||
embed.title!,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// Description
|
||||
if (embed.description != null &&
|
||||
embed.description!.isNotEmpty)
|
||||
|
||||
@@ -11,8 +11,8 @@ import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class UploadOverlay extends HookConsumerWidget {
|
||||
const UploadOverlay({super.key});
|
||||
class TaskOverlay extends HookConsumerWidget {
|
||||
const TaskOverlay({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -32,7 +32,9 @@ class UploadOverlay extends HookConsumerWidget {
|
||||
final isVisibleOverride = useState<bool?>(null);
|
||||
final pendingHide = useState(false);
|
||||
final isExpandedLocal = useState(false);
|
||||
final isCompactLocal = useState(true); // Start compact
|
||||
final autoHideTimer = useState<Timer?>(null);
|
||||
final autoCompactTimer = useState<Timer?>(null);
|
||||
|
||||
final allFinished = activeTasks.every(
|
||||
(task) =>
|
||||
@@ -69,14 +71,35 @@ class UploadOverlay extends HookConsumerWidget {
|
||||
}
|
||||
return null;
|
||||
}, [allFinished, activeTasks, isExpandedLocal.value, pendingHide.value]);
|
||||
|
||||
final isDesktop = isWideScreen(context);
|
||||
|
||||
// Auto-compact timer for mobile when not expanded
|
||||
useEffect(() {
|
||||
if (!isDesktop && !isCompactLocal.value && !isExpandedLocal.value) {
|
||||
// Start timer to auto-compact after 5 seconds
|
||||
autoCompactTimer.value?.cancel();
|
||||
autoCompactTimer.value = Timer(const Duration(seconds: 5), () {
|
||||
isCompactLocal.value = true;
|
||||
});
|
||||
} else {
|
||||
autoCompactTimer.value?.cancel();
|
||||
autoCompactTimer.value = null;
|
||||
}
|
||||
return null;
|
||||
}, [isCompactLocal.value, isExpandedLocal.value, isDesktop]);
|
||||
final isVisible =
|
||||
(isVisibleOverride.value ?? activeTasks.isNotEmpty) &&
|
||||
!pendingHide.value;
|
||||
final slideController = useAnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
final isTopPositioned = !isDesktop; // Mobile: top, Desktop: bottom
|
||||
|
||||
final slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 1), // Start from below the screen
|
||||
begin: isTopPositioned
|
||||
? const Offset(0, -1)
|
||||
: const Offset(0, 1), // Start from above/below the screen
|
||||
end: Offset.zero, // End at normal position
|
||||
).animate(CurvedAnimation(parent: slideController, curve: Curves.easeOut));
|
||||
|
||||
@@ -95,33 +118,47 @@ class UploadOverlay extends HookConsumerWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isDesktop = isWideScreen(context);
|
||||
|
||||
return Positioned(
|
||||
bottom: 0,
|
||||
top: isTopPositioned ? 0 : null,
|
||||
bottom: !isTopPositioned ? 0 : null,
|
||||
left: isDesktop ? null : 0,
|
||||
right: isDesktop ? 24 : 0,
|
||||
child: SlideTransition(
|
||||
position: slideAnimation,
|
||||
child: _UploadOverlayContent(
|
||||
activeTasks: activeTasks,
|
||||
isExpanded: isExpandedLocal.value,
|
||||
onExpansionChanged: (expanded) => isExpandedLocal.value = expanded,
|
||||
).padding(bottom: 16 + MediaQuery.of(context).padding.bottom),
|
||||
child:
|
||||
_TaskOverlayContent(
|
||||
activeTasks: activeTasks,
|
||||
isExpanded: isExpandedLocal.value,
|
||||
isCompact: isCompactLocal.value,
|
||||
onExpansionChanged: (expanded) =>
|
||||
isExpandedLocal.value = expanded,
|
||||
onCompactChanged: (compact) => isCompactLocal.value = compact,
|
||||
).padding(
|
||||
top: isTopPositioned
|
||||
? MediaQuery.of(context).padding.top + 16
|
||||
: 0,
|
||||
bottom: !isTopPositioned
|
||||
? 16 + MediaQuery.of(context).padding.bottom
|
||||
: 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadOverlayContent extends HookConsumerWidget {
|
||||
class _TaskOverlayContent extends HookConsumerWidget {
|
||||
final List<DriveTask> activeTasks;
|
||||
final bool isExpanded;
|
||||
final bool isCompact;
|
||||
final Function(bool)? onExpansionChanged;
|
||||
final Function(bool)? onCompactChanged;
|
||||
|
||||
const _UploadOverlayContent({
|
||||
const _TaskOverlayContent({
|
||||
required this.activeTasks,
|
||||
required this.isExpanded,
|
||||
required this.isCompact,
|
||||
this.onExpansionChanged,
|
||||
this.onCompactChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -130,11 +167,16 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
initialValue: 0.0,
|
||||
);
|
||||
final heightAnimation = useAnimation(
|
||||
Tween<double>(begin: 60, end: 400).animate(
|
||||
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
|
||||
),
|
||||
);
|
||||
final compactHeight = 32.0;
|
||||
final collapsedHeight = 60.0;
|
||||
final expandedHeight = 400.0;
|
||||
|
||||
final currentHeight = isCompact
|
||||
? compactHeight
|
||||
: isExpanded
|
||||
? expandedHeight
|
||||
: collapsedHeight;
|
||||
|
||||
final opacityAnimation = useAnimation(
|
||||
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
|
||||
);
|
||||
@@ -152,233 +194,364 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
||||
|
||||
final taskNotifier = ref.read(uploadTasksProvider.notifier);
|
||||
|
||||
void handleInteraction() {
|
||||
if (isCompact) {
|
||||
onCompactChanged?.call(false);
|
||||
} else if (!isExpanded) {
|
||||
onExpansionChanged?.call(true);
|
||||
} else {
|
||||
onExpansionChanged?.call(false);
|
||||
}
|
||||
}
|
||||
|
||||
Widget content = AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(isCompact ? 64 : 12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
width: isCompact
|
||||
? _getCompactWidth(activeTasks)
|
||||
: (isMobile ? MediaQuery.of(context).size.width - 32 : 320),
|
||||
height: currentHeight,
|
||||
child: GestureDetector(
|
||||
onTap: isMobile ? handleInteraction : null,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(isCompact ? 64 : 12),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
switchInCurve: Curves.easeInOut,
|
||||
switchOutCurve: Curves.easeInOut,
|
||||
child: isCompact
|
||||
? // Compact view with progress bar background and text
|
||||
Container(
|
||||
key: const ValueKey('compact'),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(
|
||||
_getOverallStatusIcon(activeTasks),
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
activeTasks.isEmpty
|
||||
? '0 tasks'
|
||||
: _getOverallStatusText(activeTasks),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: _getOverallProgress(activeTasks),
|
||||
strokeWidth: 3,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
if (activeTasks.any(
|
||||
(task) =>
|
||||
task.status == DriveTaskStatus.inProgress &&
|
||||
task.uploadedBytes < task.fileSize,
|
||||
))
|
||||
CircularProgressIndicator(
|
||||
value: null, // Indeterminate
|
||||
strokeWidth: 3,
|
||||
trackGap: 0,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.secondary.withOpacity(0.5),
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12),
|
||||
)
|
||||
: Container(
|
||||
key: const ValueKey('expanded'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Collapsed Header
|
||||
Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Task icon with animation
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
key: ValueKey(isExpanded),
|
||||
isExpanded
|
||||
? Symbols.list_rounded
|
||||
: _getOverallStatusIcon(activeTasks),
|
||||
size: 24,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title and count
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isExpanded
|
||||
? 'tasks'.tr()
|
||||
: _getOverallStatusText(activeTasks),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (!isExpanded && activeTasks.isNotEmpty)
|
||||
Text(
|
||||
_getOverallProgressText(activeTasks),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Progress indicator (collapsed)
|
||||
if (!isExpanded)
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: Stack(
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: _getOverallProgress(activeTasks),
|
||||
strokeWidth: 3,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
if (activeTasks.any(
|
||||
(task) =>
|
||||
task.status ==
|
||||
DriveTaskStatus.inProgress &&
|
||||
task.uploadedBytes < task.fileSize,
|
||||
))
|
||||
CircularProgressIndicator(
|
||||
value: null, // Indeterminate
|
||||
strokeWidth: 3,
|
||||
trackGap: 0,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.secondary
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Expand/collapse button
|
||||
IconButton(
|
||||
icon: AnimatedRotation(
|
||||
turns: opacityAnimation * 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(
|
||||
isExpanded
|
||||
? Symbols.expand_more
|
||||
: Symbols.chevron_right,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
onExpansionChanged?.call(!isExpanded),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Expanded content
|
||||
if (isExpanded)
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline,
|
||||
width:
|
||||
1 /
|
||||
MediaQuery.of(context).devicePixelRatio,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 18,
|
||||
right: 16,
|
||||
),
|
||||
title: const Text(
|
||||
'clearCompleted',
|
||||
).tr(),
|
||||
leading: Icon(
|
||||
Symbols.clear_all,
|
||||
size: 18,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onTap: () {
|
||||
taskNotifier.clearCompletedTasks();
|
||||
onExpansionChanged?.call(false);
|
||||
},
|
||||
trailing: IconButton(
|
||||
tooltip: 'clearAll'.tr(),
|
||||
icon: Icon(
|
||||
Symbols.close,
|
||||
size: 18,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () {
|
||||
taskNotifier.clearAllTasks();
|
||||
onExpansionChanged?.call(false);
|
||||
},
|
||||
),
|
||||
tileColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
|
||||
// Task list
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((
|
||||
context,
|
||||
index,
|
||||
) {
|
||||
final task = activeTasks[index];
|
||||
return AnimatedOpacity(
|
||||
opacity: opacityAnimation,
|
||||
duration: const Duration(
|
||||
milliseconds: 150,
|
||||
),
|
||||
child: UploadTaskTile(task: task),
|
||||
);
|
||||
}, childCount: activeTasks.length),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Add MouseRegion for desktop hover
|
||||
if (!isMobile) {
|
||||
content = MouseRegion(
|
||||
onEnter: (_) => onCompactChanged?.call(false),
|
||||
onExit: (_) => onCompactChanged?.call(true),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompact) {
|
||||
content = Center(child: content);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: isMobile ? 16 : 24,
|
||||
left: isMobile ? 16 : 0,
|
||||
right: isMobile ? 16 : 24,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => onExpansionChanged?.call(!isExpanded),
|
||||
child: AnimatedBuilder(
|
||||
animation: animationController,
|
||||
builder: (context, child) {
|
||||
return Material(
|
||||
elevation: 8 + (opacityAnimation * 4),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
width: isMobile ? MediaQuery.of(context).size.width - 32 : 320,
|
||||
height: heightAnimation,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Collapsed Header
|
||||
Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Upload icon with animation
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
key: ValueKey(isExpanded),
|
||||
isExpanded
|
||||
? Symbols.list_rounded
|
||||
: _getOverallStatusIcon(activeTasks),
|
||||
size: 24,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title and count
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isExpanded
|
||||
? 'uploadTasks'.tr()
|
||||
: _getOverallStatusText(activeTasks),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (!isExpanded && activeTasks.isNotEmpty)
|
||||
Text(
|
||||
_getOverallProgressText(activeTasks),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Progress indicator (collapsed)
|
||||
if (!isExpanded)
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
value: _getOverallProgress(activeTasks),
|
||||
strokeWidth: 3,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
|
||||
// Expand/collapse button
|
||||
IconButton(
|
||||
icon: AnimatedRotation(
|
||||
turns: opacityAnimation * 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(
|
||||
isExpanded
|
||||
? Symbols.expand_more
|
||||
: Symbols.chevron_right,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
onPressed: () =>
|
||||
onExpansionChanged?.call(!isExpanded),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Expanded content
|
||||
if (isExpanded)
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
width:
|
||||
1 /
|
||||
MediaQuery.of(context).devicePixelRatio,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// Clear completed tasks button
|
||||
if (_hasCompletedTasks(activeTasks))
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
title: const Text('clearCompleted').tr(),
|
||||
leading: Icon(
|
||||
Symbols.clear_all,
|
||||
size: 18,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onTap: () {
|
||||
taskNotifier.clearCompletedTasks();
|
||||
onExpansionChanged?.call(false);
|
||||
},
|
||||
|
||||
tileColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
|
||||
// Clear all tasks button
|
||||
if (activeTasks.any(
|
||||
(task) =>
|
||||
task.status != DriveTaskStatus.completed,
|
||||
))
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
title: const Text('Clear All'),
|
||||
leading: Icon(
|
||||
Symbols.clear_all,
|
||||
size: 18,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
onTap: () {
|
||||
taskNotifier.clearAllTasks();
|
||||
onExpansionChanged?.call(false);
|
||||
},
|
||||
tileColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
|
||||
// Task list
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((
|
||||
context,
|
||||
index,
|
||||
) {
|
||||
final task = activeTasks[index];
|
||||
return AnimatedOpacity(
|
||||
opacity: opacityAnimation,
|
||||
duration: const Duration(
|
||||
milliseconds: 150,
|
||||
),
|
||||
child: UploadTaskTile(task: task),
|
||||
);
|
||||
}, childCount: activeTasks.length),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
double? _getTaskProgress(DriveTask task) {
|
||||
if (task.status == DriveTaskStatus.completed || (task.uploadedBytes >= task.fileSize && task.fileSize > 0)) return 1.0;
|
||||
if (task.status != DriveTaskStatus.inProgress) return 0.0;
|
||||
|
||||
return task.fileSize > 0 ? task.uploadedBytes / task.fileSize : 0.0;
|
||||
}
|
||||
|
||||
double _getOverallProgress(List<DriveTask> tasks) {
|
||||
if (tasks.isEmpty) return 0.0;
|
||||
final totalProgress = tasks.fold<double>(
|
||||
|
||||
final progressValues = tasks.map((task) => _getTaskProgress(task));
|
||||
final determinateProgresses = progressValues.where((p) => p != null);
|
||||
|
||||
if (determinateProgresses.isEmpty) return 0.0;
|
||||
|
||||
final totalProgress = determinateProgresses.fold<double>(
|
||||
0.0,
|
||||
(sum, task) =>
|
||||
sum +
|
||||
(task.status == DriveTaskStatus.inProgress
|
||||
? task.progress
|
||||
: task.status == DriveTaskStatus.completed
|
||||
? 1
|
||||
: 0),
|
||||
(sum, progress) => sum + progress!,
|
||||
);
|
||||
return totalProgress / tasks.length;
|
||||
}
|
||||
@@ -431,7 +604,7 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
String _getOverallStatusText(List<DriveTask> tasks) {
|
||||
if (tasks.isEmpty) return '0 tasks';
|
||||
if (tasks.isEmpty) return 'tasks'.plural(0);
|
||||
|
||||
final hasDownload = tasks.any((task) => task.type == 'FileDownload');
|
||||
final hasInProgress = tasks.any(
|
||||
@@ -469,18 +642,21 @@ class _UploadOverlayContent extends HookConsumerWidget {
|
||||
} else if (hasCompleted) {
|
||||
return '${tasks.length} ${'completed'.tr()}';
|
||||
} else {
|
||||
return '${tasks.length} ${'tasks'.tr()}';
|
||||
return 'tasks'.plural(tasks.length);
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasCompletedTasks(List<DriveTask> tasks) {
|
||||
return tasks.any(
|
||||
(task) =>
|
||||
task.status == DriveTaskStatus.completed ||
|
||||
task.status == DriveTaskStatus.failed ||
|
||||
task.status == DriveTaskStatus.cancelled ||
|
||||
task.status == DriveTaskStatus.expired,
|
||||
);
|
||||
double _getCompactWidth(List<DriveTask> tasks) {
|
||||
// Base width for icon and padding
|
||||
double width = 16 + 12 + 12; // icon size + padding + spacing
|
||||
|
||||
// Add text width estimation
|
||||
final text = activeTasks.isEmpty ? '0 tasks' : _getOverallStatusText(tasks);
|
||||
// Rough estimation: 8px per character
|
||||
width += text.length * 8.0;
|
||||
|
||||
// Cap at reasonable maximum
|
||||
return width.clamp(200, 280);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +667,13 @@ class UploadTaskTile extends StatefulWidget {
|
||||
|
||||
@override
|
||||
State<UploadTaskTile> createState() => _UploadTaskTileState();
|
||||
|
||||
static double? _getTaskProgress(DriveTask task) {
|
||||
if (task.status == DriveTaskStatus.completed || (task.uploadedBytes >= task.fileSize && task.fileSize > 0)) return 1.0;
|
||||
if (task.status == DriveTaskStatus.inProgress) return null;
|
||||
|
||||
return task.fileSize > 0 ? task.uploadedBytes / task.fileSize : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
class _UploadTaskTileState extends State<UploadTaskTile>
|
||||
@@ -551,7 +734,7 @@ class _UploadTaskTileState extends State<UploadTaskTile>
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: CircularProgressIndicator(
|
||||
value: widget.task.progress,
|
||||
value: UploadTaskTile._getTaskProgress(widget.task),
|
||||
strokeWidth: 2.5,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
@@ -678,7 +861,7 @@ class _UploadTaskTileState extends State<UploadTaskTile>
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: widget.task.progress,
|
||||
value: UploadTaskTile._getTaskProgress(widget.task),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
@@ -838,4 +1021,4 @@ class _UploadTaskTileState extends State<UploadTaskTile>
|
||||
return '${duration.inSeconds}s';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audio_session
|
||||
import connectivity_plus
|
||||
import desktop_drop
|
||||
import device_info_plus
|
||||
@@ -25,6 +26,7 @@ import gal
|
||||
import hotkey_manager_macos
|
||||
import in_app_review
|
||||
import irondash_engine_context
|
||||
import just_audio
|
||||
import livekit_client
|
||||
import local_auth_darwin
|
||||
import media_kit_libs_macos_video
|
||||
@@ -48,6 +50,7 @@ import wakelock_plus
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
@@ -68,6 +71,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
||||
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
|
||||
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
|
||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
|
||||
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
PODS:
|
||||
- audio_session (0.0.1):
|
||||
- FlutterMacOS
|
||||
- connectivity_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- croppy (0.0.1):
|
||||
@@ -177,6 +179,9 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- irondash_engine_context (0.0.1):
|
||||
- FlutterMacOS
|
||||
- just_audio (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- KeychainAccess (4.2.2)
|
||||
- livekit_client (2.5.4):
|
||||
- flutter_webrtc
|
||||
@@ -261,6 +266,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
|
||||
- croppy (from `Flutter/ephemeral/.symlinks/plugins/croppy/macos`)
|
||||
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
|
||||
@@ -283,6 +289,7 @@ DEPENDENCIES:
|
||||
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
|
||||
- irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`)
|
||||
- just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`)
|
||||
- livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
|
||||
- local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`)
|
||||
- media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
|
||||
@@ -330,6 +337,8 @@ SPEC REPOS:
|
||||
- WebRTC-SDK
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_session:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||
connectivity_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
|
||||
croppy:
|
||||
@@ -374,6 +383,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos
|
||||
irondash_engine_context:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos
|
||||
just_audio:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin
|
||||
livekit_client:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos
|
||||
local_auth_darwin:
|
||||
@@ -418,6 +429,7 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
||||
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
||||
croppy: d9bfc8c02f3cd1851f669a421df298a474b78f43
|
||||
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
|
||||
@@ -454,6 +466,7 @@ SPEC CHECKSUMS:
|
||||
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
||||
in_app_review: 66e7680752b632d83f4f0e88b34d52ed303fbff4
|
||||
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
|
||||
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
||||
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
|
||||
livekit_client: 3df5a1787d64010ca56c4002959d9e47c03ba3fb
|
||||
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
|
||||
|
||||
100
pubspec.lock
100
pubspec.lock
@@ -97,6 +97,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
audio_session:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audio_session
|
||||
sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
autotrie:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -269,10 +277,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243"
|
||||
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
version: "4.11.1"
|
||||
collection:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -557,10 +565,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.7"
|
||||
version: "2.0.8"
|
||||
event_bus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -589,10 +597,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.1.5"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -774,6 +782,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.2"
|
||||
flutter_app_intents:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_app_intents
|
||||
sha256: bec5a1ec991b93d475435205dbdca6efdd8979749f2a3c73ebb9f8334b9d3fa2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
flutter_app_update:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1265,10 +1281,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
sha256: eefe5ee217f331627d8bbcf01f91b21c730bf66e225d6dc8a148370b0819168d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
version: "7.0.0"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1533,6 +1549,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.11.2"
|
||||
just_audio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: just_audio
|
||||
sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.5"
|
||||
just_audio_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: just_audio_platform_interface
|
||||
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.0"
|
||||
just_audio_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: just_audio_web
|
||||
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.16"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -2221,6 +2261,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
quick_actions:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: quick_actions
|
||||
sha256: "7e35dd6a21f5bbd21acf6899039eaf85001a5ac26d52cbd6a8a2814505b90798"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
quick_actions_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: quick_actions_android
|
||||
sha256: "23f04632ada7fc16665d84ba54a0c792c09727e7fda6c989c6e6ba1853aa15dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.27"
|
||||
quick_actions_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: quick_actions_ios
|
||||
sha256: a2e08ceb01f9d26e1b1826b1c4f5da6b7b6bbf61bcbaacd8e93dfff58b91f996
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
quick_actions_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: quick_actions_platform_interface
|
||||
sha256: "1fec7068db5122cd019e9340d3d7be5d36eab099695ef3402c7059ee058329a4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
recase:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -3155,6 +3227,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
video_thumbnail:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_thumbnail
|
||||
sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.6"
|
||||
visibility_detector:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -3191,10 +3271,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.2.1"
|
||||
waveform_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
12
pubspec.yaml
12
pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 3.5.0+160
|
||||
version: 3.5.0+162
|
||||
|
||||
environment:
|
||||
sdk: ^3.8.0
|
||||
@@ -38,6 +38,7 @@ dependencies:
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_hooks: ^0.21.3+1
|
||||
hooks_riverpod: ^3.1.0
|
||||
quick_actions: ^1.1.0
|
||||
go_router: ^17.0.1
|
||||
styled_widget: ^0.4.1
|
||||
shared_preferences: ^2.5.4
|
||||
@@ -51,7 +52,7 @@ dependencies:
|
||||
flutter_highlight: ^0.7.0
|
||||
uuid: ^4.5.2
|
||||
url_launcher: ^6.3.2
|
||||
google_fonts: ^6.3.3
|
||||
google_fonts: ^7.0.0
|
||||
gap: ^3.0.1
|
||||
cached_network_image: ^3.4.1
|
||||
web: ^1.1.1
|
||||
@@ -147,7 +148,7 @@ dependencies:
|
||||
shelf_web_socket: ^3.0.0
|
||||
windows_notification: ^1.3.0
|
||||
win32: ^5.15.0
|
||||
ffi: ^2.1.4
|
||||
ffi: ^2.1.5
|
||||
dart_ipc: ^1.0.1
|
||||
pretty_diff_text: ^2.1.0
|
||||
window_manager: ^0.5.1
|
||||
@@ -173,6 +174,9 @@ dependencies:
|
||||
shake: ^3.0.0
|
||||
in_app_review: ^2.0.11
|
||||
snow_fall_animation: ^0.0.1+3
|
||||
flutter_app_intents: ^0.7.0
|
||||
video_thumbnail: ^0.5.6
|
||||
just_audio: ^0.10.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -211,6 +215,7 @@ flutter:
|
||||
- assets/images/oidc/
|
||||
- assets/images/stickers/
|
||||
- assets/icons/
|
||||
- assets/audio/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
@@ -273,3 +278,4 @@ msix_config:
|
||||
protocol_activation: solian, https
|
||||
app_uri_handler_hosts: solian.app
|
||||
capabilities: internetClientServer, location, microphone, webcam
|
||||
|
||||
|
||||
Reference in New Issue
Block a user