Compare commits

..

61 Commits

Author SHA1 Message Date
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
1a74f2b3e9 🚀 Launch 3.5.0+160 (SNAPSHOT) 2026-01-04 01:21:58 +08:00
97a5e951e1 💄 Optimize iOS notifications widget 2026-01-04 01:20:50 +08:00
9071ac44fe 🐛 Fix iOS widget catalog issue 2026-01-04 01:14:47 +08:00
6abee8d8bd iOS notification widget 2026-01-04 01:04:49 +08:00
8cf03683dc 💄 Better iOS widget styling 2026-01-03 21:42:30 +08:00
f34d80b7d4 🚚 Rename iOS check in widget file name to ready add more 2026-01-03 21:30:23 +08:00
b6d7e52148 iOS check in widget with notable days support 2026-01-03 21:29:35 +08:00
978b7b32fd iOS check in widget 2026-01-03 18:50:54 +08:00
35a9c9ff4b 🐛 Fix android biometric issue 2026-01-03 18:50:46 +08:00
e5cb296367 Due to server changes, rollback the meta field rename 2026-01-03 13:49:52 +08:00
cf3a2b6340 🎉 Create the iOS extension widget target 2026-01-03 13:06:10 +08:00
f568baf14d 💄 Adjust post subscription filter 2026-01-03 12:47:00 +08:00
703335429a 🐛 Fix post metadata uses wrong key to deserialize 2026-01-03 12:45:28 +08:00
123 changed files with 10704 additions and 4409 deletions

View File

@@ -17,6 +17,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" /> <uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<application <application
android:label="Solian" android:label="Solian"

View File

