Compare commits

...

51 Commits

Author SHA1 Message Date
b66640c6df 🚀 Launch 3.5.0+162 2026-01-12 01:13:55 +08:00
4ac10706ae iOS push notification custom sound 2026-01-12 01:08:52 +08:00
bf2844162d 💄 Optimize post detail layout 2026-01-12 01:04:03 +08:00
23c11a2fbd 🗑️ Remove the file references 2026-01-12 00:25:19 +08:00
2b99f54bc5 🐛 Fix upload task 2026-01-12 00:22:28 +08:00
ab2fc1013b 💄 Scrollable alert 2026-01-12 00:08:51 +08:00
LittleSheep
9a41ff26ef 🔀 Merge pull request #228 from Texas0295/v3
[FIX] linux: defer AnalyticsService init to avoid Firebase crash
2026-01-11 23:49:05 +08:00
63a55658ab Sound effects on notifications 2026-01-11 23:32:00 +08:00
Texas0295
3122ae4cc2 [FIX] linux: defer AnalyticsService init to avoid Firebase crash
Defer FirebaseAnalytics initialization to constructor to avoid crashes on
unsupported platforms (notably Linux). Replace class-level initialization
with nullable field and guarded calls.

Changes:
- remove class-level initialization of _analytics
- add private _init() invoked in constructor
- make _analytics nullable
- add null checks in logEvent() and setUserId()
- emit warning instead of throwing
- Linux builds no longer crash on startup

Signed-off-by: Texas0295 <kimura@texas0295.top>
2026-01-11 20:31:27 +08:00
00e063e99f 🐛 Dozens of bug fixes 2026-01-11 18:41:05 +08:00
532bb30c6a Sfx on notification 2026-01-11 18:32:23 +08:00
d8c33b576f Attachment video preview 2026-01-11 16:56:41 +08:00
a984cba2fa 💫 Animated attachment preview progress bar 2026-01-11 13:42:55 +08:00
b7d5aa5dfb Better attachment preview progress 2026-01-11 13:40:35 +08:00
826238a374 💄 Optimize task overlay 2026-01-11 13:22:13 +08:00
88c4d648d5 ♻️ Better task overlay progress 2026-01-11 13:11:15 +08:00
bf59108569 🐛 Add well defined mounted check in messages notifier 2026-01-11 12:53:57 +08:00
eec181da55 💄 Optimize task overlay styles 2026-01-11 02:48:44 +08:00
c93b543da9 💄 Animated image gates 2026-01-10 23:20:16 +08:00
2fd93246c7 💄 Improve the image don't animated the opacity if cached 2026-01-10 23:02:31 +08:00
1b2620e957 💫 Optimize the chat message animation and performance 2026-01-10 22:56:08 +08:00
d443343052 🐛 Fix serval issues in past commits 2026-01-10 22:23:42 +08:00
9957905212 ⬆️ Upgrade dependecies 2026-01-10 21:21:31 +08:00
e36d694397 💫 Optimize the chat room animation 2026-01-10 16:10:55 +08:00
3847581f1f ♻️ Decouple the room.dart 2026-01-10 14:18:59 +08:00
64903bf1f3 📈 Tracking data's analytics service 2026-01-10 13:43:31 +08:00
a449fbb58a More app intents available on iOS 2026-01-10 13:06:09 +08:00
6a4a50b81a 🚀 Launch 3.5.0+161 (SNAPSHOT) 2026-01-09 02:02:52 +08:00
5e328b736f App shortcuts 2026-01-09 01:58:29 +08:00
d44e481a0d 💄 Optimize the two parts split avatar displaying 2026-01-09 01:41:21 +08:00
e6fb38572c App intents (aims to support Siri on iOS) (untested) 2026-01-09 01:39:08 +08:00
64c5ee0fe9 🐛 Trying to fix NSE attachment doesn't work as expected on the default types 2026-01-08 23:10:22 +08:00
9cba568e47 💄 New media post layout 2026-01-07 23:30:48 +08:00
38fc4e969f 💄 Improved pinned post 2026-01-07 22:52:46 +08:00
0e100c3e64 💄 Better pinned post view 2026-01-07 22:46:36 +08:00
735724ae4d 🐛 Fix some bugs within relationship forgot 2026-01-07 22:31:16 +08:00
6c31337fbc 🐛 Optimize the disposal check in message notifier, close #211 2026-01-07 19:46:07 +08:00
d2806fdc3a 💄 Optimize timeline cursor picking 2026-01-07 19:44:53 +08:00
4ebbda1b14 🐛 Trying to fix timeline duplicate posts, close #227 2026-01-07 01:55:10 +08:00
3e7007f58f 🐛 Trying to fix app scaffold focus conflict 2026-01-07 01:52:25 +08:00
d96629a7f1 🐛 Fix universal image still got state changes after unmounted 2026-01-07 01:48:20 +08:00
1b6ccccf32 💫 Animated profile card 2026-01-07 01:43:46 +08:00
910dafaa43 🐛 Fix rename didn't commited 2026-01-07 01:43:03 +08:00
c6fd342072 ♻️ Refactored the relationship screen according to server updates 2026-01-07 01:42:20 +08:00
757f1b880c 🐛 Fix explore pagination placeholder maxWidth 2026-01-07 01:22:28 +08:00
67805b413e 🐛 Fix regex in markdown 2026-01-06 01:08:09 +08:00
e1eb0f705e Prevent the non-user name got rendered as mention chip, close #226 2026-01-05 22:46:40 +08:00
1e8e785e5f Adjust riverpod auto retries logic 2026-01-05 22:35:52 +08:00
d9dfea6b8b 🐛 Fix creation date parsing in iOS code 2026-01-05 22:14:29 +08:00
d29f0b1170 Shuffle post iOS widget 2026-01-05 22:13:26 +08:00
479a79c7f6 Circular accessory on iOS 2026-01-04 23:03:18 +08:00
97 changed files with 7887 additions and 4488 deletions