@@ -5,8 +5,9 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.sharedpreferences.LegacySharedPreferencesPlugin import io.flutter.plugins.sharedpreferences.LegacySharedPreferencesPlugin
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterActivity() class MainActivity : FlutterFragmentActivity()
{ {
private val CHANNEL = "dev.solsynth.solian/notifications" private val CHANNEL = "dev.solsynth.solian/notifications"

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", "relationshipStatusBlocked": "Blocked",
"blockUser": "Block User", "blockUser": "Block User",
"unblockUser": "Unblock 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 {}", "friendRequestAccepted": "Accepted friend request from {}",
"friendRequestDeclined": "Declined friend request from {}", "friendRequestDeclined": "Declined friend request from {}",
"requestExpiredIn": "Expired in {}", "requestExpiredIn": "Expired in {}",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "Blocked", "relationshipStatusBlocked": "Blocked",
"blockUser": "Block User", "blockUser": "Block User",
"unblockUser": "Unblock User", "unblockUser": "Unblock User",
"forgotRelationship": "関係を忘れる",
"forgotRelationshipConfirm": "{} との関係を忘れてもよろしいですか?",
"relationshipDeleted": "関係が削除されました",
"friendRequestAccepted": "Accepted friend request from {}", "friendRequestAccepted": "Accepted friend request from {}",
"friendRequestDeclined": "Declined friend request from {}", "friendRequestDeclined": "Declined friend request from {}",
"requestExpiredIn": "Expired in {}", "requestExpiredIn": "Expired in {}",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "Blocked", "relationshipStatusBlocked": "Blocked",
"blockUser": "Block User", "blockUser": "Block User",
"unblockUser": "Unblock User", "unblockUser": "Unblock User",
"forgotRelationship": "관계 잊기",
"forgotRelationshipConfirm": "{}와의 관계를 잊으시겠습니까?",
"relationshipDeleted": "관계가 삭제되었습니다",
"friendRequestAccepted": "Accepted friend request from {}", "friendRequestAccepted": "Accepted friend request from {}",
"friendRequestDeclined": "Declined friend request from {}", "friendRequestDeclined": "Declined friend request from {}",
"requestExpiredIn": "Expired in {}", "requestExpiredIn": "Expired in {}",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "已屏蔽", "relationshipStatusBlocked": "已屏蔽",
"blockUser": "屏蔽用户", "blockUser": "屏蔽用户",
"unblockUser": "解除屏蔽用户", "unblockUser": "解除屏蔽用户",
"forgotRelationship": "忘记关系",
"forgotRelationshipConfirm": "确定要忘记与 {} 的关系吗?这只会改变您对他/她的关系,他可能看到不到您的友限帖,但是您仍然可以看到他/她的。他/她也可以再次提出好友申请。",
"relationshipDeleted": "关系已删除",
"friendRequestAccepted": "已接受 {} 的好友请求", "friendRequestAccepted": "已接受 {} 的好友请求",
"friendRequestDeclined": "已拒绝 {} 的好友请求", "friendRequestDeclined": "已拒绝 {} 的好友请求",
"requestExpiredIn": "{} 后过期", "requestExpiredIn": "{} 后过期",
@@ -455,6 +458,7 @@
"checkInResultT2": "中平", "checkInResultT2": "中平",
"checkInResultT3": "吉", "checkInResultT3": "吉",
"checkInResultT4": "大吉", "checkInResultT4": "大吉",
"checkInResultT5": "特殊",
"accountProfileView": "查看个人资料", "accountProfileView": "查看个人资料",
"unspecified": "未指定", "unspecified": "未指定",
"added": "已添加", "added": "已添加",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "已阻", "relationshipStatusBlocked": "已阻",
"blockUser": "禁此戶", "blockUser": "禁此戶",
"unblockUser": "解禁", "unblockUser": "解禁",
"forgotRelationship": "忘締交",
"forgotRelationshipConfirm": "確定要忘與 {} 之締交乎?",
"relationshipDeleted": "締交已忘",
"friendRequestAccepted": "已納 {} 締交書", "friendRequestAccepted": "已納 {} 締交書",
"friendRequestDeclined": "已謝 {} 締交書", "friendRequestDeclined": "已謝 {} 締交書",
"requestExpiredIn": "效期 {}", "requestExpiredIn": "效期 {}",
@@ -455,6 +458,7 @@
"checkInResultT2": "Mid", "checkInResultT2": "Mid",
"checkInResultT3": "Good", "checkInResultT3": "Good",
"checkInResultT4": "Best", "checkInResultT4": "Best",
"checkInResultT5": "Special",
"accountProfileView": "View Profile", "accountProfileView": "View Profile",
"unspecified": "Unspecified", "unspecified": "Unspecified",
"added": "Added", "added": "Added",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "已屏蔽", "relationshipStatusBlocked": "已屏蔽",
"blockUser": "屏蔽用戶", "blockUser": "屏蔽用戶",
"unblockUser": "解除屏蔽用戶", "unblockUser": "解除屏蔽用戶",
"forgotRelationship": "忘記關係",
"forgotRelationshipConfirm": "確定要忘記與 {} 的關係嗎?",
"relationshipDeleted": "關係已刪除",
"friendRequestAccepted": "已接受 {} 的好友請求", "friendRequestAccepted": "已接受 {} 的好友請求",
"friendRequestDeclined": "已拒絕 {} 的好友請求", "friendRequestDeclined": "已拒絕 {} 的好友請求",
"requestExpiredIn": "{} 後過期", "requestExpiredIn": "{} 後過期",
@@ -455,6 +458,7 @@
"checkInResultT2": "中平", "checkInResultT2": "中平",
"checkInResultT3": "吉", "checkInResultT3": "吉",
"checkInResultT4": "大吉", "checkInResultT4": "大吉",
"checkInResultT5": "特殊",
"accountProfileView": "查看個人資料", "accountProfileView": "查看個人資料",
"unspecified": "未指定", "unspecified": "未指定",
"added": "已添加", "added": "已添加",

View File

@@ -1,4 +1,4 @@
platform :ios, '15.0' platform :ios, '16.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -1,5 +1,7 @@
PODS: PODS:
- Alamofire (5.11.0) - Alamofire (5.11.0)
- audio_session (0.0.1):
- Flutter
- connectivity_plus (0.0.1): - connectivity_plus (0.0.1):
- Flutter - Flutter
- croppy (0.0.1): - croppy (0.0.1):
@@ -125,6 +127,8 @@ PODS:
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1) - PromisesSwift (~> 2.1)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_app_intents (0.1.0):
- Flutter
- flutter_app_update (0.0.1): - flutter_app_update (0.0.1):
- Flutter - Flutter
- flutter_inappwebview_ios (0.0.1): - flutter_inappwebview_ios (0.0.1):
@@ -217,6 +221,9 @@ PODS:
- Flutter - Flutter
- irondash_engine_context (0.0.1): - irondash_engine_context (0.0.1):
- Flutter - Flutter
- just_audio (0.0.1):
- Flutter
- FlutterMacOS
- KeychainAccess (4.2.2) - KeychainAccess (4.2.2)
- Kingfisher (8.6.2) - Kingfisher (8.6.2)
- KingfisherWebP (1.7.2): - KingfisherWebP (1.7.2):
@@ -269,6 +276,8 @@ PODS:
- PromisesObjC (= 2.4.0) - PromisesObjC (= 2.4.0)
- protocol_handler_ios (0.0.1): - protocol_handler_ios (0.0.1):
- Flutter - Flutter
- quick_actions_ios (0.0.1):
- Flutter
- receive_sharing_intent (1.8.1): - receive_sharing_intent (1.8.1):
- Flutter - Flutter
- record_ios (1.1.0): - record_ios (1.1.0):
@@ -320,12 +329,16 @@ PODS:
- Flutter - Flutter
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- video_thumbnail (0.0.1):
- Flutter
- libwebp
- wakelock_plus (0.0.1): - wakelock_plus (0.0.1):
- Flutter - Flutter
- WebRTC-SDK (137.7151.04) - WebRTC-SDK (137.7151.04)
DEPENDENCIES: DEPENDENCIES:
- Alamofire - Alamofire
- audio_session (from `.symlinks/plugins/audio_session/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- croppy (from `.symlinks/plugins/croppy/ios`) - croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/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_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_app_intents (from `.symlinks/plugins/flutter_app_intents/ios`)
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`) - flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/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`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`) - in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
- Kingfisher (~> 8.0) - Kingfisher (~> 8.0)
- KingfisherWebP - KingfisherWebP
- livekit_client (from `.symlinks/plugins/livekit_client/ios`) - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
@@ -362,6 +377,7 @@ DEPENDENCIES:
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`) - pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`) - protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
- quick_actions_ios (from `.symlinks/plugins/quick_actions_ios/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`)
- sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`)
@@ -373,6 +389,7 @@ DEPENDENCIES:
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) - super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`) - syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS: SPEC REPOS:
@@ -408,6 +425,8 @@ SPEC REPOS:
- WebRTC-SDK - WebRTC-SDK
EXTERNAL SOURCES: EXTERNAL SOURCES:
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
connectivity_plus: connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios" :path: ".symlinks/plugins/connectivity_plus/ios"
croppy: croppy:
@@ -428,6 +447,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/firebase_messaging/ios" :path: ".symlinks/plugins/firebase_messaging/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_app_intents:
:path: ".symlinks/plugins/flutter_app_intents/ios"
flutter_app_update: flutter_app_update:
:path: ".symlinks/plugins/flutter_app_update/ios" :path: ".symlinks/plugins/flutter_app_update/ios"
flutter_inappwebview_ios: flutter_inappwebview_ios:
@@ -454,6 +475,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/in_app_review/ios" :path: ".symlinks/plugins/in_app_review/ios"
irondash_engine_context: irondash_engine_context:
:path: ".symlinks/plugins/irondash_engine_context/ios" :path: ".symlinks/plugins/irondash_engine_context/ios"
just_audio:
:path: ".symlinks/plugins/just_audio/darwin"
livekit_client: livekit_client:
:path: ".symlinks/plugins/livekit_client/ios" :path: ".symlinks/plugins/livekit_client/ios"
local_auth_darwin: local_auth_darwin:
@@ -476,6 +499,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/pointer_interceptor_ios/ios" :path: ".symlinks/plugins/pointer_interceptor_ios/ios"
protocol_handler_ios: protocol_handler_ios:
:path: ".symlinks/plugins/protocol_handler_ios/ios" :path: ".symlinks/plugins/protocol_handler_ios/ios"
quick_actions_ios:
:path: ".symlinks/plugins/quick_actions_ios/ios"
receive_sharing_intent: receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios" :path: ".symlinks/plugins/receive_sharing_intent/ios"
record_ios: record_ios:
@@ -498,11 +523,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios" :path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
video_thumbnail:
:path: ".symlinks/plugins/video_thumbnail/ios"
wakelock_plus: wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios" :path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
Alamofire: bd5e7b23a1a750975288482c1831d71e74415f86 Alamofire: bd5e7b23a1a750975288482c1831d71e74415f86
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30 croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
@@ -525,6 +553,7 @@ SPEC CHECKSUMS:
FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4 FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4
FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_app_intents: e77f999f398c841ab584a1925dbce33ee0168fb5
flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9 flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619 flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619
@@ -542,6 +571,7 @@ SPEC CHECKSUMS:
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937 in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51 KeychainAccess: c0c4f7f38f6fc7bbe58f5702e25f7bd2f65abf51
Kingfisher: 23d18f54677d973b713e54ce6a8f5eef6e7056ba Kingfisher: 23d18f54677d973b713e54ce6a8f5eef6e7056ba
KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402 KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402
@@ -561,6 +591,7 @@ SPEC CHECKSUMS:
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
protocol_handler_ios: 59f23ee71f3ec602d67902ca7f669a80957888d5 protocol_handler_ios: 59f23ee71f3ec602d67902ca7f669a80957888d5
quick_actions_ios: 500fcc11711d9f646739093395c4ae8eec25f779
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
@@ -575,9 +606,10 @@ SPEC CHECKSUMS:
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14 syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
PODFILE CHECKSUM: 585198f58dca90ac6492607c83a8d17045ab3852 PODFILE CHECKSUM: 5a3652182a0f0e1093d3ac36dcabc72f6cc46b3a
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@@ -11,6 +11,9 @@
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
5D8143680678FCD1D1827271 /* Pods_Solian_Watch_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C9C046CF867AE03DC170F861 /* Pods_Solian_Watch_App.framework */; }; 5D8143680678FCD1D1827271 /* Pods_Solian_Watch_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C9C046CF867AE03DC170F861 /* Pods_Solian_Watch_App.framework */; };
7301DB032F08D99C008390F3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7301DB022F08D99C008390F3 /* WidgetKit.framework */; };
7301DB052F08D99C008390F3 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7301DB042F08D99C008390F3 /* SwiftUI.framework */; };
7301DB102F08D99D008390F3 /* SolianWidgetExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7301DB012F08D99C008390F3 /* SolianWidgetExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
7310A7DF2EB10963002C0FD3 /* Solian Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 7310A7D42EB10962002C0FD3 /* Solian Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7310A7DF2EB10963002C0FD3 /* Solian Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 7310A7D42EB10962002C0FD3 /* Solian Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; }; 73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; };
73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -36,6 +39,13 @@
remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner; remoteInfo = Runner;
}; };
7301DB0E2F08D99D008390F3 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 7301DB002F08D99C008390F3;
remoteInfo = SolianWidgetExtensionExtension;
};
73ACDFC12E3D0E6100B63535 /* PBXContainerItemProxy */ = { 73ACDFC12E3D0E6100B63535 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */; containerPortal = 97C146E61CF9000F007C117D /* Project object */;
@@ -77,6 +87,7 @@
dstPath = ""; dstPath = "";
dstSubfolderSpec = 13; dstSubfolderSpec = 13;
files = ( files = (
7301DB102F08D99D008390F3 /* SolianWidgetExtensionExtension.appex in Embed Foundation Extensions */,
73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */, 73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */,
73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */, 73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */,
73CDD6812DEC00480059D95D /* SolianNotificationService.appex in Embed Foundation Extensions */, 73CDD6812DEC00480059D95D /* SolianNotificationService.appex in Embed Foundation Extensions */,
@@ -117,6 +128,10 @@
39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolianNotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolianNotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
3A1C47BD29CC6AC2587D4DBE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 3A1C47BD29CC6AC2587D4DBE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
7301DB012F08D99C008390F3 /* SolianWidgetExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianWidgetExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
7301DB022F08D99C008390F3 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
7301DB042F08D99C008390F3 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
7301DB162F08D9A5008390F3 /* SolianWidgetExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SolianWidgetExtensionExtension.entitlements; sourceTree = "<group>"; };
7310A7D42EB10962002C0FD3 /* Solian Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Solian Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7310A7D42EB10962002C0FD3 /* Solian Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Solian Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; 737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianBroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianBroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -151,6 +166,13 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
7301DB142F08D99D008390F3 /* Exceptions for "SolianWidgetExtension" folder in "SolianWidgetExtensionExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 7301DB002F08D99C008390F3 /* SolianWidgetExtensionExtension */;
};
73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */ = { 73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
@@ -177,12 +199,21 @@
membershipExceptions = ( membershipExceptions = (
CloudFile.swift, CloudFile.swift,
DataExchange.swift, DataExchange.swift,
GroupDefaultSync.swift,
); );
target = 73CDD6792DEC00480059D95D /* SolianNotificationService */; target = 73CDD6792DEC00480059D95D /* SolianNotificationService */;
}; };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
7301DB062F08D99C008390F3 /* SolianWidgetExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
7301DB142F08D99D008390F3 /* Exceptions for "SolianWidgetExtension" folder in "SolianWidgetExtensionExtension" target */,
);
path = SolianWidgetExtension;
sourceTree = "<group>";
};
7310A7D52EB10962002C0FD3 /* Solian Watch App */ = { 7310A7D52EB10962002C0FD3 /* Solian Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
@@ -233,6 +264,15 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
7301DAFE2F08D99C008390F3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
7301DB052F08D99C008390F3 /* SwiftUI.framework in Frameworks */,
7301DB032F08D99C008390F3 /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
7310A7D12EB10962002C0FD3 /* Frameworks */ = { 7310A7D12EB10962002C0FD3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -295,6 +335,8 @@
73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */, 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */,
73ACDFB82E3D0E6100B63535 /* UIKit.framework */, 73ACDFB82E3D0E6100B63535 /* UIKit.framework */,
C9C046CF867AE03DC170F861 /* Pods_Solian_Watch_App.framework */, C9C046CF867AE03DC170F861 /* Pods_Solian_Watch_App.framework */,
7301DB022F08D99C008390F3 /* WidgetKit.framework */,
7301DB042F08D99C008390F3 /* SwiftUI.framework */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -341,12 +383,14 @@
97C146E51CF9000F007C117D = { 97C146E51CF9000F007C117D = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7301DB162F08D9A5008390F3 /* SolianWidgetExtensionExtension.entitlements */,
9740EEB11CF90186004384FC /* Flutter */, 9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */, 97C146F01CF9000F007C117D /* Runner */,
73CDD67B2DEC00480059D95D /* SolianNotificationService */, 73CDD67B2DEC00480059D95D /* SolianNotificationService */,
73C305CF2E0BE878009035B9 /* SolianShareExtension */, 73C305CF2E0BE878009035B9 /* SolianShareExtension */,
73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */, 73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */,
7310A7D52EB10962002C0FD3 /* Solian Watch App */, 7310A7D52EB10962002C0FD3 /* Solian Watch App */,
7301DB062F08D99C008390F3 /* SolianWidgetExtension */,
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */, 331C8082294A63A400263BE5 /* RunnerTests */,
91E124CE95BCB4DCD890160D /* Pods */, 91E124CE95BCB4DCD890160D /* Pods */,
@@ -364,6 +408,7 @@
73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */, 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */,
73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */, 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */,
7310A7D42EB10962002C0FD3 /* Solian Watch App.app */, 7310A7D42EB10962002C0FD3 /* Solian Watch App.app */,
7301DB012F08D99C008390F3 /* SolianWidgetExtensionExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -408,6 +453,26 @@
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
}; };
7301DB002F08D99C008390F3 /* SolianWidgetExtensionExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = 7301DB152F08D99D008390F3 /* Build configuration list for PBXNativeTarget "SolianWidgetExtensionExtension" */;
buildPhases = (
7301DAFD2F08D99C008390F3 /* Sources */,
7301DAFE2F08D99C008390F3 /* Frameworks */,
7301DAFF2F08D99C008390F3 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
7301DB062F08D99C008390F3 /* SolianWidgetExtension */,
);
name = SolianWidgetExtensionExtension;
productName = SolianWidgetExtensionExtension;
productReference = 7301DB012F08D99C008390F3 /* SolianWidgetExtensionExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
7310A7D32EB10962002C0FD3 /* Solian Watch App */ = { 7310A7D32EB10962002C0FD3 /* Solian Watch App */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "Solian Watch App" */; buildConfigurationList = 7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "Solian Watch App" */;
@@ -515,6 +580,7 @@
73CDD6802DEC00480059D95D /* PBXTargetDependency */, 73CDD6802DEC00480059D95D /* PBXTargetDependency */,
73C305D72E0BE878009035B9 /* PBXTargetDependency */, 73C305D72E0BE878009035B9 /* PBXTargetDependency */,
73ACDFC22E3D0E6100B63535 /* PBXTargetDependency */, 73ACDFC22E3D0E6100B63535 /* PBXTargetDependency */,
7301DB0F2F08D99D008390F3 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
73268D272DEB012A0076E970 /* Services */, 73268D272DEB012A0076E970 /* Services */,
@@ -531,7 +597,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 2600; LastSwiftUpdateCheck = 2620;
LastUpgradeCheck = 1510; LastUpgradeCheck = 1510;
ORGANIZATIONNAME = ""; ORGANIZATIONNAME = "";
TargetAttributes = { TargetAttributes = {
@@ -539,6 +605,9 @@
CreatedOnToolsVersion = 14.0; CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D; TestTargetID = 97C146ED1CF9000F007C117D;
}; };
7301DB002F08D99C008390F3 = {
CreatedOnToolsVersion = 26.2;
};
7310A7D32EB10962002C0FD3 = { 7310A7D32EB10962002C0FD3 = {
CreatedOnToolsVersion = 26.0.1; CreatedOnToolsVersion = 26.0.1;
}; };
@@ -563,6 +632,11 @@
knownRegions = ( knownRegions = (
en, en,
Base, Base,
"zh-Hans",
es,
ja,
ko,
"zh-Hant",
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
@@ -576,6 +650,7 @@
73C305CD2E0BE878009035B9 /* SolianShareExtension */, 73C305CD2E0BE878009035B9 /* SolianShareExtension */,
73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */, 73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */,
7310A7D32EB10962002C0FD3 /* Solian Watch App */, 7310A7D32EB10962002C0FD3 /* Solian Watch App */,
7301DB002F08D99C008390F3 /* SolianWidgetExtensionExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -588,6 +663,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
7301DAFF2F08D99C008390F3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
7310A7D22EB10962002C0FD3 /* Resources */ = { 7310A7D22EB10962002C0FD3 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -852,6 +934,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
7301DAFD2F08D99C008390F3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
7310A7D02EB10962002C0FD3 /* Sources */ = { 7310A7D02EB10962002C0FD3 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -898,6 +987,11 @@
target = 97C146ED1CF9000F007C117D /* Runner */; target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
}; };
7301DB0F2F08D99D008390F3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 7301DB002F08D99C008390F3 /* SolianWidgetExtensionExtension */;
targetProxy = 7301DB0E2F08D99D008390F3 /* PBXContainerItemProxy */;
};
73ACDFC22E3D0E6100B63535 /* PBXTargetDependency */ = { 73ACDFC22E3D0E6100B63535 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = 73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */; target = 73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */;
@@ -1002,7 +1096,7 @@
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian; INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -1080,6 +1174,138 @@
}; };
name = Profile; name = Profile;
}; };
7301DB112F08D99D008390F3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = SolianWidgetExtensionExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SolianWidgetExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianWidgetExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
7301DB122F08D99D008390F3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = SolianWidgetExtensionExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SolianWidgetExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianWidgetExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
7301DB132F08D99D008390F3 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = SolianWidgetExtensionExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SolianWidgetExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianWidgetExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Profile;
};
7310A7E02EB10963002C0FD3 /* Debug */ = { 7310A7E02EB10963002C0FD3 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 31EA49B10397BD4145AD765E /* Pods-Solian Watch App.debug.xcconfig */; baseConfigurationReference = 31EA49B10397BD4145AD765E /* Pods-Solian Watch App.debug.xcconfig */;
@@ -1371,7 +1597,7 @@
INFOPLIST_FILE = SolianShareExtension/Info.plist; INFOPLIST_FILE = SolianShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -1416,7 +1642,7 @@
INFOPLIST_FILE = SolianShareExtension/Info.plist; INFOPLIST_FILE = SolianShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -1458,7 +1684,7 @@
INFOPLIST_FILE = SolianShareExtension/Info.plist; INFOPLIST_FILE = SolianShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -1724,7 +1950,7 @@
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian; INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -1755,7 +1981,7 @@
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian; INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -1785,6 +2011,16 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
7301DB152F08D99D008390F3 /* Build configuration list for PBXNativeTarget "SolianWidgetExtensionExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7301DB112F08D99D008390F3 /* Debug */,
7301DB122F08D99D008390F3 /* Release */,
7301DB132F08D99D008390F3 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "Solian Watch App" */ = { 7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "Solian Watch App" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7310A7D32EB10962002C0FD3"
BuildableName = "Solian Watch App.app"
BlueprintName = "Solian Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7310A7D32EB10962002C0FD3"
BuildableName = "Solian Watch App.app"
BlueprintName = "Solian Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7310A7D32EB10962002C0FD3"
BuildableName = "Solian Watch App.app"
BlueprintName = "Solian Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "73ACDFAA2E3D0E6100B63535"
BuildableName = "SolianBroadcastExtension.appex"
BlueprintName = "SolianBroadcastExtension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "73CDD6792DEC00480059D95D"
BuildableName = "SolianNotificationService.appex"
BlueprintName = "SolianNotificationService"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "73C305CD2E0BE878009035B9"
BuildableName = "SolianShareExtension.appex"
BlueprintName = "SolianShareExtension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7301DB002F08D99C008390F3"
BuildableName = "SolianWidgetExtensionExtension.appex"
BlueprintName = "SolianWidgetExtensionExtension"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7310A7D32EB10962002C0FD3"
BuildableName = "Solian Watch App.app"
BlueprintName = "Solian Watch App"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,6 +1,9 @@
import Flutter import Flutter
import WidgetKit
import UIKit import UIKit
import WatchConnectivity import WatchConnectivity
import AppIntents
import flutter_app_intents
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
@@ -11,6 +14,9 @@ import WatchConnectivity
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
syncDefaultsToGroup()
WidgetCenter.shared.reloadAllTimelines()
UNUserNotificationCenter.current().delegate = notifyDelegate UNUserNotificationCenter.current().delegate = notifyDelegate
let replyableMessageCategory = UNNotificationCategory( let replyableMessageCategory = UNNotificationCategory(
@@ -29,6 +35,9 @@ import WatchConnectivity
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
// Setup widget sync method channel
setupWidgetSyncChannel()
// Always initialize and retain a strong reference // Always initialize and retain a strong reference
if WCSession.isSupported() { if WCSession.isSupported() {
AppDelegate.sharedWatchConnectivityService = WatchConnectivityService.shared AppDelegate.sharedWatchConnectivityService = WatchConnectivityService.shared
@@ -38,6 +47,30 @@ import WatchConnectivity
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
private func setupWidgetSyncChannel() {
let controller = window?.rootViewController as? FlutterViewController
let channel = FlutterMethodChannel(name: "dev.solsynth.solian/widget", binaryMessenger: controller!.binaryMessenger)
channel.setMethodCallHandler { [weak self] (call, result) in
if call.method == "syncToWidget" {
syncDefaultsToGroup()
WidgetCenter.shared.reloadAllTimelines()
result(true)
} else {
result(FlutterMethodNotImplemented)
}
}
}
override func applicationDidEnterBackground(_ application: UIApplication) {
syncDefaultsToGroup()
WidgetCenter.shared.reloadAllTimelines()
}
override func applicationWillTerminate(_ application: UIApplication) {
syncDefaultsToGroup()
}
} }
final class WatchConnectivityService: NSObject, WCSessionDelegate { final class WatchConnectivityService: NSObject, WCSessionDelegate {
@@ -76,7 +109,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
let token = UserDefaults.standard.getFlutterToken() let token = UserDefaults.standard.getFlutterToken()
let serverUrl = UserDefaults.standard.getServerUrl() let serverUrl = UserDefaults.standard.getServerUrl()
var data: [String: Any] = ["serverUrl": serverUrl ?? ""] var data: [String: Any] = ["serverUrl": serverUrl]
if let token = token { if let token = token {
data["token"] = token data["token"] = token
} }
@@ -94,7 +127,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
let token = UserDefaults.standard.getFlutterToken() let token = UserDefaults.standard.getFlutterToken()
let serverUrl = UserDefaults.standard.getServerUrl() let serverUrl = UserDefaults.standard.getServerUrl()
var data: [String: Any] = ["serverUrl": serverUrl ?? ""] var data: [String: Any] = ["serverUrl": serverUrl]
if let token = token { if let token = token {
data["token"] = token data["token"] = token
} }
@@ -107,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> <string>Allow the Solar Network verify your ownership of the logged in account and continue your action quickly.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Grant access to Microphone will allow Solian record audio for your post.</string> <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> <key>NSPhotoLibraryAddUsageDescription</key>
<string>Grant access to Photo Library will allow Solian download photo to album for you.</string> <string>Grant access to Photo Library will allow Solian download photo to album for you.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>

View File

@@ -0,0 +1,41 @@
//
// GroupDefaultSync.swift
// Runner
//
// Created by LittleSheep on 2026/1/3.
//
import Foundation
private let flutterKeyPrefix = "flutter."
private let flutterKeysToSync: [String] = [
"dyn_user_tk",
"app_server_url"
]
func syncDefaultsToGroup() {
print("[iOS] syncDefaultsToGroup() called")
let standard = UserDefaults.standard
let shared = UserDefaults(suiteName: "group.solsynth.solian")
guard let shared else {
print("[iOS] App Group UserDefaults not available")
return
}
for key in flutterKeysToSync {
let prefixedKey = key.starts(with: flutterKeyPrefix) ? key : flutterKeyPrefix + key
if let value = standard.object(forKey: prefixedKey) {
print("[iOS] Syncing key to App Group: \(prefixedKey)")
shared.set(value, forKey: prefixedKey)
} else {
print("[iOS] Key \(prefixedKey) was not found in the app data, skipping...")
}
}
shared.synchronize()
print("[iOS] Sync completed")
}

View File

@@ -5,7 +5,7 @@
// Created by LittleSheep on 2025/5/31. // Created by LittleSheep on 2025/5/31.
// //
import UserNotifications @preconcurrency import UserNotifications
import Intents import Intents
import Kingfisher import Kingfisher
import UniformTypeIdentifiers import UniformTypeIdentifiers
@@ -149,7 +149,7 @@ class NotificationService: UNNotificationServiceExtension {
let dispatchGroup = DispatchGroup() let dispatchGroup = DispatchGroup()
var attachments: [UNNotificationAttachment] = [] 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 { for attachmentUrl in attachmentUrls {
guard let remoteUrl = URL(string: attachmentUrl) else { guard let remoteUrl = URL(string: attachmentUrl) else {
@@ -163,7 +163,7 @@ class NotificationService: UNNotificationServiceExtension {
.processor(scaleProcessor) .processor(scaleProcessor)
] : nil) { [weak self] result in ] : nil) { [weak self] result in
defer { dispatchGroup.leave() } defer { dispatchGroup.leave() }
guard let self = self else { return } guard self != nil else { return }
switch result { switch result {
case .success(let retrievalResult): case .success(let retrievalResult):
@@ -176,12 +176,12 @@ class NotificationService: UNNotificationServiceExtension {
try retrievalResult.image.pngData()?.write(to: cachedFileUrl) try retrievalResult.image.pngData()?.write(to: cachedFileUrl)
if let attachment = try? UNNotificationAttachment(identifier: attachmentUrl, url: cachedFileUrl, options: [ if let attachment = try? UNNotificationAttachment(identifier: attachmentUrl, url: cachedFileUrl, options: [
UNNotificationAttachmentOptionsTypeHintKey: type?.identifier as Any, UNNotificationAttachmentOptionsTypeHintKey: UTType.png.identifier,
UNNotificationAttachmentOptionsThumbnailHiddenKey: 0, UNNotificationAttachmentOptionsThumbnailHiddenKey: 0,
]) { ]) {
lock.lock() queue.async {
attachments.append(attachment) attachments.append(attachment)
lock.unlock() }
} }
} catch { } catch {
print("Failed to write media to temporary file: \(error.localizedDescription)") print("Failed to write media to temporary file: \(error.localizedDescription)")

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "icon-dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "icon 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "icon.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "icon-dark.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

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

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,720 @@
//
// SolianWidgetExtension.swift
// SolianWidgetExtension
//
// Created by LittleSheep on 2026/1/3.
//
import WidgetKit
import SwiftUI
struct CheckInTip: Codable {
let isPositive: Bool
let title: String
let content: String
enum CodingKeys: String, CodingKey {
case isPositive = "is_positive"
case title
case content
}
}
struct CheckInAccount: Codable {
let id: String
let nick: String?
let profile: CheckInProfile?
}
struct CheckInProfile: Codable {
let picture: String?
}
struct CheckInResult: Codable {
let id: String
let level: Int
let rewardPoints: Int
let rewardExperience: Int
let tips: [CheckInTip]
let accountId: String
let account: CheckInAccount?
let createdAt: String
let updatedAt: String
let deletedAt: String?
enum CodingKeys: String, CodingKey {
case id
case level
case rewardPoints = "reward_points"
case rewardExperience = "reward_experience"
case tips
case accountId = "account_id"
case account
case createdAt = "created_at"
case updatedAt = "updated_at"
case deletedAt = "deleted_at"
}
var createdDate: Date? {
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)
}
}
struct NotableDay: Codable {
let date: String
let localName: String
let globalName: String
let countryCode: String?
let localizableKey: String?
let holidays: [Int]?
enum CodingKeys: String, CodingKey {
case date
case localName = "local_name"
case globalName = "global_name"
case countryCode = "country_code"
case localizableKey = "localizable_key"
case holidays
}
var notableDate: Date? {
ISO8601DateFormatter().date(from: date)
}
var isToday: Bool {
guard let notableDate = notableDate else { return false }
let calendar = Calendar.current
return calendar.isDateInToday(notableDate)
}
}
class CheckInService {
private let networkService = WidgetNetworkService()
func fetchCheckInResult() async throws -> CheckInResult? {
return try await networkService.makeRequest(path: "/pass/accounts/me/check-in")
}
}
class NotableDayService {
private let networkService = WidgetNetworkService()
func fetchRecentNotableDay() async throws -> NotableDay? {
print("[WidgetKit] [NotableDayService] Fetching recent notable day...")
do {
let result: [NotableDay]? = try await networkService.makeRequest(path: "/pass/notable/me/recent")
print("[WidgetKit] [NotableDayService] Result: \(String(describing: result))")
guard let result = result else {
print("[WidgetKit] [NotableDayService] Result is nil")
return nil
}
print("[WidgetKit] [NotableDayService] Result count: \(result.count)")
guard result.isEmpty == false else {
print("[WidgetKit] [NotableDayService] No notable days found")
return nil
}
let firstDay = result.first!
print("[WidgetKit] [NotableDayService] First notable day: \(firstDay.localName), date: \(firstDay.date)")
return firstDay
} catch let decodingError as DecodingError {
print("[WidgetKit] [NotableDayService] Decoding error, trying as single object...")
print("[WidgetKit] [NotableDayService] Error: \(decodingError.localizedDescription)")
switch decodingError {
case .typeMismatch(let type, let context):
print("[WidgetKit] [NotableDayService] Type mismatch: expected \(type), context: \(context.debugDescription)")
case .valueNotFound(let type, let context):
print("[WidgetKit] [NotableDayService] Value not found: type \(type), context: \(context.debugDescription)")
case .keyNotFound(let key, let context):
print("[WidgetKit] [NotableDayService] Key not found: \(key), context: \(context.debugDescription)")
case .dataCorrupted(let context):
print("[WidgetKit] [NotableDayService] Data corrupted: \(context.debugDescription)")
@unknown default:
print("[WidgetKit] [NotableDayService] Unknown decoding error")
}
do {
let singleResult: NotableDay? = try await networkService.makeRequest(path: "/pass/notable/me/recent")
print("[WidgetKit] [NotableDayService] Single object decode succeeded: \(singleResult?.localName ?? "nil")")
return singleResult
} catch {
print("[WidgetKit] [NotableDayService] Single object decode also failed: \(error.localizedDescription)")
throw decodingError
}
} catch {
print("[WidgetKit] [NotableDayService] Error fetching notable day: \(error.localizedDescription)")
print("[WidgetKit] [NotableDayService] Error type: \(type(of: error))")
throw error
}
}
}
struct CheckInEntry: TimelineEntry {
let date: Date
let result: CheckInResult?
let notableDay: NotableDay?
let error: String?
let isLoading: Bool
static func placeholder() -> CheckInEntry {
CheckInEntry(date: Date(), result: nil, notableDay: nil, error: nil, isLoading: true)
}
}
struct Provider: TimelineProvider {
private let apiService = CheckInService()
func placeholder(in context: Context) -> CheckInEntry {
CheckInEntry.placeholder()
}
func getSnapshot(in context: Context, completion: @escaping (CheckInEntry) -> ()) {
Task {
print("[WidgetKit] [Provider] Getting snapshot...")
async let checkInResult = try? await apiService.fetchCheckInResult()
async let notableDay = try? await NotableDayService().fetchRecentNotableDay()
let result = try? await checkInResult
let day = try? await notableDay
print("[WidgetKit] [Provider] Snapshot - CheckIn: \(result != nil ? "Found" : "Not found"), NotableDay: \(day != nil ? "Found" : "Not found")")
let entry = CheckInEntry(date: Date(), result: result, notableDay: day, error: nil, isLoading: false)
completion(entry)
}
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
Task {
let currentDate = Date()
print("[WidgetKit] [Provider] Getting timeline at \(currentDate)...")
do {
async let checkInResult = try await apiService.fetchCheckInResult()
async let notableDay = try await NotableDayService().fetchRecentNotableDay()
let result = try await checkInResult
let day = try await notableDay
print("[WidgetKit] [Provider] Timeline - CheckIn: \(result != nil ? "Found" : "Not found"), NotableDay: \(day != nil ? "Found" : "Not found")")
let entry = CheckInEntry(date: currentDate, result: result, notableDay: day, error: nil, isLoading: false)
let nextUpdateDate: Date
if let result = result, let createdDate = result.createdDate {
let calendar = Calendar.current
if let tomorrow = calendar.date(byAdding: .day, value: 1, to: createdDate) {
nextUpdateDate = min(tomorrow, calendar.date(byAdding: .hour, value: 1, to: currentDate)!)
} else {
nextUpdateDate = calendar.date(byAdding: .hour, value: 1, to: currentDate)!
}
} else {
nextUpdateDate = Calendar.current.date(byAdding: .minute, value: 30, to: currentDate)!
}
print("[WidgetKit] [Provider] Next update at: \(nextUpdateDate)")
let timeline = Timeline(entries: [entry], policy: .after(nextUpdateDate))
completion(timeline)
} catch {
print("[WidgetKit] [Provider] Error in getTimeline: \(error.localizedDescription)")
let entry = CheckInEntry(date: currentDate, result: nil, notableDay: nil, error: error.localizedDescription, isLoading: false)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 10, to: currentDate)!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
}
}
}
struct CheckInWidgetEntryView: View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var family
var body: some View {
if let result = entry.result {
CheckedInView(result: result, notableDay: entry.notableDay)
} else if entry.isLoading {
LoadingView()
} else if let error = entry.error {
ErrorView(error: error)
} else {
NotCheckedInView(notableDay: entry.notableDay)
}
}
private func getLevelName(for level: Int) -> String {
let key = "checkInResultT\(level)"
return NSLocalizedString(key, comment: "Check-in result level name")
}
@ViewBuilder
private func NotableDayView(notableDay: NotableDay) -> some View {
VStack(alignment: .leading, spacing: 4) {
if !notableDay.isToday {
Text(NSLocalizedString("notableDayUpcoming", comment: "Upcoming"))
.font(.caption2)
.foregroundStyle(.secondary)
}
HStack(spacing: isAccessory ? 8 : 6) {
Image(systemName: "sparkles")
.foregroundColor(.orange)
.font(isAccessory ? .caption : .subheadline)
VStack(alignment: .leading, spacing: 2) {
if notableDay.isToday {
Text(String(format: NSLocalizedString("notableDayToday", comment: "{name} is today!"), notableDay.localName))
.font(isAccessory ? .caption : .footnote)
.fontWeight(.bold)
.foregroundColor(.primary)
.lineLimit(2)
} else {
if let notableDate = notableDay.notableDate {
let dateString = isCompact ? formatDateCompact(notableDate) : formatDateRegular(notableDate)
Text(String(format: NSLocalizedString("notableDayIs", comment: "{date} is {name}"), dateString, notableDay.localName))
.font(isAccessory ? .caption : .footnote)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(2)
} else {
Text(notableDay.localName)
.font(isAccessory ? .caption : .footnote)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(2)
}
}
}
Spacer()
}
if notableDay.isToday && !isAccessory {
HStack(spacing: 4) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundColor(.orange)
Text(NSLocalizedString("today", comment: "Today"))
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
private var isCompact: Bool {
family == .systemSmall || isAccessory
}
private func formatDateCompact(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "M/d"
return formatter.string(from: date)
}
private func formatDateRegular(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy"
return formatter.string(from: date)
}
@ViewBuilder
private func CheckedInView(result: CheckInResult, notableDay: NotableDay?) -> some View {
Link(destination: URL(string: "solian://dashboard")!) {
VStack(alignment: .leading, spacing: isAccessory ? 2 : 8) {
HStack(spacing: 8) {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
.font(isAccessory ? .caption : .title3)
Text(getLevelName(for: result.level))
.font(isAccessory ? .caption2 : .headline)
.fontWeight(.bold)
Spacer()
}
if !result.tips.isEmpty {
if isAccessory {
let positiveTips = result.tips.filter { $0.isPositive }
let negativeTips = result.tips.filter { !$0.isPositive }
HStack(spacing: 2) {
if let positiveTip = positiveTips.first {
HStack(spacing: 8) {
Image(systemName: "hand.thumbsup.fill")
.font(.caption2)
.foregroundColor(.secondary)
Text(positiveTip.title)
.font(.caption2)
.foregroundColor(.primary)
.lineLimit(1)
}
}
if let negativeTip = negativeTips.first {
HStack(spacing: 8) {
Image(systemName: "hand.thumbsdown.fill")
.font(.caption2)
.foregroundColor(.secondary)
Text(negativeTip.title)
.font(.caption2)
.foregroundColor(.primary)
.lineLimit(1)
}
}
Spacer()
}
} else if family == .systemSmall {
let positiveTips = result.tips.filter { $0.isPositive }
let negativeTips = result.tips.filter { !$0.isPositive }
VStack(alignment: .leading, spacing: 4) {
if let positiveTip = positiveTips.first {
HStack(spacing: 4) {
Image(systemName: "hand.thumbsup.fill")
.font(.caption)
.foregroundColor(.green.opacity(0.8))
Text(positiveTip.title)
.font(.caption)
.foregroundColor(.primary)
Spacer()
}
}
if let negativeTip = negativeTips.first {
HStack(spacing: 4) {
Image(systemName: "hand.thumbsdown.fill")
.font(.caption)
.foregroundColor(.red.opacity(0.8))
Text(negativeTip.title)
.font(.caption)
.foregroundColor(.primary)
Spacer()
}
}
}
} else {
let positiveTips = result.tips.filter { $0.isPositive }
let negativeTips = result.tips.filter { !$0.isPositive }
VStack(alignment: .leading, spacing: 4) {
if !positiveTips.isEmpty {
HStack(spacing: 6) {
Image(systemName: "hand.thumbsup.fill")
.font(.caption)
.foregroundColor(.green.opacity(0.8))
ForEach(Array(positiveTips.prefix(3)), id: \.title) { tip in
Text(tip.title)
.font(.caption)
.foregroundColor(.primary)
if tip.title != positiveTips.last?.title {
Text("")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
}
if !negativeTips.isEmpty {
HStack(spacing: 6) {
Image(systemName: "hand.thumbsdown.fill")
.font(.caption)
.foregroundColor(.red.opacity(0.8))
ForEach(Array(negativeTips.prefix(3)), id: \.title) { tip in
Text(tip.title)
.font(.caption)
.foregroundColor(.primary)
if tip.title != negativeTips.last?.title {
Text("")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
}
}
}
} else if !isAccessory && family != .systemSmall {
Text("No fortune today")
.font(.caption)
.foregroundColor(.secondary)
}
if let notableDay = notableDay {
NotableDayView(notableDay: notableDay)
}
if family == .systemLarge {
Spacer()
WidgetFooter()
}
}
.padding(isAccessory ? 0 : (family == .systemSmall ? 6 : 12))
}
}
@ViewBuilder
private func WidgetFooter() -> some View {
HStack {
Text("Solian")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.secondary)
Spacer()
}
}
private var isAccessory: Bool {
if #available(iOS 16.0, *) {
if case .accessoryRectangular = family {
return true
}
}
return false
}
@ViewBuilder
private func NotCheckedInView(notableDay: NotableDay?) -> some View {
Link(destination: URL(string: "solian://dashboard")!) {
VStack(alignment: .leading, spacing: isAccessory ? 2 : 8) {
HStack(spacing: 8) {
Image(systemName: "flame.fill")
.foregroundColor(.secondary)
.font(isAccessory ? .caption : .title3)
Text(NSLocalizedString("checkIn", comment: "Check In"))
.font(isAccessory ? .caption2 : .headline)
.fontWeight(.bold)
Spacer()
}
if !isAccessory {
Text(NSLocalizedString("tapToCheckIn", comment: "Tap to check in today"))
.font(.caption)
.foregroundColor(.secondary)
if let notableDay = notableDay {
NotableDayView(notableDay: notableDay)
}
Spacer()
WidgetFooter()
} else if let notableDay = notableDay {
NotableDayView(notableDay: notableDay)
}
}
.padding(isAccessory ? 0 : (family == .systemSmall ? 6 : 12))
}
}
@ViewBuilder
private func LoadingView() -> some View {
VStack(alignment: .leading, spacing: isAccessory ? 2 : 8) {
HStack(spacing: 4) {
ProgressView()
.scaleEffect(isAccessory ? 0.6 : 0.8)
Text(NSLocalizedString("loading", comment: "Loading..."))
.font(isAccessory ? .caption2 : .caption)
.foregroundColor(.secondary)
Spacer()
}
if !isAccessory {
Spacer()
WidgetFooter()
}
}
.padding(isAccessory ? 0 : 12)
}
@ViewBuilder
private func ErrorView(error: String) -> some View {
Link(destination: URL(string: "solian://dashboard")!) {
VStack(alignment: .leading, spacing: isAccessory ? 2 : 8) {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.secondary)
.font(isAccessory ? .caption : .title3)
Text(NSLocalizedString("error", comment: "Error"))
.font(isAccessory ? .caption2 : .headline)
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()
WidgetFooter()
}
}
.padding(isAccessory ? 0 : (family == .systemSmall ? 6 : 12))
}
}
}
struct CheckInWidgetRootView: View {
var entry: Provider.Entry
@Environment(\.colorScheme) var colorScheme
var body: some View {
if #available(iOS 17.0, *) {
ZStack {
CheckInWidgetEntryView(entry: entry)
if entry.result != nil || entry.notableDay != 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.18)
.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 {
CheckInWidgetEntryView(entry: entry)
.padding()
.background()
}
}
}
struct SolianCheckInWidget: Widget {
let kind: String = "SolianCheckInWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
CheckInWidgetRootView(entry: entry)
}
.configurationDisplayName("Check In")
.description("View your daily check-in status")
.supportedFamilies(supportedFamilies)
}
private var supportedFamilies: [WidgetFamily] {
#if os(iOS)
return [.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular]
#else
return [.systemSmall, .systemMedium, .systemLarge]
#endif
}
}
#Preview(as: .systemSmall) {
SolianCheckInWidget()
} timeline: {
CheckInEntry(date: .now, result: nil, notableDay: nil, error: nil, isLoading: false)
}
#Preview(as: .systemMedium) {
SolianCheckInWidget()
} timeline: {
CheckInEntry(
date: .now,
result: CheckInResult(
id: "test-id",
level: 2,
rewardPoints: 10,
rewardExperience: 100,
tips: [
CheckInTip(isPositive: true, title: "Good Luck", content: "Great day"),
CheckInTip(isPositive: true, title: "Creative", content: "Inspiration"),
CheckInTip(isPositive: false, title: "Shopping", content: "Expensive")
],
accountId: "account-id",
account: nil,
createdAt: ISO8601DateFormatter().string(from: Date()),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
),
notableDay: NotableDay(
date: ISO8601DateFormatter().string(from: Calendar.current.date(byAdding: .day, value: 5, to: Date())!),
localName: "Christmas",
globalName: "Christmas",
countryCode: nil,
localizableKey: nil,
holidays: []
),
error: nil,
isLoading: false
)
}
#if os(iOS)
#Preview(as: .accessoryRectangular) {
SolianCheckInWidget()
} timeline: {
CheckInEntry(
date: .now,
result: CheckInResult(
id: "test-id",
level: 4,
rewardPoints: 50,
rewardExperience: 500,
tips: [
CheckInTip(isPositive: true, title: "Lucky", content: "Great fortune"),
CheckInTip(isPositive: true, title: "Success", content: "Opportunity")
],
accountId: "account-id",
account: nil,
createdAt: ISO8601DateFormatter().string(from: Date()),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
),
notableDay: NotableDay(
date: ISO8601DateFormatter().string(from: Date()),
localName: "New Year",
globalName: "New Year",
countryCode: nil,
localizableKey: nil,
holidays: []
),
error: nil,
isLoading: false
)
}
#endif

View File

@@ -0,0 +1,937 @@
//
// SolianNotificationWidget.swift
// Runner
//
// Created by LittleSheep on 2026/1/4.
//
import Foundation
import WidgetKit
import SwiftUI
// MARK: - Notification Widget
struct NotificationMeta: Codable {
let pfp: String?
let images: [String]?
let actionUri: String?
enum CodingKeys: String, CodingKey {
case pfp
case images
case actionUri = "action_uri"
}
}
struct SnNotification: Codable, Identifiable {
let id: String
let topic: String
let title: String
let subtitle: String
let content: String
let meta: NotificationMeta?
let priority: Int
let viewedAt: String?
let accountId: String
let createdAt: String
let updatedAt: String
let deletedAt: String?
enum CodingKeys: String, CodingKey {
case id
case topic
case title
case subtitle
case content
case meta
case priority
case viewedAt = "viewed_at"
case accountId = "account_id"
case createdAt = "created_at"
case updatedAt = "updated_at"
case deletedAt = "deleted_at"
}
var createdDate: Date? {
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 {
return viewedAt == nil
}
func getTopicIcon() -> String {
switch topic {
case "post.replies":
return "arrow.uturn.backward"
case "wallet.transactions":
return "wallet.pass"
case "relationships.friends.request":
return "person.badge.plus"
case "invites.chat":
return "message.badge"
case "invites.realm":
return "globe"
case "auth.login":
return "arrow.right.square"
case "posts.new":
return "doc.badge.plus"
case "wallet.orders.paid":
return "baggage"
case "posts.reactions.new":
return "face.smiling"
default:
return "bell"
}
}
}
struct NotificationEntry: TimelineEntry {
let date: Date
let notifications: [SnNotification]?
let unreadCount: Int
let error: String?
let isLoading: Bool
static func placeholder() -> NotificationEntry {
NotificationEntry(date: Date(), notifications: nil, unreadCount: 0, error: nil, isLoading: true)
}
}
class NotificationService {
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 fetchRecentNotifications(take: Int = 5) async throws -> [SnNotification] {
if take == 0 {
return []
}
guard let token = networkService.token else {
throw RemoteError.missingCredentials
}
let baseURL = networkService.baseURL
guard let url = URL(string: "\(baseURL)/ring/notifications?unmark=true&take=\(take)") 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 notifications = try decoder.decode([SnNotification].self, from: data)
return notifications
case 404:
return []
default:
throw RemoteError.httpError(httpResponse.statusCode)
}
}
func fetchUnreadCount() async throws -> Int {
guard let token = networkService.token else {
throw RemoteError.missingCredentials
}
let baseURL = networkService.baseURL
guard let url = URL(string: "\(baseURL)/ring/notifications/count") 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:
if let count = try? JSONSerialization.jsonObject(with: data) as? Int {
return count
} else if let count = try? JSONSerialization.jsonObject(with: data) as? Double {
return Int(count)
}
return 0
case 404:
return 0
default:
throw RemoteError.httpError(httpResponse.statusCode)
}
}
}
struct NotificationProvider: TimelineProvider {
private let notificationService = NotificationService()
func placeholder(in context: Context) -> NotificationEntry {
NotificationEntry.placeholder()
}
func getSnapshot(in context: Context, completion: @escaping (NotificationEntry) -> ()) {
Task {
print("[WidgetKit] [NotificationProvider] Getting snapshot...")
async let notifications = try? await notificationService.fetchRecentNotifications(take: 5)
async let unreadCount = try? await notificationService.fetchUnreadCount()
let notifs = try? await notifications
let unread = (try? await unreadCount) ?? 0
print("[WidgetKit] [NotificationProvider] Snapshot - Notifications: \(notifs?.count ?? 0), Unread: \(unread)")
let entry = NotificationEntry(date: Date(), notifications: notifs, unreadCount: unread, error: nil, isLoading: false)
completion(entry)
}
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
Task {
let currentDate = Date()
print("[WidgetKit] [NotificationProvider] Getting timeline at \(currentDate)...")
do {
let takeLimit: Int
switch context.family {
case .systemSmall:
takeLimit = 0
case .systemMedium:
takeLimit = 1
case .systemLarge:
takeLimit = 3
default:
takeLimit = 0
}
async let notifications = try await notificationService.fetchRecentNotifications(take: takeLimit)
async let unreadCount = try await notificationService.fetchUnreadCount()
let notifs = try await notifications
let unread = try await unreadCount
print("[WidgetKit] [NotificationProvider] Timeline - Notifications: \(notifs.count), Unread: \(unread)")
let entry = NotificationEntry(date: currentDate, notifications: notifs, unreadCount: unread, error: nil, isLoading: false)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)!
print("[WidgetKit] [NotificationProvider] Next update at: \(nextUpdate)")
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
} catch {
print("[WidgetKit] [NotificationProvider] Error in getTimeline: \(error.localizedDescription)")
let entry = NotificationEntry(date: currentDate, notifications: nil, unreadCount: 0, 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 NotificationWidgetEntryView: View {
var entry: NotificationProvider.Entry
@Environment(\.widgetFamily) var family
var body: some View {
if let notifications = entry.notifications, !notifications.isEmpty {
HasNotificationsView(notifications: notifications, unreadCount: entry.unreadCount)
} else if entry.isLoading {
LoadingView()
} else if let error = entry.error {
ErrorView(error: error)
} else {
EmptyView()
}
}
private var isCompact: Bool {
family == .systemSmall || isAccessory
}
private var isAccessory: Bool {
if #available(iOS 16.0, *) {
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
}
@ViewBuilder
private func HasNotificationsView(notifications: [SnNotification], unreadCount: Int) -> some View {
Link(destination: URL(string: "solian://notifications")!) {
if isCompact {
if isAccessory {
if isCircular {
ZStack {
Image(systemName: "bell.fill")
.font(.system(size: 20))
.foregroundColor(unreadCount > 0 ? .orange : .gray)
if unreadCount > 0 {
Text("\(min(unreadCount, 99))")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.white)
.padding(4)
.background(Circle().fill(Color.blue))
.offset(x: 12, y: -12)
}
}
} else {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "bell.fill")
.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) {
HStack(spacing: 6) {
Image(systemName: "bell.fill")
.font(.subheadline)
.foregroundColor(.orange)
Text(NSLocalizedString("notifications", comment: "Notifications"))
.font(.headline)
.fontWeight(.bold)
Spacer()
}.padding(.bottom, 8)
Spacer(minLength: 2)
if unreadCount > 0 {
VStack(alignment: .leading, spacing: 2) {
Text("\(unreadCount)")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.bold)
}
}
Spacer(minLength: 2)
if unreadCount > 0 {
Text(NSLocalizedString("unreadNotifications", comment: "unread notifications"))
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
} else {
Text(NSLocalizedString("noUnreadNotifications", comment: "no unread notifications"))
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(12)
}
} else {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "bell.fill")
.font(.subheadline)
.foregroundColor(.orange)
Text(NSLocalizedString("notifications", comment: "Notifications"))
.font(.headline)
.fontWeight(.bold)
Spacer()
if unreadCount > 0 {
Text("\(unreadCount)")
.font(.caption)
.fontWeight(.bold)
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.blue)
.clipShape(Capsule())
}
}.padding(.bottom, 8)
let displayCount = family == .systemMedium ? 1 : 5
let displayNotifications = Array(notifications.prefix(displayCount))
VStack(alignment: .leading, spacing: 4) {
ForEach(displayNotifications) { notification in
NotificationItemView(notification: notification, compact: false)
}
}
if family == .systemMedium {
Spacer()
} else {
Spacer()
Text(NSLocalizedString("tapToViewAll", comment: "Tap to view all notifications"))
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(12)
}
}
}
@ViewBuilder
private func NotificationItemView(notification: SnNotification, compact: Bool) -> some View {
HStack(alignment: .top, spacing: compact ? 6 : 12) {
if compact {
Image(systemName: notification.getTopicIcon())
.font(.caption)
.foregroundColor(.secondary)
} else {
ZStack {
Circle()
.fill(Color.gray.opacity(0.2))
.frame(width: 32, height: 32)
Image(systemName: notification.getTopicIcon())
.font(.caption)
.foregroundColor(.primary)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(notification.title)
.font(compact ? .caption : .subheadline)
.fontWeight(notification.isUnread ? .semibold : .regular)
.lineLimit(1)
if !compact && !notification.subtitle.isEmpty {
Text(notification.subtitle)
.font(.caption)
.lineLimit(1)
}
if !compact && !notification.content.isEmpty {
Text(notification.content)
.font(.caption2)
.lineLimit(2)
.multilineTextAlignment(.leading)
}
if let createdDate = notification.createdDate {
Text(formatRelativeTime(createdDate))
.font(.caption2)
.foregroundColor(.secondary)
.padding(.top, 2)
}
}
Spacer()
if notification.isUnread {
Circle()
.fill(Color.blue)
.frame(width: compact ? 6 : 8, height: compact ? 6 : 8)
.padding(.trailing, 6)
.padding(.top, 8)
}
}
.padding(.vertical, compact ? 2 : 4)
}
@ViewBuilder
private func NotificationCompactItem(notification: SnNotification) -> some View {
HStack(spacing: 4) {
Image(systemName: notification.getTopicIcon())
.font(.caption2)
.foregroundColor(.secondary)
Text(notification.title)
.font(.caption2)
.lineLimit(1)
.fontWeight(notification.isUnread ? .semibold : .regular)
Spacer()
}
}
@ViewBuilder
private func EmptyView() -> some View {
Link(destination: URL(string: "solian://notifications")!) {
if isCircular {
ZStack {
Image(systemName: "bell")
.font(.system(size: 20))
.foregroundColor(.gray)
}
} else {
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
HStack(spacing: 6) {
Image(systemName: "bell")
.font(isAccessory ? .caption : .title3)
.foregroundColor(.secondary)
Text(NSLocalizedString("notifications", comment: "Notifications"))
.font(isAccessory ? .caption2 : .headline)
.fontWeight(.bold)
Spacer()
}
if !isAccessory {
Text(NSLocalizedString("noNotifications", comment: "No notifications yet"))
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
}
.padding(isAccessory ? 4 : 12)
}
}
}
@ViewBuilder
private func LoadingView() -> some View {
if isCircular {
ProgressView()
.scaleEffect(0.8)
} else {
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
HStack(spacing: 6) {
ProgressView()
.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)
}
}
@ViewBuilder
private func ErrorView(error: String) -> some View {
Link(destination: URL(string: "solian://notifications")!) {
if isCircular {
ZStack {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 20))
.foregroundColor(.red)
}
} else {
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.secondary)
.font(isAccessory ? .caption : .title3)
Text(NSLocalizedString("error", comment: "Error"))
.font(isAccessory ? .caption2 : .headline)
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)
}
}
}
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 NotificationWidgetRootView: View {
var entry: NotificationProvider.Entry
@Environment(\.colorScheme) var colorScheme
var body: some View {
if #available(iOS 17.0, *) {
ZStack {
NotificationWidgetEntryView(entry: entry)
if let notifications = entry.notifications, !notifications.isEmpty {
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.85,
y: 20
)
}
.allowsHitTesting(false)
}
}
.containerBackground(.fill.tertiary, for: .widget)
.padding(.vertical, 8)
} else {
NotificationWidgetEntryView(entry: entry)
.padding()
.background()
}
}
}
struct SolianNotificationWidget: Widget {
let kind: String = "SolianNotificationWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: NotificationProvider()) { entry in
NotificationWidgetRootView(entry: entry)
}
.configurationDisplayName("Notifications")
.description("View your recent notifications")
.supportedFamilies(supportedFamilies)
}
private var supportedFamilies: [WidgetFamily] {
#if os(iOS)
return [.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryCircular]
#else
return [.systemSmall, .systemMedium, .systemLarge]
#endif
}
}
#if os(iOS)
#Preview(as: .accessoryRectangular) {
SolianNotificationWidget()
} timeline: {
NotificationEntry(
date: .now,
notifications: [
SnNotification(
id: "1",
topic: "post.replies",
title: "New reply to your post",
subtitle: "Someone replied to your message",
content: "This is notification content",
meta: nil,
priority: 0,
viewedAt: nil,
accountId: "acc-1",
createdAt: ISO8601DateFormatter().string(from: Date()),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
),
SnNotification(
id: "2",
topic: "relationships.friends.request",
title: "New friend request",
subtitle: "You have a pending friend request",
content: "Someone wants to be your friend",
meta: nil,
priority: 0,
viewedAt: ISO8601DateFormatter().string(from: Date()),
accountId: "acc-1",
createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-3600)),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
)
],
unreadCount: 1,
error: nil,
isLoading: false
)
}
#endif
#Preview(as: .systemSmall) {
SolianNotificationWidget()
} timeline: {
NotificationEntry(
date: .now,
notifications: [
SnNotification(
id: "1",
topic: "post.replies",
title: "New reply to your post",
subtitle: "Someone replied to your message",
content: "This is notification content",
meta: nil,
priority: 0,
viewedAt: nil,
accountId: "acc-1",
createdAt: ISO8601DateFormatter().string(from: Date()),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
),
SnNotification(
id: "2",
topic: "relationships.friends.request",
title: "New friend request",
subtitle: "You have a pending friend request",
content: "Someone wants to be your friend",
meta: nil,
priority: 0,
viewedAt: ISO8601DateFormatter().string(from: Date()),
accountId: "acc-1",
createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-3600)),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
)
],
unreadCount: 1,
error: nil,
isLoading: false
)
}
#Preview(as: .systemMedium) {
SolianNotificationWidget()
} timeline: {
NotificationEntry(
date: .now,
notifications: [
SnNotification(
id: "1",
topic: "post.replies",
title: "New reply to your post",
subtitle: "Someone replied to your message",
content: "This is notification content",
meta: nil,
priority: 0,
viewedAt: nil,
accountId: "acc-1",
createdAt: ISO8601DateFormatter().string(from: Date()),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
),
SnNotification(
id: "2",
topic: "relationships.friends.request",
title: "New friend request",
subtitle: "You have a pending friend request",
content: "Someone wants to be your friend",
meta: nil,
priority: 0,
viewedAt: nil,
accountId: "acc-1",
createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-3600)),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
),
SnNotification(
id: "3",
topic: "invites.chat",
title: "New chat invite",
subtitle: "You've been invited to a chat",
content: "Join the conversation",
meta: nil,
priority: 0,
viewedAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-86400)),
accountId: "acc-1",
createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-86400)),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
)
],
unreadCount: 2,
error: nil,
isLoading: false
)
}
#if os(iOS)
#Preview(as: .systemLarge) {
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
),
SnNotification(
id: "2",
topic: "relationships.friends.request",
title: "New friend request",
subtitle: "You have a pending friend request",
content: "Someone wants to be your friend",
meta: nil,
priority: 0,
viewedAt: nil,
accountId: "acc-1",
createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-3600)),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
),
SnNotification(
id: "3",
topic: "invites.chat",
title: "New chat invite",
subtitle: "You've been invited to a chat",
content: "Join the conversation",
meta: nil,
priority: 0,
viewedAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-86400)),
accountId: "acc-1",
createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-86400)),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
)
],
unreadCount: 3,
error: nil,
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