BIN
assets/audio/messages.mp3 Normal file

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -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 {}",

View File

@@ -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 {}",

View File

@@ -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 {}",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "已屏蔽",
"blockUser": "屏蔽用户",
"unblockUser": "解除屏蔽用户",
"forgotRelationship": "忘记关系",
"forgotRelationshipConfirm": "确定要忘记与 {} 的关系吗?这只会改变您对他/她的关系,他可能看到不到您的友限帖,但是您仍然可以看到他/她的。他/她也可以再次提出好友申请。",
"relationshipDeleted": "关系已删除",
"friendRequestAccepted": "已接受 {} 的好友请求",
"friendRequestDeclined": "已拒绝 {} 的好友请求",
"requestExpiredIn": "{} 后过期",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "已阻",
"blockUser": "禁此戶",
"unblockUser": "解禁",
"forgotRelationship": "忘締交",
"forgotRelationshipConfirm": "確定要忘與 {} 之締交乎?",
"relationshipDeleted": "締交已忘",
"friendRequestAccepted": "已納 {} 締交書",
"friendRequestDeclined": "已謝 {} 締交書",
"requestExpiredIn": "效期 {}",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "已屏蔽",
"blockUser": "屏蔽用戶",
"unblockUser": "解除屏蔽用戶",
"forgotRelationship": "忘記關係",
"forgotRelationshipConfirm": "確定要忘記與 {} 的關係嗎?",
"relationshipDeleted": "關係已刪除",
"friendRequestAccepted": "已接受 {} 的好友請求",
"friendRequestDeclined": "已拒絕 {} 的好友請求",
"requestExpiredIn": "{} 後過期",

View File

@@ -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'

View File

@@ -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

View File

@@ -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",

View File

@@ -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)"
]
)
]
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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>

View File

@@ -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)")

Binary file not shown.

Binary file not shown.

View File

@@ -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)
}
}

View File

@@ -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

View 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
)
}

View File

@@ -13,5 +13,6 @@ struct SolianWidgetExtensionBundle: WidgetBundle {
var body: some Widget {
SolianCheckInWidget()
SolianNotificationWidget()
SolianPostShuffleWidget()
}
}

View File

@@ -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...";

View File

@@ -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...";

View File

@@ -34,3 +34,8 @@
"invalidResponse" = "サーバーから無効な応答が返されました。";
"httpError" = "サーバーエラー (%d)。";
"decodingError" = "サーバーデータの読み込みに失敗しました。";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "利用可能な投稿がありません";
"tapToRefresh" = "タップして更新";
"loadingPost" = "投稿を読み込み中...";

View File

@@ -34,3 +34,8 @@
"invalidResponse" = "서버에서 잘못된 응답을 받았습니다.";
"httpError" = "서버 오류 (%d).";
"decodingError" = "서버 데이터 읽기에 실패했습니다.";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "사용 가능한 게시물이 없습니다";
"tapToRefresh" = "탭하여 새로고침";
"loadingPost" = "게시물 로딩 중...";

View File

@@ -39,3 +39,8 @@
"noNotifications" = "没有通知";
"noUnreadNotifications" = "没有未读通知";
"unread" = "未读";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "没有可用的帖子";
"tapToRefresh" = "点击刷新";
"loadingPost" = "加载帖子中...";

View File

@@ -34,3 +34,8 @@
"invalidResponse" = "伺服器返回了無效響應。";
"httpError" = "伺服器錯誤 (%d)。";
"decodingError" = "讀取伺服器數據失敗。";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "沒有可用的帖子";
"tapToRefresh" = "點擊刷新";
"loadingPost" = "載入帖子中...";

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

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

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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?,

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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(),
};

View File

@@ -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;

View File

@@ -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));
});
}

View File

@@ -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,
};

View File

@@ -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;
});
}
}
}

View File

@@ -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,
);

View File

@@ -50,7 +50,7 @@ final class MessagesNotifierProvider
}
}
String _$messagesNotifierHash() => r'a721a4b92b48ee7c2289cdcd7130bbf1ca9dcb40';
String _$messagesNotifierHash() => r'622fed0908eb04381a971e36540c516743246dff';
final class MessagesNotifierFamily extends $Family
with

View File

@@ -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) {

View File

@@ -65,7 +65,7 @@ final class AppSettingsNotifierProvider
}
String _$appSettingsNotifierHash() =>
r'2437c621dcb1625a120ed1f21ab5c29906ba98be';
r'64cf6dfda90fc634336d96bd5313b2a07b19eccb';
abstract class _$AppSettingsNotifier extends $Notifier<AppSettings> {
AppSettings build();

View File

@@ -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();
}

View File

@@ -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';
}

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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,
);

View File

@@ -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

View File

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

View File

@@ -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),
);

View File

@@ -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')),
);
}
}

View File

@@ -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<

View File

@@ -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 {
),
);
}
}
}

View File

@@ -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,

View File

@@ -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),

View File

@@ -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(

View 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';
}
}

View File

@@ -0,0 +1 @@
export 'app_intents/ios.dart';

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

View File

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

View File

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

View 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;
}
}

View File

@@ -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);
});
}

View File

@@ -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,
),
),
),
);

View File

@@ -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(

View File

@@ -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 {
),
);
}
}
}

View File

@@ -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) {

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

View 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;
}
}

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

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

View File

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

View File

@@ -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(

View File

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

View File

@@ -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,
),

View File

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

View File

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

View File

@@ -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({

View File

@@ -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),
),
),
],

View File

@@ -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);

View File

@@ -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(

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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,

View File

@@ -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)

View File

@@ -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';
}
}
}
}

View File

@@ -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"))

View File

@@ -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

View File

@@ -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:

View File

@@ -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