@@ -0,0 +1,18 @@
//
// SolianWidgetExtensionBundle.swift
// SolianWidgetExtension
//
// Created by LittleSheep on 2026/1/3.
//
import WidgetKit
import SwiftUI
@main
struct SolianWidgetExtensionBundle: WidgetBundle {
var body: some Widget {
SolianCheckInWidget()
SolianNotificationWidget()
SolianPostShuffleWidget()
}
}

View File

@@ -0,0 +1,136 @@
//
// Networking.swift
// SolianWidgetExtensionExtension
//
// Created by LittleSheep on 2026/1/4.
//
import Foundation
enum RemoteError: Error {
case missingCredentials
case invalidURL
case invalidResponse
case httpError(Int)
case decodingError
}
extension RemoteError: LocalizedError {
var errorDescription: String? {
switch self {
case .missingCredentials:
return "Please open the app to sign in."
case .invalidURL:
return "Invalid server configuration."
case .invalidResponse:
return "Server returned an invalid response."
case .httpError(let code):
return "Server error (\(code))."
case .decodingError:
return "Failed to read server data."
}
}
}
struct TokenData: Codable {
let token: String
}
class WidgetNetworkService {
private let appGroup = "group.solsynth.solian"
private let tokenKey = "flutter.dyn_user_tk"
private let urlKey = "flutter.app_server_url"
private lazy var session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 10.0
configuration.timeoutIntervalForResource = 10.0
configuration.waitsForConnectivity = false
return URLSession(configuration: configuration)
}()
private var userDefaults: UserDefaults? {
UserDefaults(suiteName: appGroup)
}
var token: String? {
guard let tokenString = userDefaults?.string(forKey: tokenKey) else {
return nil
}
guard let data = tokenString.data(using: .utf8) else {
return nil
}
do {
let tokenData = try JSONDecoder().decode(TokenData.self, from: data)
return tokenData.token
} catch {
print("[WidgetKit] Failed to decode token: \(error)")
return nil
}
}
var baseURL: String {
return userDefaults?.string(forKey: urlKey) ?? "https://api.solian.app"
}
func makeRequest<T: Codable>(
path: String,
method: String = "GET",
headers: [String: String] = [:]
) async throws -> T? {
guard let token = token else {
throw RemoteError.missingCredentials
}
guard let url = URL(string: "\(baseURL)\(path)") else {
throw RemoteError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
request.timeoutInterval = 10.0
print("[WidgetKit] [Network] Requesting: \(baseURL)\(path)")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw RemoteError.invalidResponse
}
print("[WidgetKit] [Network] Status: \(httpResponse.statusCode), Data length: \(data.count)")
if let jsonString = String(data: data, encoding: .utf8) {
print("[WidgetKit] [Network] Response: \(jsonString.prefix(500))")
}
switch httpResponse.statusCode {
case 200...299:
let decoder = JSONDecoder()
do {
let result = try decoder.decode(T.self, from: data)
print("[WidgetKit] [Network] Successfully decoded response")
return result
} catch {
print("[WidgetKit] [Network] Decoding error: \(error.localizedDescription)")
print("[WidgetKit] [Network] Expected type: \(String(describing: T.self))")
throw RemoteError.decodingError
}
case 404:
print("[WidgetKit] [Network] Resource not found (404)")
return nil
default:
print("[WidgetKit] [Network] HTTP Error: \(httpResponse.statusCode)")
throw RemoteError.httpError(httpResponse.statusCode)
}
}
}

View File

@@ -0,0 +1,46 @@
/* Check In Level Names */
"checkInResultT0" = "Great Misfortune";
"checkInResultT1" = "Misfortune";
"checkInResultT2" = "Moderate";
"checkInResultT3" = "Fortune";
"checkInResultT4" = "Great Fortune";
"checkInResultT5" = "Special";
/* Widget UI Strings */
"checkIn" = "Check In";
"tapToCheckIn" = "Tap to check in today";
"error" = "Error";
"openAppToRefresh" = "Open app to refresh";
"loading" = "Loading...";
"rewardPoints" = "%d";
"rewardExperience" = "%d EXP";
"footer" = "Solian Check In";
/* Notable Day Strings */
"notableDayToday" = "%@ is today!";
"notableDayIs" = "%@ is %@";
"notableDayUpcoming" = "Upcoming";
"today" = "Today";
/* Notification Widget Strings */
"notifications" = "Notifications";
"noNotifications" = "No notifications yet";
"tapToViewAll" = "Tap to view all notifications";
"justNow" = "Just now";
"minutesAgo" = "%d min ago";
"hoursAgo" = "%d hr ago";
"daysAgo" = "%d d ago";
"pleaseSignIn" = "Please open the app to sign in.";
"invalidServerConfig" = "Invalid server configuration.";
"invalidResponse" = "Server returned an invalid response.";
"httpError" = "Server error (%d).";
"decodingError" = "Failed to read server data.";
"unreadNotifications" = "Notification(s) unread";
"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

@@ -0,0 +1,41 @@
/* Check In Level Names */
"checkInResultT0" = "Gran Desventura";
"checkInResultT1" = "Desventura";
"checkInResultT2" = "Moderado";
"checkInResultT3" = "Buena Fortuna";
"checkInResultT4" = "Gran Fortuna";
"checkInResultT5" = "Especial";
/* Widget UI Strings */
"checkIn" = "Registrar";
"tapToCheckIn" = "Toca para registrar hoy";
"error" = "Error";
"openAppToRefresh" = "Abre la aplicación para actualizar";
"loading" = "Cargando...";
"rewardPoints" = "%d";
"rewardExperience" = "%d EXP";
"footer" = "Registro Solian";
/* Notable Day Strings */
"notableDayToday" = "%@ es hoy!";
"notableDayIs" = "%@ es %@";
"today" = "Hoy";
/* Notification Widget Strings */
"notifications" = "Notificaciones";
"noNotifications" = "Aún no hay notificaciones";
"tapToViewAll" = "Toca para ver todas las notificaciones";
"justNow" = "Ahora mismo";
"minutesAgo" = "hace %d min";
"hoursAgo" = "hace %d hr";
"daysAgo" = "hace %d días";
"pleaseSignIn" = "Por favor, abre la aplicación para iniciar sesión.";
"invalidServerConfig" = "Configuración del servidor no válida.";
"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

@@ -0,0 +1,41 @@
/* Check In Level Names */
"checkInResultT0" = "大凶";
"checkInResultT1" = "凶";
"checkInResultT2" = "中平";
"checkInResultT3" = "吉";
"checkInResultT4" = "大吉";
"checkInResultT5" = "特殊";
/* Widget UI Strings */
"checkIn" = "チェックイン";
"tapToCheckIn" = "タップして今日チェックイン";
"error" = "エラー";
"openAppToRefresh" = "アプリを開いて更新";
"loading" = "読み込み中...";
"rewardPoints" = "%d";
"rewardExperience" = "%d 経験値";
"footer" = "Solian チェックイン";
/* Notable Day Strings */
"notableDayToday" = "%@は今日です!";
"notableDayIs" = "%@は%@です";
"today" = "今日";
/* Notification Widget Strings */
"notifications" = "通知";
"noNotifications" = "まだ通知はありません";
"tapToViewAll" = "タップしてすべての通知を表示";
"justNow" = "たった今";
"minutesAgo" = "%d分前";
"hoursAgo" = "%d時間前";
"daysAgo" = "%d日前";
"pleaseSignIn" = "アプリを開いてサインインしてください。";
"invalidServerConfig" = "サーバー設定が無効です。";
"invalidResponse" = "サーバーから無効な応答が返されました。";
"httpError" = "サーバーエラー (%d)。";
"decodingError" = "サーバーデータの読み込みに失敗しました。";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "利用可能な投稿がありません";
"tapToRefresh" = "タップして更新";
"loadingPost" = "投稿を読み込み中...";

View File

@@ -0,0 +1,41 @@
/* Check In Level Names */
"checkInResultT0" = "대흉";
"checkInResultT1" = "흉";
"checkInResultT2" = "중평";
"checkInResultT3" = "길";
"checkInResultT4" = "대길";
"checkInResultT5" = "특수";
/* Widget UI Strings */
"checkIn" = "체크인";
"tapToCheckIn" = "탭하여 오늘 체크인";
"error" = "오류";
"openAppToRefresh" = "앱을 열어 새로고침";
"loading" = "로딩 중...";
"rewardPoints" = "%d";
"rewardExperience" = "%d 경험치";
"footer" = "Solian 체크인";
/* Notable Day Strings */
"notableDayToday" = "%@ 오늘입니다!";
"notableDayIs" = "%@ 은/는 %@";
"today" = "오늘";
/* Notification Widget Strings */
"notifications" = "알림";
"noNotifications" = "아직 알림이 없습니다";
"tapToViewAll" = "탭하여 모든 알림 보기";
"justNow" = "방금";
"minutesAgo" = "%d분 전";
"hoursAgo" = "%d시간 전";
"daysAgo" = "%d일 전";
"pleaseSignIn" = "앱을 열어 로그인하세요.";
"invalidServerConfig" = "서버 구성이 올바르지 않습니다.";
"invalidResponse" = "서버에서 잘못된 응답을 받았습니다.";
"httpError" = "서버 오류 (%d).";
"decodingError" = "서버 데이터 읽기에 실패했습니다.";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "사용 가능한 게시물이 없습니다";
"tapToRefresh" = "탭하여 새로고침";
"loadingPost" = "게시물 로딩 중...";

View File

@@ -0,0 +1,46 @@
/* Check In Level Names */
"checkInResultT0" = "大凶";
"checkInResultT1" = "凶";
"checkInResultT2" = "中平";
"checkInResultT3" = "吉";
"checkInResultT4" = "大吉";
"checkInResultT5" = "特殊";
/* Widget UI Strings */
"checkIn" = "打卡";
"tapToCheckIn" = "点击今日打卡";
"error" = "错误";
"openAppToRefresh" = "打开应用以刷新";
"loading" = "加载中...";
"rewardPoints" = "%d";
"rewardExperience" = "%d 经验值";
"footer" = "Solian 签到";
/* Notable Day Strings */
"notableDayToday" = "%@是今天!";
"notableDayIs" = "%@ 是 %@";
"notableDayUpcoming" = "接下来";
"today" = "今天";
/* Notification Widget Strings */
"notifications" = "通知";
"noNotifications" = "还没有通知";
"tapToViewAll" = "点击查看所有通知";
"justNow" = "刚刚";
"minutesAgo" = "%d分钟前";
"hoursAgo" = "%d小时前";
"daysAgo" = "%d天前";
"pleaseSignIn" = "请打开应用登录。";
"invalidServerConfig" = "服务器配置无效。";
"invalidResponse" = "服务器返回了无效响应。";
"httpError" = "服务器错误 (%d)。";
"decodingError" = "读取服务器数据失败。";
"unreadNotifications" = "条通知未读";
"noNotifications" = "没有通知";
"noUnreadNotifications" = "没有未读通知";
"unread" = "未读";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "没有可用的帖子";
"tapToRefresh" = "点击刷新";
"loadingPost" = "加载帖子中...";

View File

@@ -0,0 +1,41 @@
/* Check In Level Names */
"checkInResultT0" = "大凶";
"checkInResultT1" = "凶";
"checkInResultT2" = "中平";
"checkInResultT3" = "吉";
"checkInResultT4" = "大吉";
"checkInResultT5" = "特殊";
/* Widget UI Strings */
"checkIn" = "打卡";
"tapToCheckIn" = "點擊今日打卡";
"error" = "錯誤";
"openAppToRefresh" = "打開應用以刷新";
"loading" = "載入中...";
"rewardPoints" = "%d";
"rewardExperience" = "%d 經驗值";
"footer" = "Solian 簽到";
/* Notable Day Strings */
"notableDayToday" = "%@是今天!";
"notableDayIs" = "%@ 是 %@";
"today" = "今天";
/* Notification Widget Strings */
"notifications" = "通知";
"noNotifications" = "還沒有通知";
"tapToViewAll" = "點擊查看所有通知";
"justNow" = "剛剛";
"minutesAgo" = "%d分鐘前";
"hoursAgo" = "%d小時前";
"daysAgo" = "%d天前";
"pleaseSignIn" = "請打開應用登錄。";
"invalidServerConfig" = "伺服器配置無效。";
"invalidResponse" = "伺服器返回了無效響應。";
"httpError" = "伺服器錯誤 (%d)。";
"decodingError" = "讀取伺服器數據失敗。";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "沒有可用的帖子";
"tapToRefresh" = "點擊刷新";
"loadingPost" = "載入帖子中...";

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.solsynth.solian</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

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 'dart:io';
import 'package:croppy/croppy.dart'; import 'package:croppy/croppy.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart';
@@ -20,7 +21,10 @@ import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.dart'; import 'package:island/route.dart';
import 'package:island/services/notify.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/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/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
@@ -97,6 +101,28 @@ void main() async {
talker.error("[SplashScreen] Failed to load timezone database... $err"); 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(); final prefs = await SharedPreferences.getInstance();
if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) { if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) {
@@ -169,6 +195,12 @@ void main() async {
runApp( runApp(
ProviderScope( ProviderScope(
retry: (retryCount, error) {
if (error is DioException && error.response?.statusCode == 404) {
return null;
}
return const Duration(milliseconds: 300);
},
observers: [ observers: [
TalkerRiverpodObserver( TalkerRiverpodObserver(
talker: talker, talker: talker,
@@ -282,6 +314,11 @@ class IslandApp extends HookConsumerWidget {
ref.listen(websocketStateProvider, (_, state) { ref.listen(websocketStateProvider, (_, state) {
talker.info('[WebSocket] $state'); talker.info('[WebSocket] $state');
}); });
ref.listen(userInfoProvider, (_, user) {
if (user.value != null) {
WidgetSyncService().syncToWidget();
}
});
Future(() { Future(() {
userNotifier.fetchUser().then((_) { userNotifier.fetchUser().then((_) {
final user = ref.watch(userInfoProvider); final user = ref.watch(userInfoProvider);

View File

@@ -8,7 +8,7 @@ sealed class SnScrappedLink with _$SnScrappedLink {
const factory SnScrappedLink({ const factory SnScrappedLink({
required String type, required String type,
required String url, required String url,
required String title, required String? title,
required String? description, required String? description,
required String? imageUrl, required String? imageUrl,
required String? faviconUrl, required String? faviconUrl,

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$SnScrappedLink { 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 /// Create a copy of SnScrappedLink
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -48,7 +48,7 @@ abstract mixin class $SnScrappedLinkCopyWith<$Res> {
factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl; factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of SnScrappedLink
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable 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,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,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?,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?,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?,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 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) { switch (_that) {
case _SnScrappedLink() when $default != null: 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 _: 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) { switch (_that) {
case _SnScrappedLink(): case _SnScrappedLink():
return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);} 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) { switch (_that) {
case _SnScrappedLink() when $default != null: 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 _: 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 type;
@override final String url; @override final String url;
@override final String title; @override final String? title;
@override final String? description; @override final String? description;
@override final String? imageUrl; @override final String? imageUrl;
@override final String? faviconUrl; @override final String? faviconUrl;
@@ -259,7 +259,7 @@ abstract mixin class _$SnScrappedLinkCopyWith<$Res> implements $SnScrappedLinkCo
factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl; factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of SnScrappedLink
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_SnScrappedLink(
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable 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,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,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?,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?,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?,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 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( _SnScrappedLink(
type: json['type'] as String, type: json['type'] as String,
url: json['url'] as String, url: json['url'] as String,
title: json['title'] as String, title: json['title'] as String?,
description: json['description'] as String?, description: json['description'] as String?,
imageUrl: json['image_url'] as String?, imageUrl: json['image_url'] as String?,
faviconUrl: json['favicon_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? updatedAt,
required DateTime? deletedAt, required DateTime? deletedAt,
required String accountId, required String accountId,
required SnAccount account, // Usually the account was not included in the response
required SnAccount? account,
required String relatedId, required String relatedId,
required SnAccount related, required SnAccount? related,
required DateTime? expiredAt, required DateTime? expiredAt,
required int status, required int status,
}) = _SnRelationship; }) = _SnRelationship;

View File

@@ -15,7 +15,8 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$SnRelationship { 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 /// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -48,11 +49,11 @@ abstract mixin class $SnRelationshipCopyWith<$Res> {
factory $SnRelationshipCopyWith(SnRelationship value, $Res Function(SnRelationship) _then) = _$SnRelationshipCopyWithImpl; factory $SnRelationshipCopyWith(SnRelationship value, $Res Function(SnRelationship) _then) = _$SnRelationshipCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// @nodoc
@@ -65,16 +66,16 @@ class _$SnRelationshipCopyWithImpl<$Res>
/// Create a copy of SnRelationship /// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable 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?,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?,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 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 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 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 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 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 DateTime?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as int, as int,
)); ));
@@ -83,18 +84,24 @@ as int,
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @override
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account { $SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return $SnAccountCopyWith<$Res>(_self.account, (value) { return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value)); return _then(_self.copyWith(account: value));
}); });
}/// Create a copy of SnRelationship }/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @override
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get related { $SnAccountCopyWith<$Res>? get related {
if (_self.related == null) {
return $SnAccountCopyWith<$Res>(_self.related, (value) { return null;
}
return $SnAccountCopyWith<$Res>(_self.related!, (value) {
return _then(_self.copyWith(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) { switch (_that) {
case _SnRelationship() when $default != null: 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 _: 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) { switch (_that) {
case _SnRelationship(): case _SnRelationship():
return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,_that.account,_that.relatedId,_that.related,_that.expiredAt,_that.status);} 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) { switch (_that) {
case _SnRelationship() when $default != null: 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 _: 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? updatedAt;
@override final DateTime? deletedAt; @override final DateTime? deletedAt;
@override final String accountId; @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 String relatedId;
@override final SnAccount related; @override final SnAccount? related;
@override final DateTime? expiredAt; @override final DateTime? expiredAt;
@override final int status; @override final int status;
@@ -275,11 +283,11 @@ abstract mixin class _$SnRelationshipCopyWith<$Res> implements $SnRelationshipCo
factory _$SnRelationshipCopyWith(_SnRelationship value, $Res Function(_SnRelationship) _then) = __$SnRelationshipCopyWithImpl; factory _$SnRelationshipCopyWith(_SnRelationship value, $Res Function(_SnRelationship) _then) = __$SnRelationshipCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// @nodoc
@@ -292,16 +300,16 @@ class __$SnRelationshipCopyWithImpl<$Res>
/// Create a copy of SnRelationship /// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_SnRelationship(
createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable 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?,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?,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 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 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 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 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 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 DateTime?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as int, as int,
)); ));
@@ -311,18 +319,24 @@ as int,
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @override
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account { $SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return $SnAccountCopyWith<$Res>(_self.account, (value) { return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value)); return _then(_self.copyWith(account: value));
}); });
}/// Create a copy of SnRelationship }/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @override
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get related { $SnAccountCopyWith<$Res>? get related {
if (_self.related == null) {
return $SnAccountCopyWith<$Res>(_self.related, (value) { return null;
}
return $SnAccountCopyWith<$Res>(_self.related!, (value) {
return _then(_self.copyWith(related: value)); return _then(_self.copyWith(related: value));
}); });
} }

View File

@@ -18,9 +18,13 @@ _SnRelationship _$SnRelationshipFromJson(Map<String, dynamic> json) =>
? null ? null
: DateTime.parse(json['deleted_at'] as String), : DateTime.parse(json['deleted_at'] as String),
accountId: json['account_id'] 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, 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 expiredAt: json['expired_at'] == null
? null ? null
: DateTime.parse(json['expired_at'] as String), : DateTime.parse(json['expired_at'] as String),
@@ -33,9 +37,9 @@ Map<String, dynamic> _$SnRelationshipToJson(_SnRelationship instance) =>
'updated_at': instance.updatedAt?.toIso8601String(), 'updated_at': instance.updatedAt?.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(),
'account_id': instance.accountId, 'account_id': instance.accountId,
'account': instance.account.toJson(), 'account': instance.account?.toJson(),
'related_id': instance.relatedId, 'related_id': instance.relatedId,
'related': instance.related.toJson(), 'related': instance.related?.toJson(),
'expired_at': instance.expiredAt?.toIso8601String(), 'expired_at': instance.expiredAt?.toIso8601String(),
'status': instance.status, 'status': instance.status,
}; };

View File

@@ -2,6 +2,8 @@ import "dart:async";
import "dart:convert"; import "dart:convert";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.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/models/chat.dart";
import "package:island/pods/chat/chat_room.dart"; import "package:island/pods/chat/chat_room.dart";
import "package:island/pods/lifecycle.dart"; import "package:island/pods/lifecycle.dart";
@@ -198,7 +200,7 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
return _typingStatuses; return _typingStatuses;
} }
void onMessage(WebSocketPacket pkt) { Future<void> onMessage(WebSocketPacket pkt) async {
if (!pkt.type.startsWith('messages')) return; if (!pkt.type.startsWith('messages')) return;
if (['messages.read'].contains(pkt.type)) return; if (['messages.read'].contains(pkt.type)) return;
@@ -238,6 +240,17 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
_messagesNotifier.receiveMessage(message); _messagesNotifier.receiveMessage(message);
// Send read receipt for new message // Send read receipt for new message
sendReadReceipt(); 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; _typingCooldownTimer = null;
}); });
} }
} }

View File

@@ -50,9 +50,6 @@ class MessagesNotifier extends _$MessagesNotifier {
late Future<SnAccount?> Function(String) _fetchAccount; late Future<SnAccount?> Function(String) _fetchAccount;
// Disposal handling
bool _disposed = false;
@override @override
FutureOr<List<LocalChatMessage>> build(String roomId) async { FutureOr<List<LocalChatMessage>> build(String roomId) async {
_apiClient = ref.watch(apiClientProvider); _apiClient = ref.watch(apiClientProvider);
@@ -81,17 +78,11 @@ class MessagesNotifier extends _$MessagesNotifier {
talker.log('MessagesNotifier built for room $roomId'); 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 // Only setup sync and lifecycle listeners if user is a member
if (identity != null) { if (identity != null) {
ref.listen(appLifecycleStateProvider, (_, next) { ref.listen(appLifecycleStateProvider, (_, next) {
next.whenData((state) { next.whenData((state) {
if (_disposed) return; // Check disposal before accessing ref if (!ref.mounted) return; // Check disposal before accessing ref
if (state == AppLifecycleState.paused) { if (state == AppLifecycleState.paused) {
_lastPauseTime = DateTime.now(); _lastPauseTime = DateTime.now();
talker.log('App paused, recording time'); talker.log('App paused, recording time');
@@ -100,7 +91,7 @@ class MessagesNotifier extends _$MessagesNotifier {
final diff = DateTime.now().difference(_lastPauseTime!); final diff = DateTime.now().difference(_lastPauseTime!);
if (diff > const Duration(minutes: 1)) { if (diff > const Duration(minutes: 1)) {
talker.log('App resumed after >1 min, syncing messages'); talker.log('App resumed after >1 min, syncing messages');
if (!_disposed) { if (!!ref.mounted) {
syncMessages(); // Check disposal before calling syncMessages syncMessages(); // Check disposal before calling syncMessages
} }
} else { } else {
@@ -137,7 +128,7 @@ class MessagesNotifier extends _$MessagesNotifier {
uniqueMessages.add(message); uniqueMessages.add(message);
} }
} }
state = AsyncValue.data(uniqueMessages); if (ref.mounted) state = AsyncValue.data(uniqueMessages);
} finally { } finally {
_isUpdatingState = false; _isUpdatingState = false;
} }
@@ -336,7 +327,7 @@ class MessagesNotifier extends _$MessagesNotifier {
talker.log('Starting message sync'); talker.log('Starting message sync');
// Use Future.microtask to set syncing state, but check disposal to avoid errors // Use Future.microtask to set syncing state, but check disposal to avoid errors
Future.microtask(() { Future.microtask(() {
if (!_disposed) { if (!!ref.mounted) {
ref.read(chatSyncingProvider.notifier).set(true); ref.read(chatSyncingProvider.notifier).set(true);
} }
}); });
@@ -359,7 +350,7 @@ class MessagesNotifier extends _$MessagesNotifier {
offset: 0, offset: 0,
take: _pageSize, take: _pageSize,
); );
state = AsyncValue.data(newMessages); if (ref.mounted) state = AsyncValue.data(newMessages);
return; return;
} }
@@ -417,7 +408,9 @@ class MessagesNotifier extends _$MessagesNotifier {
} finally { } finally {
talker.log('Finished message sync'); talker.log('Finished message sync');
// Always reset global syncing state, regardless of disposal // 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; _isSyncing = false;
} }
} }
@@ -507,18 +500,18 @@ class MessagesNotifier extends _$MessagesNotifier {
_hasMore = messages.length == _pageSize; _hasMore = messages.length == _pageSize;
state = AsyncValue.data(messages); if (ref.mounted) state = AsyncValue.data(messages);
} }
Future<void> loadMore() async { Future<void> loadMore() async {
if (!_hasMore || state is AsyncLoading) return; if (!_hasMore || state is AsyncLoading) return;
talker.log('Loading more messages'); talker.log('Loading more messages');
if (!_disposed) { if (!!ref.mounted) {
Future.microtask(() => ref.read(chatSyncingProvider.notifier).set(true)); Future.microtask(() => ref.read(chatSyncingProvider.notifier).set(true));
} }
try { try {
final currentMessages = state.value ?? []; final currentMessages = (ref.mounted ? state.value : null) ?? [];
final offset = currentMessages.length; final offset = currentMessages.length;
final newMessages = await listMessages(offset: offset, take: _pageSize); final newMessages = await listMessages(offset: offset, take: _pageSize);
@@ -527,9 +520,11 @@ class MessagesNotifier extends _$MessagesNotifier {
_hasMore = false; _hasMore = false;
} }
state = AsyncValue.data( if (ref.mounted) {
_sortMessages([...currentMessages, ...newMessages]), state = AsyncValue.data(
); _sortMessages([...currentMessages, ...newMessages]),
);
}
} catch (err, stackTrace) { } catch (err, stackTrace) {
talker.log( talker.log(
'Error loading more messages', 'Error loading more messages',
@@ -540,12 +535,14 @@ class MessagesNotifier extends _$MessagesNotifier {
showErrorAlert(err); showErrorAlert(err);
} finally { } finally {
// Always reset global syncing state, regardless of disposal // 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( Future<void> sendMessage(
WidgetRef ref, WidgetRef outerRef,
String content, String content,
List<UniversalFile> attachments, { List<UniversalFile> attachments, {
SnPoll? poll, SnPoll? poll,
@@ -578,14 +575,14 @@ class MessagesNotifier extends _$MessagesNotifier {
_fileUploadProgress[localMessage.id] = {}; _fileUploadProgress[localMessage.id] = {};
await _database.saveMessageWithSender(localMessage); await _database.saveMessageWithSender(localMessage);
final currentMessages = state.value ?? []; final currentMessages = (ref.mounted ? state.value : null) ?? [];
state = AsyncValue.data([localMessage, ...currentMessages]); state = AsyncValue.data([localMessage, ...currentMessages]);
try { try {
var cloudAttachments = List.empty(growable: true); var cloudAttachments = List.empty(growable: true);
for (var idx = 0; idx < attachments.length; idx++) { for (var idx = 0; idx < attachments.length; idx++) {
final cloudFile = await FileUploader.createCloudFile( final cloudFile = await FileUploader.createCloudFile(
ref: ref, ref: outerRef,
fileData: attachments[idx], fileData: attachments[idx],
onProgress: (progress, _) { onProgress: (progress, _) {
_fileUploadProgress[localMessage.id]?[idx] = progress ?? 0.0; _fileUploadProgress[localMessage.id]?[idx] = progress ?? 0.0;
@@ -628,24 +625,27 @@ class MessagesNotifier extends _$MessagesNotifier {
await _database.deleteMessage(localMessage.id); await _database.deleteMessage(localMessage.id);
await _database.saveMessageWithSender(updatedMessage); await _database.saveMessageWithSender(updatedMessage);
final currentMessages = state.value ?? []; if (ref.mounted) {
if (editingTo != null) { final currentMessages = state.value ?? [];
final newMessages = currentMessages if (editingTo != null) {
.where((m) => m.id != localMessage.id) // remove pending message final newMessages = currentMessages
.map( .where((m) => m.id != localMessage.id) // remove pending message
(m) => m.id == editingTo.id ? updatedMessage : m, .map(
) // update original message (m) => m.id == editingTo.id ? updatedMessage : m,
.toList(); ) // update original message
state = AsyncValue.data(newMessages); .toList();
} else { state = AsyncValue.data(newMessages);
final newMessages = currentMessages.map((m) { } else {
if (m.id == localMessage.id) { final newMessages = currentMessages.map((m) {
return updatedMessage; if (m.id == localMessage.id) {
} return updatedMessage;
return m; }
}).toList(); return m;
state = AsyncValue.data(newMessages); }).toList();
state = AsyncValue.data(newMessages);
}
} }
talker.log('Message with nonce $nonce sent successfully'); talker.log('Message with nonce $nonce sent successfully');
} catch (e, stackTrace) { } catch (e, stackTrace) {
talker.log( talker.log(
@@ -660,13 +660,15 @@ class MessagesNotifier extends _$MessagesNotifier {
localMessage.id, localMessage.id,
MessageStatus.failed, MessageStatus.failed,
); );
final newMessages = (state.value ?? []).map((m) { if (ref.mounted) {
if (m.id == localMessage.id) { final newMessages = (state.value ?? []).map((m) {
return m..status = MessageStatus.failed; if (m.id == localMessage.id) {
} return m..status = MessageStatus.failed;
return m; }
}).toList(); return m;
state = AsyncValue.data(newMessages); }).toList();
state = AsyncValue.data(newMessages);
}
showErrorAlert(e); showErrorAlert(e);
} }
} }
@@ -707,13 +709,15 @@ class MessagesNotifier extends _$MessagesNotifier {
await _database.deleteMessage(pendingMessageId); await _database.deleteMessage(pendingMessageId);
await _database.saveMessageWithSender(updatedMessage); await _database.saveMessageWithSender(updatedMessage);
final newMessages = (state.value ?? []).map((m) { if (ref.mounted) {
if (m.id == pendingMessageId) { final newMessages = (state.value ?? []).map((m) {
return updatedMessage; if (m.id == pendingMessageId) {
} return updatedMessage;
return m; }
}).toList(); return m;
state = AsyncValue.data(newMessages); }).toList();
state = AsyncValue.data(newMessages);
}
} catch (e, stackTrace) { } catch (e, stackTrace) {
talker.log( talker.log(
'Failed to retry message $pendingMessageId', 'Failed to retry message $pendingMessageId',
@@ -727,13 +731,15 @@ class MessagesNotifier extends _$MessagesNotifier {
pendingMessageId, pendingMessageId,
MessageStatus.failed, MessageStatus.failed,
); );
final newMessages = (state.value ?? []).map((m) { if (ref.mounted) {
if (m.id == pendingMessageId) { final newMessages = (state.value ?? []).map((m) {
return m..status = MessageStatus.failed; if (m.id == pendingMessageId) {
} return m..status = MessageStatus.failed;
return m; }
}).toList(); return m;
state = AsyncValue.data(_sortMessages(newMessages)); }).toList();
state = AsyncValue.data(_sortMessages(newMessages));
}
showErrorAlert(e); showErrorAlert(e);
} }
} }
@@ -765,21 +771,23 @@ class MessagesNotifier extends _$MessagesNotifier {
if (!isSilentMessage) { if (!isSilentMessage) {
await _database.saveMessageWithSender(localMessage); await _database.saveMessageWithSender(localMessage);
final currentMessages = state.value ?? []; final currentMessages = (ref.mounted ? state.value : null) ?? [];
final existingIndex = currentMessages.indexWhere( final existingIndex = currentMessages.indexWhere(
(m) => (m) =>
m.id == localMessage.id || m.id == localMessage.id ||
(localMessage.nonce != null && m.nonce == localMessage.nonce), (localMessage.nonce != null && m.nonce == localMessage.nonce),
); );
if (existingIndex >= 0) { if (ref.mounted) {
final newList = [...currentMessages]; if (existingIndex >= 0) {
newList[existingIndex] = localMessage; final newList = [...currentMessages];
state = AsyncValue.data(_sortMessages(newList)); newList[existingIndex] = localMessage;
} else { state = AsyncValue.data(_sortMessages(newList));
state = AsyncValue.data( } else {
_sortMessages([localMessage, ...currentMessages]), state = AsyncValue.data(
); _sortMessages([localMessage, ...currentMessages]),
);
}
} }
} }
@@ -846,13 +854,15 @@ class MessagesNotifier extends _$MessagesNotifier {
await _database.updateMessage(_database.messageToCompanion(updatedMessage)); 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); final index = currentMessages.indexWhere((m) => m.id == updatedMessage.id);
if (index >= 0) { if (ref.mounted) {
final newList = [...currentMessages]; if (index >= 0) {
newList[index] = updatedMessage; final newList = [...currentMessages];
state = AsyncValue.data(_sortMessages(newList)); newList[index] = updatedMessage;
state = AsyncValue.data(_sortMessages(newList));
}
} }
} }
@@ -866,7 +876,7 @@ class MessagesNotifier extends _$MessagesNotifier {
talker.log('Received message deletion $messageId'); talker.log('Received message deletion $messageId');
_pendingMessages.remove(messageId); _pendingMessages.remove(messageId);
final currentMessages = state.value ?? []; final currentMessages = (ref.mounted ? state.value : null) ?? [];
final messageIndex = currentMessages.indexWhere((m) => m.id == messageId); final messageIndex = currentMessages.indexWhere((m) => m.id == messageId);
LocalChatMessage? messageToUpdate; LocalChatMessage? messageToUpdate;
@@ -892,10 +902,12 @@ class MessagesNotifier extends _$MessagesNotifier {
await _database.saveMessageWithSender(deletedMessage); await _database.saveMessageWithSender(deletedMessage);
if (messageIndex != -1) { if (ref.mounted) {
final newList = [...currentMessages]; if (messageIndex != -1) {
newList[messageIndex] = deletedMessage; final newList = [...currentMessages];
state = AsyncValue.data(newList); newList[messageIndex] = deletedMessage;
state = AsyncValue.data(newList);
}
} }
} }
@@ -916,7 +928,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages.remove(messageId); _pendingMessages.remove(messageId);
await _database.deleteMessage(messageId); await _database.deleteMessage(messageId);
final currentMessages = state.value ?? []; final currentMessages = (ref.mounted ? state.value : null) ?? [];
final newMessages = currentMessages final newMessages = currentMessages
.where((m) => m.id != messageId) .where((m) => m.id != messageId)
.toList(); .toList();
@@ -1058,7 +1070,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_isJumping = true; _isJumping = true;
// Clear flashing messages when starting a new jump // Clear flashing messages when starting a new jump
if (!_disposed) { if (!!ref.mounted) {
ref.read(flashingMessagesProvider.notifier).state = {}; 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 // 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( final existingIndex = currentMessages.indexWhere(
(m) => m.id == messageId, (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 final class MessagesNotifierFamily extends $Family
with with

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/services/analytics_service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
@@ -256,8 +257,11 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
void setThemeMode(String value) { void setThemeMode(String value) {
final prefs = ref.read(sharedPreferencesProvider); final prefs = ref.read(sharedPreferencesProvider);
final oldValue = state.themeMode;
prefs.setString(kAppThemeMode, value); prefs.setString(kAppThemeMode, value);
state = state.copyWith(themeMode: value); state = state.copyWith(themeMode: value);
AnalyticsService().logThemeChanged(oldValue ?? 'system', value);
} }
void setAppTransparentBackground(double value) { void setAppTransparentBackground(double value) {

View File

@@ -65,7 +65,7 @@ final class AppSettingsNotifierProvider
} }
String _$appSettingsNotifierHash() => String _$appSettingsNotifierHash() =>
r'2437c621dcb1625a120ed1f21ab5c29906ba98be'; r'64cf6dfda90fc634336d96bd5313b2a07b19eccb';
abstract class _$AppSettingsNotifier extends $Notifier<AppSettings> { abstract class _$AppSettingsNotifier extends $Notifier<AppSettings> {
AppSettings build(); 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) { void _handleWebSocketPacket(dynamic packet) {
if (packet.type.startsWith('task.')) { if (packet.type.startsWith('task.') || packet.type == 'upload.completed') {
final data = packet.data; final data = packet.data;
if (data == null) return; if (data == null && packet.type != 'upload.completed') return;
// Debug logging // Debug logging
talker.info( talker.info(
'[UploadTasks] Received WebSocket packet: ${packet.type}, data: $data', '[UploadTasks] Received WebSocket packet: ${packet.type}, data: $data',
); );
final taskId = data['task_id'] as String?; final taskId = data != null ? (data['task_id'] as String?) : null;
if (taskId == null) return; if (taskId == null && packet.type != 'upload.completed') return;
switch (packet.type) { switch (packet.type) {
case 'task.created': case 'task.created':
_handleTaskCreated(taskId, data); _handleTaskCreated(taskId!, data);
break; break;
case 'task.progress': case 'task.progress':
_handleProgressUpdate(taskId, data); _handleProgressUpdate(taskId!, data);
break; break;
case 'task.completed': 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; break;
case 'task.failed': case 'task.failed':
_handleUploadFailed(taskId, data); _handleUploadFailed(taskId!, data);
break; break;
} }
} }
@@ -256,6 +285,21 @@ class UploadTasksNotifier extends Notifier<List<DriveTask>> {
}).toList(); }).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( void updateDownloadProgress(
String taskId, String taskId,
int downloadedBytes, int downloadedBytes,
@@ -470,6 +514,7 @@ class EnhancedFileUploader extends FileUploader {
// Step 2: Upload chunks // Step 2: Upload chunks
int bytesUploaded = 0; int bytesUploaded = 0;
int chunksUploaded = 0;
if (fileData is XFile) { if (fileData is XFile) {
// Use stream for XFile // Use stream for XFile
final subscription = fileData.openRead().listen(null); final subscription = fileData.openRead().listen(null);
@@ -494,6 +539,11 @@ class EnhancedFileUploader extends FileUploader {
}, },
); );
bytesUploaded += chunkData.length; bytesUploaded += chunkData.length;
chunksUploaded += 1;
// Update upload progress in UI
ref
.read(uploadTasksProvider.notifier)
.updateUploadProgress(taskId, bytesUploaded, chunksUploaded);
} }
subscription.cancel(); subscription.cancel();
} else if (fileData is Uint8List) { } else if (fileData is Uint8List) {
@@ -521,6 +571,11 @@ class EnhancedFileUploader extends FileUploader {
}, },
); );
bytesUploaded += chunks[i].length; bytesUploaded += chunks[i].length;
chunksUploaded += 1;
// Update upload progress in UI
ref
.read(uploadTasksProvider.notifier)
.updateUploadProgress(taskId, bytesUploaded, chunksUploaded);
} }
} else { } else {
throw ArgumentError('Invalid fileData type'); throw ArgumentError('Invalid fileData type');
@@ -530,4 +585,4 @@ class EnhancedFileUploader extends FileUploader {
onProgress?.call(null, Duration.zero); onProgress?.call(null, Duration.zero);
return await completeUpload(taskId); return await completeUpload(taskId);
} }
} }

View File

@@ -58,11 +58,18 @@ class ActivityListNotifier
// Find the latest createdAt timestamp from all items for cursor-based pagination // 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 // This ensures we get items created before this timestamp, regardless of sort order
if (items.isNotEmpty) { if (items.isNotEmpty) {
final latestCreatedAt = items final newestCreatedAt = items
.where((e) => e.type.startsWith('posts.')) .where((e) => e.type.startsWith('posts.'))
.map((e) => e.createdAt) .map((e) => e.createdAt)
.reduce((a, b) => a.isBefore(b) ? a : b); .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; return items;

View File

@@ -1,10 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io' show Platform;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/talker.dart'; import 'package:island/talker.dart';
import 'package:island/services/analytics_service.dart';
class UserInfoNotifier extends AsyncNotifier<SnAccount?> { class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
@override @override
@@ -31,9 +28,7 @@ class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
final response = await client.get('/pass/accounts/me'); final response = await client.get('/pass/accounts/me');
final user = SnAccount.fromJson(response.data); final user = SnAccount.fromJson(response.data);
if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) { AnalyticsService().setUserId(user.id);
FirebaseAnalytics.instance.setUserId(id: user.id);
}
return user; return user;
} catch (error, stackTrace) { } catch (error, stackTrace) {
if (error is DioException) { if (error is DioException) {
@@ -91,9 +86,8 @@ class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
final prefs = ref.read(sharedPreferencesProvider); final prefs = ref.read(sharedPreferencesProvider);
await prefs.remove(kTokenPairStoreKey); await prefs.remove(kTokenPairStoreKey);
ref.invalidate(tokenProvider); ref.invalidate(tokenProvider);
if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) { AnalyticsService().setUserId(null);
FirebaseAnalytics.instance.setUserId(id: null); AnalyticsService().logLogout();
}
} }
} }

View File

@@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/paging.dart'; import 'package:island/pods/paging.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
@@ -22,7 +23,7 @@ import 'package:island/pods/network.dart';
part 'relationship.g.dart'; part 'relationship.g.dart';
@riverpod @riverpod
Future<List<SnRelationship>> sentFriendRequest(Ref ref) async { Future<List<SnRelationship>> friendRequest(Ref ref) async {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
final resp = await client.get('/pass/relationships/requests'); final resp = await client.get('/pass/relationships/requests');
return resp.data return resp.data
@@ -31,11 +32,9 @@ Future<List<SnRelationship>> sentFriendRequest(Ref ref) async {
.toList(); .toList();
} }
final relationshipListNotifierProvider = final relationshipListNotifierProvider = AsyncNotifierProvider.autoDispose(
AsyncNotifierProvider.autoDispose< RelationshipListNotifier.new,
RelationshipListNotifier, );
PaginationState<SnRelationship>
>(RelationshipListNotifier.new);
class RelationshipListNotifier class RelationshipListNotifier
extends AsyncNotifier<PaginationState<SnRelationship>> extends AsyncNotifier<PaginationState<SnRelationship>>
@@ -84,6 +83,7 @@ class RelationshipListTile extends StatelessWidget {
final String? currentUserId; final String? currentUserId;
final bool showRelatedAccount; final bool showRelatedAccount;
final Function(SnRelationship, int)? onUpdateStatus; final Function(SnRelationship, int)? onUpdateStatus;
final Function(SnRelationship)? onDelete;
const RelationshipListTile({ const RelationshipListTile({
super.key, super.key,
@@ -96,18 +96,19 @@ class RelationshipListTile extends StatelessWidget {
required this.currentUserId, required this.currentUserId,
this.showRelatedAccount = false, this.showRelatedAccount = false,
this.onUpdateStatus, this.onUpdateStatus,
this.onDelete,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final account = showRelatedAccount final account = showRelatedAccount
? relationship.related ? relationship.related!
: relationship.account; : relationship.account!;
final isPending = final isPending =
relationship.status == 0 && relationship.relatedId == currentUserId; relationship.status == 0 && relationship.relatedId == currentUserId;
final isWaiting = final isWaiting =
relationship.status == 0 && relationship.accountId == currentUserId; relationship.status == 0 && relationship.accountId == currentUserId;
final isEstablished = relationship.status == 1 || relationship.status == 2; final isEstablished = relationship.status != 0;
return ListTile( return ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 12), contentPadding: const EdgeInsets.only(left: 16, right: 12),
@@ -185,21 +186,36 @@ class RelationshipListTile extends StatelessWidget {
itemBuilder: (context) => [ itemBuilder: (context) => [
if (relationship.status >= 100) // If friend if (relationship.status >= 100) // If friend
PopupMenuItem( PopupMenuItem(
child: ListTile(
leading: const Icon(Symbols.block),
title: Text('blockUser').tr(),
contentPadding: EdgeInsets.zero,
),
onTap: () => onUpdateStatus?.call(relationship, -100), 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 else if (relationship.status <= -100) // If blocked
PopupMenuItem( PopupMenuItem(
child: ListTile(
leading: const Icon(Symbols.person_add),
title: Text('unblockUser').tr(),
contentPadding: EdgeInsets.zero,
),
onTap: () => onUpdateStatus?.call(relationship, 100), 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); final client = ref.read(apiClientProvider);
await client.post('/pass/relationships/${result.id}/friends'); await client.post('/pass/relationships/${result.id}/friends');
ref.invalidate(sentFriendRequestProvider); ref.invalidate(friendRequestProvider);
} }
final submitting = useState(false); 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( Future<void> updateRelationship(
SnRelationship relationship, SnRelationship relationship,
int newStatus, int newStatus,
@@ -276,8 +263,32 @@ class RelationshipScreen extends HookConsumerWidget {
relationshipNotifier.refresh(); 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 user = ref.watch(userInfoProvider);
final requests = ref.watch(sentFriendRequestProvider); final requests = ref.watch(friendRequestProvider);
return AppScaffold( return AppScaffold(
appBar: AppBar(title: Text('relationships').tr()), appBar: AppBar(title: Text('relationships').tr()),
@@ -293,9 +304,9 @@ class RelationshipScreen extends HookConsumerWidget {
if (requests.hasValue && requests.value!.isNotEmpty) if (requests.hasValue && requests.value!.isNotEmpty)
ListTile( ListTile(
leading: const Icon(Symbols.send), leading: const Icon(Symbols.send),
title: Text('friendSentRequest').tr(), title: Text('friendRequests').tr(),
subtitle: Text( subtitle: Text(
'friendSentRequestHint'.plural(requests.value!.length), 'friendRequestsHint'.plural(requests.value!.length),
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () { onTap: () {
@@ -316,11 +327,10 @@ class RelationshipScreen extends HookConsumerWidget {
return RelationshipListTile( return RelationshipListTile(
relationship: relationship, relationship: relationship,
submitting: submitting.value, submitting: submitting.value,
onAccept: () => handleFriendRequest(relationship, true),
onDecline: () => handleFriendRequest(relationship, false),
currentUserId: user.value?.id, currentUserId: user.value?.id,
showRelatedAccount: false, showRelatedAccount: true,
onUpdateStatus: updateRelationship, onUpdateStatus: updateRelationship,
onDelete: deleteRelationship,
); );
}, },
), ),
@@ -336,19 +346,54 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final requests = ref.watch(sentFriendRequestProvider); final requests = ref.watch(friendRequestProvider);
final user = ref.watch(userInfoProvider); final user = ref.watch(userInfoProvider);
Future<void> cancelRequest(SnRelationship request) async { Future<void> cancelRequest(SnRelationship request) async {
try { try {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
await client.delete('/pass/relationships/${request.relatedId}/friends'); await client.delete('/pass/relationships/${request.relatedId}/friends');
ref.invalidate(sentFriendRequestProvider); ref.invalidate(friendRequestProvider);
} catch (err) { } catch (err) {
showErrorAlert(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( return Container(
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8, maxHeight: MediaQuery.of(context).size.height * 0.8,
@@ -366,7 +411,7 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
child: Row( child: Row(
children: [ children: [
Text( Text(
'friendSentRequest'.tr(), 'friendRequests'.tr(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.5, letterSpacing: -0.5,
@@ -377,7 +422,7 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
icon: const Icon(Symbols.refresh), icon: const Icon(Symbols.refresh),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)), style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
onPressed: () { onPressed: () {
ref.invalidate(sentFriendRequestProvider); ref.invalidate(friendRequestProvider);
}, },
), ),
IconButton( IconButton(
@@ -394,7 +439,7 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
data: (items) => items.isEmpty data: (items) => items.isEmpty
? Center( ? Center(
child: Text( child: Text(
'friendSentRequestEmpty'.tr(), 'friendRequestsEmpty'.tr(),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
) )
@@ -406,6 +451,8 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
return RelationshipListTile( return RelationshipListTile(
relationship: request, relationship: request,
onCancel: () => cancelRequest(request), onCancel: () => cancelRequest(request),
onAccept: () => handleFriendRequest(request, true),
onDecline: () => handleFriendRequest(request, false),
currentUserId: user.value?.id, currentUserId: user.value?.id,
showRelatedAccount: true, showRelatedAccount: true,
); );

View File

@@ -9,10 +9,10 @@ part of 'relationship.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning // ignore_for_file: type=lint, type=warning
@ProviderFor(sentFriendRequest) @ProviderFor(friendRequest)
final sentFriendRequestProvider = SentFriendRequestProvider._(); final friendRequestProvider = FriendRequestProvider._();
final class SentFriendRequestProvider final class FriendRequestProvider
extends extends
$FunctionalProvider< $FunctionalProvider<
AsyncValue<List<SnRelationship>>, AsyncValue<List<SnRelationship>>,
@@ -22,19 +22,19 @@ final class SentFriendRequestProvider
with with
$FutureModifier<List<SnRelationship>>, $FutureModifier<List<SnRelationship>>,
$FutureProvider<List<SnRelationship>> { $FutureProvider<List<SnRelationship>> {
SentFriendRequestProvider._() FriendRequestProvider._()
: super( : super(
from: null, from: null,
argument: null, argument: null,
retry: null, retry: null,
name: r'sentFriendRequestProvider', name: r'friendRequestProvider',
isAutoDispose: true, isAutoDispose: true,
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
); );
@override @override
String debugGetCreateSourceHash() => _$sentFriendRequestHash(); String debugGetCreateSourceHash() => _$friendRequestHash();
@$internal @$internal
@override @override
@@ -44,8 +44,8 @@ final class SentFriendRequestProvider
@override @override
FutureOr<List<SnRelationship>> create(Ref ref) { 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/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/database/message.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/widgets/chat/message_item.dart'; import 'package:island/widgets/chat/message_item.dart';
// Provider to track animated messages to prevent replay // Provider to track animated messages to prevent replay
final animatedMessagesProvider = final animatedMessagesProvider = NotifierProvider.autoDispose(
NotifierProvider<AnimatedMessagesNotifier, Set<String>>( AnimatedMessagesNotifier.new,
AnimatedMessagesNotifier.new, );
);
class AnimatedMessagesNotifier extends Notifier<Set<String>> { class AnimatedMessagesNotifier extends Notifier<Set<String>> {
@override @override
@@ -22,7 +22,7 @@ class AnimatedMessagesNotifier extends Notifier<Set<String>> {
} }
} }
class MessageItemWrapper extends ConsumerWidget { class MessageItemWrapper extends HookConsumerWidget {
final LocalChatMessage message; final LocalChatMessage message;
final int index; final int index;
final bool isLastInGroup; final bool isLastInGroup;
@@ -54,51 +54,6 @@ class MessageItemWrapper extends ConsumerWidget {
required this.roomOpenTime, 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) { Widget _buildContent(BuildContext context, SnChatMember? identity) {
final isSelected = selectedMessages.contains(message.id); final isSelected = selectedMessages.contains(message.id);
final isCurrentUser = identity?.id == message.senderId; final isCurrentUser = identity?.id == message.senderId;
@@ -116,12 +71,9 @@ class MessageItemWrapper extends ConsumerWidget {
} }
}, },
child: Container( child: Container(
color: color: isSelected
isSelected ? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3)
? Theme.of( : null,
context,
).colorScheme.primaryContainer.withOpacity(0.3)
: null,
child: Stack( child: Stack(
children: [ children: [
MessageItem( MessageItem(
@@ -130,10 +82,9 @@ class MessageItemWrapper extends ConsumerWidget {
key: ValueKey('item-${message.id}'), key: ValueKey('item-${message.id}'),
message: message, message: message,
isCurrentUser: isCurrentUser, isCurrentUser: isCurrentUser,
onAction: onAction: isSelectionMode
isSelectionMode ? null
? null : (action) => onMessageAction(action, message),
: (action) => onMessageAction(action, message),
onJump: onJump, onJump: onJump,
progress: attachmentProgress[message.id], progress: attachmentProgress[message.id],
showAvatar: isLastInGroup, showAvatar: isLastInGroup,
@@ -178,4 +129,88 @@ class MessageItemWrapper extends ConsumerWidget {
onJump: (_) {}, 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

@@ -46,4 +46,4 @@ final class SubscribedFeedsProvider
} }
} }
String _$subscribedFeedsHash() => r'5c0c8c30c5f543f6ea1d39786a6778f77ba5b3df'; String _$subscribedFeedsHash() => r'91d6f909a3d2c9f68028550223f7c7b2128727d2';

View File

@@ -64,7 +64,7 @@ final class MarketplaceWebFeedProvider
} }
String _$marketplaceWebFeedHash() => String _$marketplaceWebFeedHash() =>
r'8383f94f1bc272b903c341b8d95000313b69d14c'; r'36f3235ba346b0d416ce5e66dca8d6cecbafb608';
final class MarketplaceWebFeedFamily extends $Family final class MarketplaceWebFeedFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnWebFeed>, String> { with $FunctionalFamilyOverride<FutureOr<SnWebFeed>, String> {
@@ -141,7 +141,7 @@ final class MarketplaceWebFeedSubscriptionProvider
} }
String _$marketplaceWebFeedSubscriptionHash() => String _$marketplaceWebFeedSubscriptionHash() =>
r'2ff06a48ed7d4236b57412ecca55e94c0a0b6330'; r'6efa43b4d7e2ab62a721a67e035038dcf63be524';
/// Provider for web feed subscription status /// Provider for web feed subscription status

View File

@@ -317,7 +317,7 @@ class ExploreScreen extends HookConsumerWidget {
// Sliver list cannot provide refresh handled by the pagination list // Sliver list cannot provide refresh handled by the pagination list
isRefreshable: false, isRefreshable: false,
isSliver: true, isSliver: true,
footerSkeletonChild: const PostItemSkeleton(), footerSkeletonChild: const PostItemSkeleton(maxWidth: double.infinity),
contentBuilder: (data, footer) => contentBuilder: (data, footer) =>
_ActivityListView(data: data, isWide: isWide, footer: 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.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/file_download.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/file_info_sheet.dart'; import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:island/widgets/content/file_viewer_contents.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 { class FileDetailScreen extends HookConsumerWidget {
final SnCloudFile item; final SnCloudFile item;
@@ -165,22 +160,6 @@ class FileDetailScreen extends HookConsumerWidget {
break; 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 // Always add info button
actions.add( actions.add(
IconButton(icon: Icon(Icons.info_outline), onPressed: showInfoSheet), 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, isCompact: true,
item: attachments[idx], item: attachments[idx],
progress: progressMap[idx], progress: progressMap[idx],
isUploading: progressMap.containsKey(idx),
onRequestUpload: () async { onRequestUpload: () async {
final config = final config =
await showModalBottomSheet< await showModalBottomSheet<

View File

@@ -9,8 +9,10 @@ import 'package:island/models/post.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/compose.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_file_collection.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/widgets/post/post_award_sheet.dart'; import 'package:island/widgets/post/post_award_sheet.dart';
import 'package:island/widgets/post/post_item.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/post_quick_reply.dart';
import 'package:island/widgets/post/compose_sheet.dart'; import 'package:island/widgets/post/compose_sheet.dart';
import 'package:island/widgets/post/post_replies.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/widgets/response.dart';
import 'package:island/utils/share_utils.dart'; import 'package:island/utils/share_utils.dart';
import 'package:island/widgets/safety/abuse_report_helper.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 { class PostActionButtons extends HookConsumerWidget {
final SnPost post; final SnPost post;
final EdgeInsets renderingPadding; final EdgeInsets renderingPadding;
@@ -435,6 +442,88 @@ 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) {
return Row(
children: [
Expanded(
flex: 3,
child: Material(
color: Theme.of(context).cardTheme.color,
elevation: 8,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: CloudFileList(
files: post.attachments,
disableConstraint: true,
padding: EdgeInsets.zero,
),
),
),
),
),
Expanded(
flex: 2,
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),
],
),
),
],
);
}
}
class PostDetailScreen extends HookConsumerWidget { class PostDetailScreen extends HookConsumerWidget {
final String id; final String id;
const PostDetailScreen({super.key, required this.id}); const PostDetailScreen({super.key, required this.id});
@@ -452,6 +541,8 @@ class PostDetailScreen extends HookConsumerWidget {
), ),
body: postState.when( body: postState.when(
data: (post) { data: (post) {
final isMediaPostLayout = isWideScreen(context) && _isMediaPost(post);
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -460,64 +551,78 @@ class PostDetailScreen extends HookConsumerWidget {
ref.invalidate(postProvider(id)); ref.invalidate(postProvider(id));
ref.read(postRepliesProvider(id).notifier).refresh(); ref.read(postRepliesProvider(id).notifier).refresh();
}, },
child: CustomScrollView( child: isMediaPostLayout
physics: const AlwaysScrollableScrollPhysics(), ? _PostDetailLargeScreenLayout(
slivers: [ post: post!,
SliverToBoxAdapter( ref: ref,
child: Center( postId: id,
child: ConstrainedBox( onUpdate: (newItem) {
constraints: BoxConstraints(maxWidth: 800), ref
child: PostItem( .read(postStateProvider(id).notifier)
item: post!, .updatePost(newItem);
isFullPost: true, },
isEmbedReply: false, onRefresh: () {
textScale: post.type == 1 ? 1.2 : 1.1, ref.invalidate(postProvider(id));
onUpdate: (newItem) { ref.read(postRepliesProvider(id).notifier).refresh();
// Update the local state with the new post data },
ref )
.read(postStateProvider(id).notifier) : CustomScrollView(
.updatePost(newItem); physics: const AlwaysScrollableScrollPhysics(),
}, slivers: [
), SliverToBoxAdapter(
), child: Center(
), child: ConstrainedBox(
), constraints: BoxConstraints(maxWidth: 800),
SliverToBoxAdapter( child: PostItem(
child: Center( item: post!,
child: ConstrainedBox( isFullPost: true,
constraints: BoxConstraints(maxWidth: 600), isEmbedReply: false,
child: PostActionButtons( textScale: post.type == 1 ? 1.2 : 1.1,
post: post, onUpdate: (newItem) {
renderingPadding: const EdgeInsets.symmetric( ref
horizontal: 8, .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: 600),
SliverGap(MediaQuery.of(context).padding.bottom + 80),
],
),
), ),
if (user.value != null) if (user.value != null && !isMediaPostLayout)
Positioned( Positioned(
bottom: 16 + MediaQuery.of(context).padding.bottom, bottom: 16 + MediaQuery.of(context).padding.bottom,
left: 16, left: 16,
right: 16, right: 16,
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 660), constraints: BoxConstraints(maxWidth: 800),
child: postState.when( child: postState.when(
data: (post) => PostQuickReply( data: (post) => PostQuickReply(
parent: post!, parent: post!,

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/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/post/post_list.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/activity_heatmap.dart';
import 'package:island/widgets/posts/post_filter.dart'; import 'package:island/widgets/posts/post_filter.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -32,6 +33,113 @@ import 'package:styled_widget/styled_widget.dart';
part 'publisher_profile.g.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 { class _PublisherBasisWidget extends StatelessWidget {
final SnPublisher data; final SnPublisher data;
final AsyncValue<SnPublisherSubscription?> subStatus; final AsyncValue<SnPublisherSubscription?> subStatus;
@@ -431,7 +539,6 @@ class PublisherProfileScreen extends HookConsumerWidget {
final queryState = useState(PostListQuery(pubName: name)); final queryState = useState(PostListQuery(pubName: name));
final subscribing = useState(false); final subscribing = useState(false);
final isPinnedExpanded = useState(true);
useEffect(() { useEffect(() {
final index = switch (queryState.value.type) { final index = switch (queryState.value.type) {
@@ -510,36 +617,8 @@ class PublisherProfileScreen extends HookConsumerWidget {
slivers: [ slivers: [
SliverGap(16), SliverGap(16),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Card( child: _PinnedPostsPageView(pubName: name),
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,
),
),
), ),
...[
if (isPinnedExpanded.value)
SliverPostList(
query: PostListQuery(pubName: name, pinned: true),
queryKey: 'publisher-$name-pinned',
),
],
SliverToBoxAdapter( SliverToBoxAdapter(
child: PostFilterWidget( child: PostFilterWidget(
categoryTabController: categoryTabController, categoryTabController: categoryTabController,
@@ -644,27 +723,8 @@ class PublisherProfileScreen extends HookConsumerWidget {
).padding(vertical: 4), ).padding(vertical: 4),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Card( child: _PinnedPostsPageView(pubName: name),
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,
),
),
), ),
...[
if (isPinnedExpanded.value)
SliverPostList(
query: PostListQuery(pubName: name, pinned: true),
queryKey: 'publisher-$name-pinned',
),
],
SliverToBoxAdapter( SliverToBoxAdapter(
child: PostFilterWidget( child: PostFilterWidget(
categoryTabController: categoryTabController, 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/account_pfc.dart';
import 'package:island/widgets/account/status.dart'; import 'package:island/widgets/account/status.dart';
import 'package:island/widgets/post/post_list.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:island/services/color_extraction.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
@@ -27,6 +28,113 @@ import 'package:island/pods/paging.dart';
import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/paging/pagination_list.dart';
import 'package:styled_widget/styled_widget.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 final realmAppbarForegroundColorProvider = FutureProvider.autoDispose
.family<Color?, String>((ref, realmSlug) async { .family<Color?, String>((ref, realmSlug) async {
final realm = await ref.watch(realmProvider(realmSlug).future); final realm = await ref.watch(realmProvider(realmSlug).future);
@@ -235,8 +343,8 @@ class RealmDetailScreen extends HookConsumerWidget {
flex: 3, flex: 3,
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverPostList( SliverToBoxAdapter(
query: PostListQuery(realm: slug, pinned: true), child: _RealmPinnedPostsPageView(realmSlug: slug),
), ),
SliverPostList( SliverPostList(
query: PostListQuery(realm: slug, pinned: false), query: PostListQuery(realm: slug, pinned: false),
@@ -338,8 +446,8 @@ class RealmDetailScreen extends HookConsumerWidget {
), ),
), ),
SliverToBoxAdapter(child: realmChatRoomListWidget(realm)), SliverToBoxAdapter(child: realmChatRoomListWidget(realm)),
SliverPostList( SliverToBoxAdapter(
query: PostListQuery(realm: slug, pinned: true), child: _RealmPinnedPostsPageView(realmSlug: slug),
), ),
SliverPostList( SliverPostList(
query: PostListQuery(realm: slug, pinned: false), query: PostListQuery(realm: slug, pinned: false),

View File

@@ -167,7 +167,9 @@ class _PostsSearchTab extends HookConsumerWidget {
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
), ),
child: const PostItemSkeleton(), child: const PostItemSkeleton(
maxWidth: double.infinity,
),
), ),
itemBuilder: (context, index, post) { itemBuilder: (context, index, post) {
return Card( return Card(
@@ -320,7 +322,7 @@ class _PostsSearchTab extends HookConsumerWidget {
isRefreshable: false, isRefreshable: false,
footerSkeletonChild: Padding( footerSkeletonChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: const PostItemSkeleton(), child: const PostItemSkeleton(maxWidth: double.infinity),
), ),
itemBuilder: (context, index, post) { itemBuilder: (context, index, post) {
return Center( 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:async';
import 'dart:io'; import 'dart:io';
import 'package:just_audio/just_audio.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -104,6 +105,13 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
if (settings.notifyWithHaptic) { if (settings.notifyWithHaptic) {
HapticFeedback.heavyImpact(); 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( showTopSnackBar(
globalOverlay.currentState!, globalOverlay.currentState!,
Center( Center(
@@ -220,4 +228,4 @@ Future<void> _putTokenToRemote(
"/ring/notifications/subscription", "/ring/notifications/subscription",
data: {"provider": provider, "device_token": token}, data: {"provider": provider, "device_token": token},
); );
} }

View File

@@ -1,8 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:just_audio/just_audio.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:island/main.dart'; import 'package:island/main.dart';
@@ -31,9 +33,7 @@ void _onAppLifecycleChanged(AppLifecycleState state) {
Future<void> initializeLocalNotifications() async { Future<void> initializeLocalNotifications() async {
// Initialize Windows notification for Windows platform // Initialize Windows notification for Windows platform
windowsNotification = winty.WindowsNotification( windowsNotification = winty.WindowsNotification(applicationId: "Solian");
applicationId: "Solian",
);
WidgetsBinding.instance.addObserver( WidgetsBinding.instance.addObserver(
LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged), LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged),
@@ -55,6 +55,7 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
) { ) {
final settings = ref.watch(appSettingsProvider);
final ws = ref.watch(websocketProvider); final ws = ref.watch(websocketProvider);
return ws.dataStream.listen((pkt) async { return ws.dataStream.listen((pkt) async {
if (pkt.type == "notifications.new") { if (pkt.type == "notifications.new") {
@@ -64,6 +65,16 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
talker.info( talker.info(
'[Notification] Showing in-app notification: ${notification.title}', '[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( showTopSnackBar(
globalOverlay.currentState!, globalOverlay.currentState!,
Center( Center(
@@ -139,7 +150,10 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
final notificationMessage = NotificationMessage.fromPluginTemplate( final notificationMessage = NotificationMessage.fromPluginTemplate(
notification.id, // unique id notification.id, // unique id
notification.title, 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, group: notification.topic,
image: imagePath, image: imagePath,
largeImage: largeImagePath, largeImage: largeImagePath,
@@ -207,4 +221,4 @@ Future<void> _putTokenToRemote(
"/ring/notifications/subscription", "/ring/notifications/subscription",
data: {"provider": provider, "device_token": token}, 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

@@ -0,0 +1,24 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class WidgetSyncService {
static const _channel = MethodChannel('dev.solsynth.solian/widget');
static final _instance = WidgetSyncService._internal();
factory WidgetSyncService() => _instance;
WidgetSyncService._internal();
bool get _isSupported => !kIsWeb && (Platform.isAndroid || Platform.isIOS);
Future<void> syncToWidget() async {
if (!_isSupported) return;
try {
await _channel.invokeMethod('syncToWidget');
} catch (e) {
debugPrint('Failed to sync to widget: $e');
}
}
}

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:path_provider/path_provider.dart' show getTemporaryDirectory;
import 'package:screenshot/screenshot.dart'; import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:island/services/analytics_service.dart';
/// Shares a post as a screenshot image /// Shares a post as a screenshot image
Future<void> sharePostAsScreenshot( Future<void> sharePostAsScreenshot(
@@ -62,5 +63,9 @@ Future<void> sharePostAsScreenshot(
.catchError((err) { .catchError((err) {
if (context.mounted) hideLoadingModal(context); if (context.mounted) hideLoadingModal(context);
showErrorAlert(err); 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final account = ref.watch(accountProvider(uname)); final account = ref.watch(accountProvider(uname));
final width = final width = math
math.min(MediaQuery.of(context).size.width - 80, 360).toDouble(); .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( return PopupCard(
elevation: 8, elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
child: SizedBox( child: AnimatedSize(
width: width, duration: const Duration(milliseconds: 200),
child: account.when( curve: Curves.easeInOut,
data: child: SizedBox(
(data) => Column( width: width,
mainAxisSize: MainAxisSize.min, child: AnimatedSwitcher(
crossAxisAlignment: CrossAxisAlignment.stretch, duration: const Duration(milliseconds: 200),
children: [ transitionBuilder: (Widget child, Animation<double> animation) {
if (data.profile.background != null) return FadeTransition(opacity: animation, child: child);
ClipRRect( },
borderRadius: const BorderRadius.vertical( child: child,
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(),
),
), ),
), ),
); );

View File

@@ -259,24 +259,26 @@ void showErrorAlert(dynamic err, {IconData? icon}) {
title: null, title: null,
titlePadding: EdgeInsets.zero, titlePadding: EdgeInsets.zero,
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
content: Column( content: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Icon( children: [
icon ?? Icons.error_outline_rounded, Icon(
size: 48, icon ?? Icons.error_outline_rounded,
color: Theme.of(context).colorScheme.error, size: 48,
), color: Theme.of(context).colorScheme.error,
const Gap(16), ),
Text( const Gap(16),
'somethingWentWrong'.tr(), Text(
style: Theme.of(context).textTheme.titleLarge, 'somethingWentWrong'.tr(),
), style: Theme.of(context).textTheme.titleLarge,
const Gap(8), ),
Text(text), const Gap(8),
const Gap(8), SelectableText(text),
], const Gap(8),
],
),
), ),
actions: [ actions: [
TextButton( TextButton(

View File

@@ -17,7 +17,7 @@ import 'package:island/services/event_bus.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/cmp/pattle.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:material_symbols_icons/material_symbols_icons.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shake/shake.dart'; import 'package:shake/shake.dart';
@@ -146,16 +146,19 @@ class WindowScaffold extends HookConsumerWidget {
}, },
); );
ShakeDetector detector = ShakeDetector.autoStart( ShakeDetector? detactor;
onPhoneShake: (_) { if (!kIsWeb && (Platform.isIOS && Platform.isAndroid)) {
showPalette.value = true; detactor = ShakeDetector.autoStart(
}, onPhoneShake: (_) {
); showPalette.value = true;
},
);
}
return () { return () {
hotKeyManager.unregister(popHotKey); hotKeyManager.unregister(popHotKey);
hotKeyManager.unregister(cmpHotKey); hotKeyManager.unregister(cmpHotKey);
detector.stopListening(); detactor?.stopListening();
}; };
}, []); }, []);
@@ -255,7 +258,7 @@ class WindowScaffold extends HookConsumerWidget {
], ],
), ),
_WebSocketIndicator(), _WebSocketIndicator(),
const UploadOverlay(), const TaskOverlay(),
if (showPalette.value) if (showPalette.value)
CommandPattleWidget(onDismiss: () => showPalette.value = false), CommandPattleWidget(onDismiss: () => showPalette.value = false),
], ],
@@ -268,7 +271,7 @@ class WindowScaffold extends HookConsumerWidget {
children: [ children: [
Positioned.fill(child: child), Positioned.fill(child: child),
_WebSocketIndicator(), _WebSocketIndicator(),
const UploadOverlay(), const TaskOverlay(),
if (showPalette.value) if (showPalette.value)
CommandPattleWidget(onDismiss: () => showPalette.value = false), CommandPattleWidget(onDismiss: () => showPalette.value = false),
], ],
@@ -357,44 +360,34 @@ class AppScaffold extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final focusNode = useFocusNode();
useEffect(() {
focusNode.requestFocus();
return null;
}, []);
final appBarHeight = appBar?.preferredSize.height ?? 0; final appBarHeight = appBar?.preferredSize.height ?? 0;
final safeTop = MediaQuery.of(context).padding.top; final safeTop = MediaQuery.of(context).padding.top;
final noBackground = isNoBackground ?? isWideScreen(context); final noBackground = isNoBackground ?? isWideScreen(context);
final builtWidget = Focus( final builtWidget = Scaffold(
focusNode: focusNode, extendBody: extendBody ?? true,
child: Scaffold( extendBodyBehindAppBar: true,
extendBody: extendBody ?? true, backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true, body: Column(
backgroundColor: Colors.transparent, children: [
body: Column( IgnorePointer(
children: [ child: SizedBox(
IgnorePointer( height: appBar != null ? appBarHeight + safeTop : 0,
child: SizedBox(
height: appBar != null ? appBarHeight + safeTop : 0,
),
), ),
if (body != null) Expanded(child: body!), ),
], if (body != null) Expanded(child: body!),
), ],
appBar: appBar,
bottomNavigationBar: bottomNavigationBar,
bottomSheet: bottomSheet,
drawer: drawer,
endDrawer: endDrawer,
floatingActionButton: floatingActionButton,
floatingActionButtonAnimator: floatingActionButtonAnimator,
onDrawerChanged: onDrawerChanged,
onEndDrawerChanged: onEndDrawerChanged,
), ),
appBar: appBar,
bottomNavigationBar: bottomNavigationBar,
bottomSheet: bottomSheet,
drawer: drawer,
endDrawer: endDrawer,
floatingActionButton: floatingActionButton,
floatingActionButtonAnimator: floatingActionButtonAnimator,
onDrawerChanged: onDrawerChanged,
onEndDrawerChanged: onEndDrawerChanged,
); );
return noBackground return noBackground
@@ -639,4 +632,4 @@ class _WebSocketIndicator extends HookConsumerWidget {
), ),
); );
} }
} }

View File

@@ -269,7 +269,17 @@ class AppWrapper extends HookConsumerWidget {
return; return;
} }
if (path == '/notifications') {
eventBus.fire(ShowNotificationSheetEvent());
return;
}
final router = ref.read(routerProvider); final router = ref.read(routerProvider);
if (path == '/dashboard') {
router.go('/');
return;
}
if (uri.queryParameters.isNotEmpty) { if (uri.queryParameters.isNotEmpty) {
path = Uri.parse( path = Uri.parse(
path, path,

View File

@@ -458,6 +458,10 @@ class ChatInput extends HookConsumerWidget {
item: attachments[idx], item: attachments[idx],
progress: progress:
attachmentProgress['chat-upload']?[idx], attachmentProgress['chat-upload']?[idx],
isUploading:
attachmentProgress['chat-upload']
?.containsKey(idx) ??
false,
onRequestUpload: () => onUploadAttachment(idx), onRequestUpload: () => onUploadAttachment(idx),
onDelete: () => onDeleteAttachment(idx), onDelete: () => onDeleteAttachment(idx),
onUpdate: (value) { 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),
],
),
),
),
),
),
],
);
}
}

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