Compare commits

...

72 Commits

Author SHA1 Message Date
ac4fa5eb85 🚀 Launch 3.3.0+144 2025-11-02 23:57:31 +08:00
8857718709 🐛 Fix compose toolbar safe area issue 2025-11-02 23:56:48 +08:00
dd17b2b9c1 Scroll gradiant to think as well 2025-11-02 23:55:00 +08:00
848439f664 Chat room scroll gradiant 2025-11-02 23:52:03 +08:00
f83117424d 🐛 Fix tag subscribe used wrong icon 2025-11-02 23:44:11 +08:00
8c19c32c76 Publisher profile collapsible pinned post 2025-11-02 23:36:42 +08:00
d62b2bed80 💄 Optimize publisher page filter select date 2025-11-02 23:34:08 +08:00
5a23eb1768 Stronger filter 2025-11-02 23:30:16 +08:00
5f6e4763d3 🐛 Fix app notification 2025-11-02 23:12:11 +08:00
580c36fb89 🐛 Fix mis placed safe area 2025-11-02 22:45:28 +08:00
6c25af3b30 Show publisher mentioned chip as well 2025-11-02 22:44:09 +08:00
a1da72d447 Show profile picture in mention chip 2025-11-02 22:41:50 +08:00
ab4120cc22 💄 Optimize cloud file list 2025-11-02 22:34:32 +08:00
52eff0fa25 🐛 Fix the NSE again... 2025-11-02 22:14:31 +08:00
beeb28abf2 💄 Optimize in-app notification style 2025-11-02 21:55:42 +08:00
c0ab3837ac 👽 Make poll load itself to match server updates 2025-11-02 21:47:37 +08:00
59d38c0d8d 💄 Refined developer hub 2025-11-02 21:19:58 +08:00
bd2247ce86 ♻️ Refactor the app management to use sheet 2025-11-02 21:12:55 +08:00
da2d3f7f17 ♻️ Make bot management into sheet 2025-11-02 21:04:35 +08:00
7497b77384 💄 Adjusted developer hub 2025-11-02 17:45:03 +08:00
f542d9fa97 🐛 Fix timezone error 2025-11-02 17:24:18 +08:00
e70439870e ♻️ Add event bus to more places 2025-11-02 17:13:10 +08:00
d764b042fe Shows account own activities on account page 2025-11-02 16:59:58 +08:00
a76b97d1d2 💄 Shows listening activities are from spotfiy 2025-11-02 16:55:16 +08:00
cfbe6e580b 👔 Add rpc prefix for activities generated from activity server 2025-11-02 16:50:31 +08:00
f08b9e057f Special display for spotify activity 2025-11-02 16:49:39 +08:00
0509f37c96 ♻️ Use system browser for OIDC 2025-11-02 16:32:29 +08:00
a7dc9ac6fa Add spotify in account connection 2025-11-02 15:49:44 +08:00
caf2f5f1f6 💄 Optimize the link embed 2025-11-02 15:43:40 +08:00
12b79af3a2 🐛 Fix bugs 2025-11-02 02:21:15 +08:00
88f149584e ♻️ Removed the post compose screen completely 2025-11-02 01:43:04 +08:00
877001b802 💄 Optimize publisher profile again 2025-11-02 01:36:14 +08:00
fec28f6223 💄 Optimize publisher page 2025-11-02 01:30:47 +08:00
85005ff9c3 💄 Optimize profile page 2025-11-02 01:20:14 +08:00
e3c92a3c55 💄 Optimize profile page styling 2025-11-02 01:05:40 +08:00
9e9fbc5d6a 💄 Optimize settings buttons 2025-11-02 01:04:10 +08:00
8d1d836b52 💄 Optimize the account page 2025-11-02 00:51:16 +08:00
bc60ce5d42 💄 Optimize the pfc and show the activities 2025-11-02 00:25:08 +08:00
c093123e3a Shows images, url from presense 2025-11-02 00:03:16 +08:00
3de73538c7 🐛 Activity refined 2025-11-01 23:36:05 +08:00
ba8d5cee09 Refined presense activity 2025-11-01 21:47:34 +08:00
5ee2e70442 New activity presence 2025-11-01 20:16:54 +08:00
53a3a32907 🚀 Launch 3.3.0+143 (SNAPSHOT) (HOTFIX) 2025-11-01 15:59:16 +08:00
9a628779d9 🚚 Rename watchOS project to proper one 2025-11-01 12:21:37 +08:00
b60bd63d0c 🐛 Made watchOS URLSession wait for connectivty 2025-11-01 12:19:56 +08:00
01cc71fd47 🐛 Fix watch connectivty didn't work on real devices 2025-11-01 02:38:53 +08:00
a2b0cd0b6a 🐛 Fix some production issue for watchOS Solian 2025-10-31 23:09:08 +08:00
7f971bcee3 🔨 Fix stupid xcode's fault cause iOS failed to build after adding watchOS 2025-10-31 22:32:15 +08:00
7de98a1731 🐛 Fix post refresh 2025-10-31 19:18:34 +08:00
b52eb95b14 🐛 Fix compose sheet 2025-10-31 19:15:22 +08:00
b3ef7d6ad0 🐛 Fix fab menu wrong type 2025-10-31 19:09:24 +08:00
d28c11940d 🐛 Bug fixes 2025-10-31 19:02:53 +08:00
504322c2dd 🍱 Update app icons for watchOS 2025-10-31 01:31:34 +08:00
a07ec3ca36 ⬆️ Upgrade deps 2025-10-31 01:02:16 +08:00
d96691e920 🔀 Merge pull request '添加 Solian for Apple Watch' (#8) from features/watchos-app into v3
Reviewed-on: #8
2025-10-30 16:58:52 +00:00
6273b2d917 💄 Auto hide input on watchOS 2025-10-31 00:56:51 +08:00
ab90d244b5 Able to send message on watchOS 2025-10-31 00:39:06 +08:00
dc6af6d9e5 Render attachments of message on watchOS 2025-10-31 00:20:38 +08:00
0ca801d963 Live updates of chat messages with websocket on watchOS 2025-10-31 00:11:24 +08:00
3edcdd72af 🐛 Fixed stupid app state updated twice 2025-10-30 23:58:05 +08:00
402bb3fe04 Make a broke websocket on watchOS (w.i.p) 2025-10-30 22:37:41 +08:00
8ba55eb1be App info header on watchOS 2025-10-30 21:20:41 +08:00
983ae2a1fc Render messages on watchOS 2025-10-30 02:15:51 +08:00
6fc94001b3 Message loading on watchOS 2025-10-30 02:04:10 +08:00
44dbcfdc94 Chat room listing 2025-10-30 01:28:36 +08:00
b57caf56db Able to clear status on watchOS
🐛 Fix some bugs in status on watchOS
2025-10-30 01:15:42 +08:00
dbcd1b6d36 watchOS able to set status 2025-10-30 01:03:19 +08:00
a8055de910 💄 Optimize account view on watchOS 2025-10-30 00:28:56 +08:00
49b15e7674 🐛 Fix compile issue on watchOS 2025-10-30 00:26:46 +08:00
e2369c40db watchOS Account profile page 2025-10-30 00:26:32 +08:00
44c5d91620 💄 Better watchOS video attachment display 2025-10-30 00:03:24 +08:00
7a5a2407b7 💄 Optimized post item row 2025-10-30 00:03:03 +08:00
167 changed files with 9329 additions and 3880 deletions

View File

@@ -62,3 +62,9 @@ If you want to build the release version, use the flutter build command. Learn m
```bash
flutter build <platform>
```
### Known Issues
Due to the issues with the flutter build tools, [see](https://github.com/flutter/flutter/issues/160622).
Since there is a watchOS app for iOS, you're unable to use the flutter cli to run iOS app. Use xcode instead.

View File

@@ -162,6 +162,7 @@
"accountConnectionProviderGithub": "GitHub",
"accountConnectionProviderDiscord": "Discord",
"accountConnectionProviderAfdian": "Afdian",
"accountConnectionProviderSpotify": "Spotify",
"checkIn": "Check In",
"checkInNone": "Not checked-in yet",
"checkInNoneHint": "Get your fortune tips and daily rewards by checking in.",
@@ -469,6 +470,7 @@
"pronouns": "Pronouns",
"location": "Location",
"timeZone": "Time Zone",
"timezoneNotFound": "Time zone not found",
"birthday": "Birthday",
"selectADate": "Select a date",
"checkInResultT0": "Worst",
@@ -1302,5 +1304,20 @@
"aiThought": "AI Thought",
"aiThoughtTitle": "Let sn-chan think",
"postReferenceUnavailable": "Referenced post is unavailable",
"fabLocation": "FAB Location"
"fabLocation": "FAB Location",
"activities": "Activities",
"presenceTypeGaming": "Playing",
"presenceTypeMusic": "Listening to Music",
"presenceTypeWorkout": "Working out",
"articleCompose": "Compose Article",
"backToHub": "Back to Hub",
"advancedFilters": "Advanced Filters",
"searchPosts": "Search Posts",
"sortBy": "Sort by",
"fromDate": "From Date",
"toDate": "To Date",
"popularity": "Popularity",
"descendingOrder": "Descending Order",
"selectDate": "Select Date",
"pinnedPosts": "Pinned Posts"
}

View File

@@ -158,11 +158,11 @@
"checkIn": "签到",
"checkInNone": "尚未签到",
"checkInNoneHint": "通过签到获取您的财富提示和每日奖励。",
"checkInResultLevel0": "最差运气",
"checkInResultLevel1": "坏运气",
"checkInResultLevel2": "一个普通的日常",
"checkInResultLevel3": "好运",
"checkInResultLevel4": "最佳运气",
"checkInResultLevel0": "大凶",
"checkInResultLevel1": "",
"checkInResultLevel2": "中平",
"checkInResultLevel3": "",
"checkInResultLevel4": "大吉",
"checkInActivityTitle": "{} 在 {} 签到并获得了 {}",
"eventCalander": "活动日历",
"eventCalanderEmpty": "该日无活动。",
@@ -344,7 +344,7 @@
"accountSettingsHelpContent": "此页面允许您管理您的帐户安全性、隐私和其他设置。如果您需要帮助,请联系管理员。",
"unauthorized": "未授权",
"unauthorizedHint": "您未登录或会话已过期,请重新登录。",
"publisherBelongsTo": "属于",
"publisherBelongsTo": "属于 {}",
"postContent": "内容",
"postSettings": "设置",
"postPublisherUnselected": "未指定发布者",

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,3 +1,5 @@
platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -25,12 +27,12 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
flutter_ios_podfile_setup
target 'Runner' do
platform :ios, '15.0'
use_frameworks!
use_modular_headers!
pod 'Alamofire'
pod 'Kingfisher', '~> 8.0'
pod 'KingfisherWebP'
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
@@ -40,8 +42,6 @@ target 'Runner' do
target 'SolianNotificationService' do
inherit! :search_paths
pod 'Kingfisher', '~> 8.0'
pod 'Alamofire'
end
target 'SolianShareExtension' do
@@ -49,7 +49,7 @@ target 'Runner' do
end
end
target 'WatchRunner Watch App' do
target 'Solian Watch App' do
platform :watchos, '11.0'
use_frameworks!

View File

@@ -218,7 +218,7 @@ PODS:
- Flutter
- irondash_engine_context (0.0.1):
- Flutter
- Kingfisher (8.6.0)
- Kingfisher (8.6.1)
- KingfisherWebP (1.7.2):
- Kingfisher (~> 8.0)
- libwebp (>= 1.1.0)
@@ -537,7 +537,7 @@ SPEC CHECKSUMS:
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
Kingfisher: 64278f126a815d0e2d391cdf71311b85882c4de0
Kingfisher: 7ac7a7288653787a54206b11a3c74f49ab650f1f
KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
livekit_client: 86c8af579274e4b7a215185a8080db2d4e176f40
@@ -571,6 +571,6 @@ SPEC CHECKSUMS:
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
PODFILE CHECKSUM: 3096dc559be56aca856e757e1dc65ca039801e2e
PODFILE CHECKSUM: 585198f58dca90ac6492607c83a8d17045ab3852
COCOAPODS: 1.16.2

View File

@@ -3,14 +3,15 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
7310A7DF2EB10963002C0FD3 /* WatchRunner Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5D8143680678FCD1D1827271 /* Pods_Solian_Watch_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C9C046CF867AE03DC170F861 /* Pods_Solian_Watch_App.framework */; };
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 */; };
73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -21,7 +22,6 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
A1D34487886D362AC8B99B2E /* Pods_WatchRunner_Watch_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 802C1CFCA7F1E069AAEFB454 /* Pods_WatchRunner_Watch_App.framework */; };
B87C0E607033790E71B54D73 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F6D834CA86410B09796B312B /* Pods_Runner.framework */; };
D174D53FF3E8EA943491A5CC /* Pods_SolianShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */; };
D1772CE196985AE8E8C9F2E5 /* Pods_SolianNotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */; };
@@ -62,11 +62,11 @@
/* Begin PBXCopyFilesBuildPhase section */
7310A7DE2EB10963002C0FD3 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
buildActionMask = 12;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
7310A7DF2EB10963002C0FD3 /* WatchRunner Watch App.app in Embed Watch Content */,
7310A7DF2EB10963002C0FD3 /* Solian Watch App.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
@@ -97,6 +97,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0ECC3D56D018DD87FC342699 /* Pods-Solian Watch App.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Solian Watch App.profile.xcconfig"; path = "Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App.profile.xcconfig"; sourceTree = "<group>"; };
103EA2362B9E9F127016A1F1 /* Pods-WatchRunner Watch App.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WatchRunner Watch App.profile.xcconfig"; path = "Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App.profile.xcconfig"; sourceTree = "<group>"; };
14118AC858B441AB16B7309E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
@@ -105,16 +106,18 @@
17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-SolianShareExtension/Pods-SolianShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
192FDACE67D7CB6AED15C634 /* Pods-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.debug.xcconfig"; sourceTree = "<group>"; };
1C14F71D23E4371602065522 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
2440CEDEAAD6D51FDA95FA62 /* Pods-Solian Watch App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Solian Watch App.release.xcconfig"; path = "Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App.release.xcconfig"; sourceTree = "<group>"; };
252A83CE6862573BB856ED8E /* Pods-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.release.xcconfig"; sourceTree = "<group>"; };
27C66EFB5A705F1A822C3EB0 /* Pods-SolianShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianShareExtension.release.xcconfig"; path = "Target Support Files/Pods-SolianShareExtension/Pods-SolianShareExtension.release.xcconfig"; sourceTree = "<group>"; };
29812C17FFBE7DBBC7203981 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
2D2457F8B2E6EF9C0F935035 /* Pods-NotificationService.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.profile.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.profile.xcconfig"; sourceTree = "<group>"; };
31EA49B10397BD4145AD765E /* Pods-Solian Watch App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Solian Watch App.debug.xcconfig"; path = "Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App.debug.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; 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>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "WatchRunner 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>"; };
73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianBroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; };
@@ -126,7 +129,6 @@
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolianShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
802C1CFCA7F1E069AAEFB454 /* Pods_WatchRunner_Watch_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WatchRunner_Watch_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
86D60BA96DA647E1B11AA7F0 /* Pods-WatchRunner Watch App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WatchRunner Watch App.debug.xcconfig"; path = "Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App.debug.xcconfig"; sourceTree = "<group>"; };
8B40620B1EEBB09456406A3C /* Pods-SolianNotificationService.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianNotificationService.profile.xcconfig"; path = "Target Support Files/Pods-SolianNotificationService/Pods-SolianNotificationService.profile.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -142,6 +144,7 @@
A85FF612AE7623A9934E57CE /* Pods-SolianShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-SolianShareExtension/Pods-SolianShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
AA0CA8A3E15DEE023BB27438 /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
B93771F2A63E4148DC6142F7 /* Pods-SolianNotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianNotificationService.release.xcconfig"; path = "Target Support Files/Pods-SolianNotificationService/Pods-SolianNotificationService.release.xcconfig"; sourceTree = "<group>"; };
C9C046CF867AE03DC170F861 /* Pods_Solian_Watch_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Solian_Watch_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E6B10A9A85BECA2E576C91FF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
F6D834CA86410B09796B312B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F830F535CB92E3F2E1653A11 /* Pods-SolianNotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SolianNotificationService.debug.xcconfig"; path = "Target Support Files/Pods-SolianNotificationService/Pods-SolianNotificationService.debug.xcconfig"; sourceTree = "<group>"; };
@@ -180,11 +183,11 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */ = {
7310A7D52EB10962002C0FD3 /* Solian Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = "WatchRunner Watch App";
path = "Solian Watch App";
sourceTree = "<group>";
};
73268D272DEB012A0076E970 /* Services */ = {
@@ -234,7 +237,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A1D34487886D362AC8B99B2E /* Pods_WatchRunner_Watch_App.framework in Frameworks */,
5D8143680678FCD1D1827271 /* Pods_Solian_Watch_App.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -291,7 +294,7 @@
7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */,
73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */,
73ACDFB82E3D0E6100B63535 /* UIKit.framework */,
802C1CFCA7F1E069AAEFB454 /* Pods_WatchRunner_Watch_App.framework */,
C9C046CF867AE03DC170F861 /* Pods_Solian_Watch_App.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -317,6 +320,9 @@
86D60BA96DA647E1B11AA7F0 /* Pods-WatchRunner Watch App.debug.xcconfig */,
A2EB1DAFDE9B8E6D88BBF7A3 /* Pods-WatchRunner Watch App.release.xcconfig */,
103EA2362B9E9F127016A1F1 /* Pods-WatchRunner Watch App.profile.xcconfig */,
31EA49B10397BD4145AD765E /* Pods-Solian Watch App.debug.xcconfig */,
2440CEDEAAD6D51FDA95FA62 /* Pods-Solian Watch App.release.xcconfig */,
0ECC3D56D018DD87FC342699 /* Pods-Solian Watch App.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -340,7 +346,7 @@
73CDD67B2DEC00480059D95D /* SolianNotificationService */,
73C305CF2E0BE878009035B9 /* SolianShareExtension */,
73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */,
7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */,
7310A7D52EB10962002C0FD3 /* Solian Watch App */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
91E124CE95BCB4DCD890160D /* Pods */,
@@ -357,7 +363,7 @@
73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */,
73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */,
73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */,
7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */,
7310A7D42EB10962002C0FD3 /* Solian Watch App.app */,
);
name = Products;
sourceTree = "<group>";
@@ -402,26 +408,26 @@
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
7310A7D32EB10962002C0FD3 /* WatchRunner Watch App */ = {
7310A7D32EB10962002C0FD3 /* Solian Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "WatchRunner Watch App" */;
buildConfigurationList = 7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "Solian Watch App" */;
buildPhases = (
DDEDA1BA6278B94F0F7B9B61 /* [CP] Check Pods Manifest.lock */,
7310A7D02EB10962002C0FD3 /* Sources */,
7310A7D12EB10962002C0FD3 /* Frameworks */,
7310A7D22EB10962002C0FD3 /* Resources */,
C74B07D6587D29C67A198025 /* [CP] Embed Pods Frameworks */,
E29ECA5954168075BDB000DC /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */,
7310A7D52EB10962002C0FD3 /* Solian Watch App */,
);
name = "WatchRunner Watch App";
name = "Solian Watch App";
productName = "WatchRunner Watch App";
productReference = 7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */;
productReference = 7310A7D42EB10962002C0FD3 /* Solian Watch App.app */;
productType = "com.apple.product-type.application";
};
73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */ = {
@@ -569,7 +575,7 @@
73CDD6792DEC00480059D95D /* SolianNotificationService */,
73C305CD2E0BE878009035B9 /* SolianShareExtension */,
73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */,
7310A7D32EB10962002C0FD3 /* WatchRunner Watch App */,
7310A7D32EB10962002C0FD3 /* Solian Watch App */,
);
};
/* End PBXProject section */
@@ -756,23 +762,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
C74B07D6587D29C67A198025 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
DDEDA1BA6278B94F0F7B9B61 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -788,13 +777,30 @@
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-WatchRunner Watch App-checkManifestLockResult.txt",
"$(DERIVED_FILE_DIR)/Pods-Solian Watch App-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
E29ECA5954168075BDB000DC /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
E86CDE9D6464F4F52B910856 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -992,6 +998,7 @@
CUSTOM_GROUP_ID = group.solsynth.solian;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
EXCLUDED_SOURCE_FILE_NAMES = "";
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
@@ -1002,10 +1009,12 @@
);
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
WATCHOS_DEPLOYMENT_TARGET = 11.6;
};
name = Profile;
};
@@ -1013,6 +1022,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 14DFD79BE7C26E51B117583C /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -1021,6 +1031,8 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SUPPORTS_MACCATALYST = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -1032,6 +1044,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 14118AC858B441AB16B7309E /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -1040,6 +1053,8 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SUPPORTS_MACCATALYST = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
@@ -1049,6 +1064,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = E6B10A9A85BECA2E576C91FF /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -1057,6 +1073,8 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SUPPORTS_MACCATALYST = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
@@ -1064,7 +1082,7 @@
};
7310A7E02EB10963002C0FD3 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 86D60BA96DA647E1B11AA7F0 /* Pods-WatchRunner Watch App.debug.xcconfig */;
baseConfigurationReference = 31EA49B10397BD4145AD765E /* Pods-Solian Watch App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -1082,9 +1100,12 @@
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = WatchRunner;
INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian;
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1106,13 +1127,13 @@
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
WATCHOS_DEPLOYMENT_TARGET = 11.6;
};
name = Debug;
};
7310A7E12EB10963002C0FD3 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = A2EB1DAFDE9B8E6D88BBF7A3 /* Pods-WatchRunner Watch App.release.xcconfig */;
baseConfigurationReference = 2440CEDEAAD6D51FDA95FA62 /* Pods-Solian Watch App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -1130,9 +1151,12 @@
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = WatchRunner;
INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian;
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1145,19 +1169,20 @@
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "watchsimulator watchos";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
WATCHOS_DEPLOYMENT_TARGET = 11.6;
};
name = Release;
};
7310A7E22EB10963002C0FD3 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 103EA2362B9E9F127016A1F1 /* Pods-WatchRunner Watch App.profile.xcconfig */;
baseConfigurationReference = 0ECC3D56D018DD87FC342699 /* Pods-Solian Watch App.profile.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -1175,9 +1200,12 @@
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = WatchRunner;
INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian;
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1190,13 +1218,14 @@
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "watchsimulator watchos";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
WATCHOS_DEPLOYMENT_TARGET = 11.6;
};
name = Profile;
};
@@ -1233,6 +1262,7 @@
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -1273,6 +1303,7 @@
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -1311,6 +1342,7 @@
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianBroadcastExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -1352,6 +1384,7 @@
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
@@ -1395,6 +1428,7 @@
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
SWIFT_VERSION = 5.0;
@@ -1436,6 +1470,7 @@
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.SolianShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
SWIFT_VERSION = 5.0;
@@ -1685,6 +1720,7 @@
CUSTOM_GROUP_ID = group.solsynth.solian;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
EXCLUDED_SOURCE_FILE_NAMES = "";
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
@@ -1700,6 +1736,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
WATCHOS_DEPLOYMENT_TARGET = 11.6;
};
name = Debug;
};
@@ -1714,6 +1751,7 @@
CUSTOM_GROUP_ID = group.solsynth.solian;
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
EXCLUDED_SOURCE_FILE_NAMES = "";
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
@@ -1722,12 +1760,15 @@
"$(inherited)",
"@executable_path/Frameworks",
);
ONLY_ACTIVE_ARCH = NO;
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphonesimulator iphoneos";
SWIFT_ENABLE_EXPLICIT_MODULES = "$(SWIFT_USE_INTEGRATED_DRIVER)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
WATCHOS_DEPLOYMENT_TARGET = 11.6;
};
name = Release;
};
@@ -1744,7 +1785,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "WatchRunner Watch App" */ = {
7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "Solian Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7310A7E02EB10963002C0FD3 /* Debug */,

View File

@@ -20,6 +20,20 @@
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

View File

@@ -5,14 +5,14 @@ import WatchConnectivity
@main
@objc class AppDelegate: FlutterAppDelegate {
let notifyDelegate = NotifyDelegate()
private var watchConnectivityService: WatchConnectivityService?
private static var sharedWatchConnectivityService: WatchConnectivityService?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = notifyDelegate
let replyableMessageCategory = UNNotificationCategory(
identifier: "CHAT_MESSAGE",
actions: [
@@ -25,38 +25,45 @@ import WatchConnectivity
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory])
GeneratedPluginRegistrant.register(with: self)
// Always initialize and retain a strong reference
if WCSession.isSupported() {
watchConnectivityService = WatchConnectivityService()
AppDelegate.sharedWatchConnectivityService = WatchConnectivityService.shared
} else {
print("[iOS] WCSession not supported on this device.")
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
class WatchConnectivityService: NSObject, WCSessionDelegate {
private let session: WCSession
override init() {
self.session = .default
final class WatchConnectivityService: NSObject, WCSessionDelegate {
static let shared = WatchConnectivityService()
private let session: WCSession = .default
private override init() {
super.init()
print("[iOS] Activating WCSession")
self.session.delegate = self
self.session.activate()
print("[iOS] Activating WCSession...")
session.delegate = self
session.activate()
}
// MARK: - WCSessionDelegate
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("[iOS] WCSession activation failed with error: \(error.localizedDescription)")
return
print("[iOS] WCSession activation failed: \(error.localizedDescription)")
} else {
print("[iOS] WCSession activated with state: \(activationState.rawValue)")
if activationState == .activated {
sendDataToWatch()
}
}
print("[iOS] WCSession activated with state: \(activationState.rawValue)")
}
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
@@ -69,10 +76,7 @@ class WatchConnectivityService: NSObject, WCSessionDelegate {
let token = UserDefaults.standard.getFlutterToken()
let serverUrl = UserDefaults.standard.getServerUrl()
print("[iOS] Retrieved token: \(token ?? "nil")")
print("[iOS] Retrieved serverUrl: \(serverUrl)")
var data: [String: Any] = ["serverUrl": serverUrl]
var data: [String: Any] = ["serverUrl": serverUrl ?? ""]
if let token = token {
data["token"] = token
}
@@ -81,4 +85,25 @@ class WatchConnectivityService: NSObject, WCSessionDelegate {
replyHandler(data)
}
}
func sendDataToWatch() {
guard session.activationState == .activated else {
return
}
let token = UserDefaults.standard.getFlutterToken()
let serverUrl = UserDefaults.standard.getServerUrl()
var data: [String: Any] = ["serverUrl": serverUrl ?? ""]
if let token = token {
data["token"] = token
}
do {
try session.updateApplicationContext(data)
print("[iOS] Sent application context: \(data)")
} catch {
print("[iOS] Failed to send application context: \(error.localizedDescription)")
}
}
}

View File

@@ -1 +1,334 @@
{"images":[{"size":"20x20","idiom":"universal","filename":"Icon-App-20x20@2x.png","scale":"2x","platform":"ios"},{"size":"20x20","idiom":"universal","filename":"Icon-App-20x20@3x.png","scale":"3x","platform":"ios"},{"size":"29x29","idiom":"universal","filename":"Icon-App-29x29@2x.png","scale":"2x","platform":"ios"},{"size":"29x29","idiom":"universal","filename":"Icon-App-29x29@3x.png","scale":"3x","platform":"ios"},{"size":"38x38","idiom":"universal","filename":"Icon-App-38x38@2x.png","scale":"2x","platform":"ios"},{"size":"38x38","idiom":"universal","filename":"Icon-App-38x38@3x.png","scale":"3x","platform":"ios"},{"size":"40x40","idiom":"universal","filename":"Icon-App-40x40@2x.png","scale":"2x","platform":"ios"},{"size":"40x40","idiom":"universal","filename":"Icon-App-40x40@3x.png","scale":"3x","platform":"ios"},{"size":"60x60","idiom":"universal","filename":"Icon-App-60x60@2x.png","scale":"2x","platform":"ios"},{"size":"60x60","idiom":"universal","filename":"Icon-App-60x60@3x.png","scale":"3x","platform":"ios"},{"size":"64x64","idiom":"universal","filename":"Icon-App-64x64@2x.png","scale":"2x","platform":"ios"},{"size":"64x64","idiom":"universal","filename":"Icon-App-64x64@3x.png","scale":"3x","platform":"ios"},{"size":"68x68","idiom":"universal","filename":"Icon-App-68x68@2x.png","scale":"2x","platform":"ios"},{"size":"76x76","idiom":"universal","filename":"Icon-App-76x76@2x.png","scale":"2x","platform":"ios"},{"size":"83.5x83.5","idiom":"universal","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x","platform":"ios"},{"size":"1024x1024","idiom":"universal","filename":"Icon-App-1024x1024@1x.png","scale":"1x","platform":"ios"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"},{"size":"20x20","idiom":"universal","filename":"Icon-App-Dark-20x20@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"20x20","idiom":"universal","filename":"Icon-App-Dark-20x20@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"29x29","idiom":"universal","filename":"Icon-App-Dark-29x29@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"29x29","idiom":"universal","filename":"Icon-App-Dark-29x29@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"38x38","idiom":"universal","filename":"Icon-App-Dark-38x38@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"38x38","idiom":"universal","filename":"Icon-App-Dark-38x38@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"40x40","idiom":"universal","filename":"Icon-App-Dark-40x40@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"40x40","idiom":"universal","filename":"Icon-App-Dark-40x40@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"60x60","idiom":"universal","filename":"Icon-App-Dark-60x60@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"60x60","idiom":"universal","filename":"Icon-App-Dark-60x60@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"64x64","idiom":"universal","filename":"Icon-App-Dark-64x64@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"64x64","idiom":"universal","filename":"Icon-App-Dark-64x64@3x.png","scale":"3x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"68x68","idiom":"universal","filename":"Icon-App-Dark-68x68@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"76x76","idiom":"universal","filename":"Icon-App-Dark-76x76@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"83.5x83.5","idiom":"universal","filename":"Icon-App-Dark-83.5x83.5@2x.png","scale":"2x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]},{"size":"1024x1024","idiom":"universal","filename":"Icon-App-Dark-1024x1024@1x.png","scale":"1x","platform":"ios","appearances":[{"appearance":"luminosity","value":"dark"}]}],"info":{"version":1,"author":"xcode"}}
{
"images" : [
{
"filename" : "Icon-App-20x20@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "Icon-App-20x20@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "Icon-App-29x29@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "Icon-App-29x29@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "Icon-App-38x38@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "38x38"
},
{
"filename" : "Icon-App-38x38@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "38x38"
},
{
"filename" : "Icon-App-40x40@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "Icon-App-40x40@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "Icon-App-60x60@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "Icon-App-60x60@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "Icon-App-64x64@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "64x64"
},
{
"filename" : "Icon-App-64x64@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "64x64"
},
{
"filename" : "Icon-App-68x68@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "68x68"
},
{
"filename" : "Icon-App-76x76@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "Icon-App-83.5x83.5@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "Icon-App-1024x1024@1x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "1x",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-20x20@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "20x20"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-20x20@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "20x20"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-29x29@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "29x29"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-29x29@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "29x29"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-38x38@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "38x38"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-38x38@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "38x38"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-40x40@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "40x40"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-40x40@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "40x40"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-60x60@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "60x60"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-60x60@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "60x60"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-64x64@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "64x64"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-64x64@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "64x64"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-68x68@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "68x68"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-76x76@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "76x76"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-83.5x83.5@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "Icon-App-Dark-1024x1024@1x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "1x",
"size" : "1024x1024"
},
{
"filename" : "Icon-App-1024x1024@1x.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

View File

@@ -95,6 +95,8 @@
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>

View File

@@ -0,0 +1,318 @@
{
"images" : [
{
"filename" : "icon-ios-20x20@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon-ios-20x20@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "icon-ios-29x29@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon-ios-29x29@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "icon-ios-38x38@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "38x38"
},
{
"filename" : "icon-ios-38x38@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "38x38"
},
{
"filename" : "icon-ios-40x40@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon-ios-40x40@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "icon-ios-60x60@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon-ios-60x60@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "icon-ios-64x64@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "64x64"
},
{
"filename" : "icon-ios-64x64@3x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "3x",
"size" : "64x64"
},
{
"filename" : "icon-ios-68x68@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "68x68"
},
{
"filename" : "icon-ios-76x76@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "icon-ios-83.5x83.5@2x.png",
"idiom" : "universal",
"platform" : "ios",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "icon-ios-1024x1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"filename" : "icon-mac-16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon-mac-16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon-mac-32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon-mac-32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon-mac-128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon-mac-128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon-mac-256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon-mac-256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon-mac-512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon-mac-512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
},
{
"filename" : "icon-watchos-22x22@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "22x22"
},
{
"filename" : "icon-watchos-24x24@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "24x24"
},
{
"filename" : "icon-watchos-27.5x27.5@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "27.5x27.5"
},
{
"filename" : "icon-watchos-29x29@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon-watchos-30x30@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "30x30"
},
{
"filename" : "icon-watchos-32x32@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon-watchos-33x33@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "33x33"
},
{
"filename" : "icon-watchos-40x40@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon-watchos-43.5x43.5@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "43.5x43.5"
},
{
"filename" : "icon-watchos-44x44@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "44x44"
},
{
"filename" : "icon-watchos-46x46@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "46x46"
},
{
"filename" : "icon-watchos-50x50@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "50x50"
},
{
"filename" : "icon-watchos-51x51@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "51x51"
},
{
"filename" : "icon-watchos-54x54@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "54x54"
},
{
"filename" : "icon-watchos-86x86@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "86x86"
},
{
"filename" : "icon-watchos-98x98@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "98x98"
},
{
"filename" : "icon-watchos-108x108@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "108x108"
},
{
"filename" : "icon-watchos-117x117@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "117x117"
},
{
"filename" : "icon-watchos-129x129@2x.png",
"idiom" : "universal",
"platform" : "watchos",
"scale" : "2x",
"size" : "129x129"
},
{
"filename" : "icon-watchos-1024x1024.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -3,8 +3,15 @@
{
"filename" : "icon.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,50 @@
//
// ContentView.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/28.
//
import SwiftUI
// The root view of the app.
struct ContentView: View {
@StateObject private var appState = AppState()
@State private var selection: Panel? = .explore
enum Panel: Hashable {
case explore
case chat
case notifications
case account
}
var body: some View {
NavigationSplitView {
List(selection: $selection) {
AppInfoHeaderView()
.listRowBackground(Color.clear)
.environmentObject(appState)
Label("Explore", systemImage: "globe.fill").tag(Panel.explore)
Label("Chat", systemImage: "message.fill").tag(Panel.chat)
Label("Notifications", systemImage: "bell.fill").tag(Panel.notifications)
Label("Account", systemImage: "person.circle.fill").tag(Panel.account)
}
.listStyle(.automatic)
} detail: {
switch selection {
case .explore:
ExploreView().environmentObject(appState)
case .chat:
ChatView().environmentObject(appState)
case .notifications:
NotificationView().environmentObject(appState)
case .account:
AccountView().environmentObject(appState)
case .none:
Text("Select a panel")
}
}
}
}

View File

@@ -1,4 +1,3 @@
//
// Models.swift
// WatchRunner Watch App
//
@@ -88,7 +87,7 @@ enum DiscoveryItemData: Codable {
}
self = .unknown
}
func encode(to encoder: Encoder) throws {
// Not needed for decoding
}
@@ -213,3 +212,154 @@ struct ActivityResponse {
let hasMore: Bool
let nextCursor: String?
}
struct SnAccount: Codable {
let id: String
let name: String
let nick: String
let profile: SnUserProfile
let createdAt: Date
}
struct SnUserProfile: Codable {
let bio: String?
let picture: SnCloudFile?
let background: SnCloudFile?
let level: Int
let experience: Int
let levelingProgress: Double
}
struct SnAccountStatus: Codable {
let id: String
let attitude: Int
let isOnline: Bool
let isInvisible: Bool
let isNotDisturb: Bool
let isCustomized: Bool
let label: String
let meta: [String: AnyCodable]?
let clearedAt: Date?
let accountId: String
let createdAt: Date
let updatedAt: Date
let deletedAt: Date?
}
// MARK: - Chat Models
struct SnChatRoom: Codable, Identifiable {
let id: String
let name: String?
let description: String?
let type: Int
let isPublic: Bool
let isCommunity: Bool
let picture: SnCloudFile?
let background: SnCloudFile?
let realmId: String?
let realm: SnRealm?
let createdAt: Date
let updatedAt: Date
let deletedAt: Date?
let members: [SnChatMember]?
}
struct SnChatMessage: Codable, Identifiable {
let id: String
let type: String
let content: String?
let nonce: String?
let meta: [String: AnyCodable]
let membersMentioned: [String]?
let editedAt: Date?
let attachments: [SnCloudFile]
let reactions: [SnChatReaction]
let repliedMessageId: String?
let forwardedMessageId: String?
let senderId: String
let sender: SnChatMember
let chatRoomId: String
let createdAt: Date
let updatedAt: Date
let deletedAt: Date?
enum CodingKeys: String, CodingKey {
case id, type, content, nonce, meta, membersMentioned, editedAt, attachments, reactions, repliedMessageId, forwardedMessageId, senderId, sender, chatRoomId, createdAt, updatedAt, deletedAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
type = try container.decode(String.self, forKey: .type)
content = try container.decodeIfPresent(String.self, forKey: .content)
nonce = try container.decodeIfPresent(String.self, forKey: .nonce)
meta = try container.decode([String: AnyCodable].self, forKey: .meta)
membersMentioned = try container.decodeIfPresent([String].self, forKey: .membersMentioned) ?? []
editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
attachments = try container.decode([SnCloudFile].self, forKey: .attachments)
reactions = try container.decode([SnChatReaction].self, forKey: .reactions)
repliedMessageId = try container.decodeIfPresent(String.self, forKey: .repliedMessageId)
forwardedMessageId = try container.decodeIfPresent(String.self, forKey: .forwardedMessageId)
senderId = try container.decode(String.self, forKey: .senderId)
sender = try container.decode(SnChatMember.self, forKey: .sender)
chatRoomId = try container.decode(String.self, forKey: .chatRoomId)
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
deletedAt = try container.decodeIfPresent(Date.self, forKey: .deletedAt)
}
}
struct SnChatReaction: Codable, Identifiable {
let id: String
let messageId: String
let senderId: String
let sender: SnChatMember
let symbol: String
let attitude: Int
let createdAt: Date
let updatedAt: Date
let deletedAt: Date?
}
struct SnChatMember: Codable, Identifiable {
let id: String
let chatRoomId: String
let chatRoom: SnChatRoom?
let accountId: String
let account: SnAccount
let nick: String?
let role: Int
let notify: Int
let joinedAt: Date?
let breakUntil: Date?
let timeoutUntil: Date?
let isBot: Bool
let status: SnAccountStatus?
let createdAt: Date
let updatedAt: Date
let deletedAt: Date?
}
struct SnChatSummary: Codable {
let unreadCount: Int
let lastMessage: SnChatMessage?
}
struct ChatRoomsResponse {
let rooms: [SnChatRoom]
}
struct ChatInvitesResponse {
let invites: [SnChatMember]
}
struct MessageSyncResponse: Codable {
let messages: [SnChatMessage]
let currentTimestamp: Date
enum CodingKeys: String, CodingKey {
case messages
case currentTimestamp = "current_timestamp"
}
}

View File

@@ -58,9 +58,8 @@ class ImageLoader: ObservableObject {
switch result {
case .success(let value):
self.image = Image(uiImage: value.image)
print("[watchOS] Image loaded successfully from \(value.cacheType == .none ? "network" : "cache (\(value.cacheType))").")
self.isLoading = false
case .failure(let error):
case .failure(_):
// If WebP processor fails (likely due to format), try with default processor
let defaultProcessor = DefaultImageProcessor.default
self.currentTask = KingfisherManager.shared.retrieveImage(
@@ -78,7 +77,6 @@ class ImageLoader: ObservableObject {
switch fallbackResult {
case .success(let value):
self.image = Image(uiImage: value.image)
print("[watchOS] Image loaded successfully from \(value.cacheType == .none ? "network" : "cache (\(value.cacheType))") using fallback processor.")
case .failure(let fallbackError):
self.errorMessage = fallbackError.localizedDescription
print("[watchOS] Image loading failed: \(fallbackError.localizedDescription)")

View File

@@ -0,0 +1,643 @@
//
// NetworkService.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/29. //
import Combine
import Foundation
// MARK: - WebSocket Data Structures
enum WebSocketState: Equatable {
case connected
case connecting
case disconnected
case serverDown
case duplicateDevice
case error(String)
// Equatable conformance
static func == (lhs: WebSocketState, rhs: WebSocketState) -> Bool {
switch (lhs, rhs) {
case (.connected, .connected),
(.connecting, .connecting),
(.disconnected, .disconnected),
(.serverDown, .serverDown),
(.duplicateDevice, .duplicateDevice):
return true
case let (.error(a), .error(b)):
return a == b
default:
return false
}
}
}
struct WebSocketPacket {
let type: String
let data: [String: Any]?
let endpoint: String?
let errorMessage: String?
}
// MARK: - Network Service
class NetworkService {
private let session: URLSession
init() {
let config = URLSessionConfiguration.ephemeral
config.waitsForConnectivity = true
session = URLSession(configuration: config)
}
// Add a serial queue for WebSocket operations
private let webSocketQueue = DispatchQueue(label: "com.solian.websocketQueue")
func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> ActivityResponse {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
var components = URLComponents(url: baseURL.appendingPathComponent("/sphere/activities"), resolvingAgainstBaseURL: false)!
var queryItems = [URLQueryItem(name: "take", value: "20")]
if filter.lowercased() != "explore" {
queryItems.append(URLQueryItem(name: "filter", value: filter.lowercased()))
}
if let cursor = cursor {
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
}
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, _) = try await session.data(for: request)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
let activities = try decoder.decode([SnActivity].self, from: data)
let hasMore = (activities.first?.type ?? "empty") != "empty"
let nextCursor = activities.isEmpty ? nil : activities.map { $0.createdAt }.min()?.ISO8601Format()
return ActivityResponse(activities: activities, hasMore: hasMore, nextCursor: nextCursor)
}
func createPost(title: String, content: String, token: String, serverUrl: String) async throws {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
let url = baseURL.appendingPathComponent("/sphere/posts")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let body: [String: Any] = ["title": title, "content": content]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 {
let responseBody = String(data: data, encoding: .utf8) ?? ""
print("[watchOS] createPost failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
}
}
func fetchNotifications(offset: Int = 0, take: Int = 20, token: String, serverUrl: String) async throws -> NotificationResponse {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)!
let queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))]
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
let notifications = try decoder.decode([SnNotification].self, from: data)
let httpResponse = response as? HTTPURLResponse
let total = Int(httpResponse?.value(forHTTPHeaderField: "X-Total") ?? "0") ?? 0
let hasMore = offset + notifications.count < total
return NotificationResponse(notifications: notifications, total: total, hasMore: hasMore)
}
func fetchUserProfile(token: String, serverUrl: String) async throws -> SnAccount {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
let url = baseURL.appendingPathComponent("/pass/accounts/me")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, _) = try await session.data(for: request)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(SnAccount.self, from: data)
}
func fetchAccountStatus(token: String, serverUrl: String) async throws -> SnAccountStatus? {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 {
return nil
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(SnAccountStatus.self, from: data)
}
func createOrUpdateStatus(attitude: Int, isInvisible: Bool, isNotDisturb: Bool, label: String?, token: String, serverUrl: String) async throws -> SnAccountStatus {
// Check if there\'s already a customized status
let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl)
let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST"
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
var body: [String: Any] = [
"attitude": attitude,
"is_invisible": isInvisible,
"is_not_disturb": isNotDisturb,
]
if let label = label, !label.isEmpty {
body["label"] = label
}
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 && httpResponse.statusCode != 200 {
let responseBody = String(data: data, encoding: .utf8) ?? ""
print("[watchOS] createOrUpdateStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(SnAccountStatus.self, from: data)
}
func clearStatus(token: String, serverUrl: String) async throws {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses")
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 204 {
let responseBody = String(data: data, encoding: .utf8) ?? ""
print("[watchOS] clearStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
}
}
// MARK: - Chat API Methods
func fetchChatRooms(token: String, serverUrl: String) async throws -> ChatRoomsResponse {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
let url = baseURL.appendingPathComponent("/sphere/chat")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, _) = try await session.data(for: request)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
let rooms = try decoder.decode([SnChatRoom].self, from: data)
return ChatRoomsResponse(rooms: rooms)
}
func fetchChatRoom(identifier: String, token: String, serverUrl: String) async throws -> SnChatRoom {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
let url = baseURL.appendingPathComponent("/sphere/chat/\(identifier)")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 {
throw URLError(.resourceUnavailable)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(SnChatRoom.self, from: data)
}
func fetchChatInvites(token: String, serverUrl: String) async throws -> ChatInvitesResponse {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
let url = baseURL.appendingPathComponent("/sphere/chat/invites")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, _) = try await session.data(for: request)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
let invites = try decoder.decode([SnChatMember].self, from: data)
return ChatInvitesResponse(invites: invites)
}
func acceptChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/accept")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
let responseBody = String(data: data, encoding: .utf8) ?? ""
print("[watchOS] acceptChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
}
}
func declineChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/decline")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
let responseBody = String(data: data, encoding: .utf8) ?? ""
print("[watchOS] declineChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
}
}
// MARK: - Message API Methods
func fetchChatMessages(chatRoomId: String, token: String, serverUrl: String, before: Date? = nil, take: Int = 50) async throws -> [SnChatMessage] {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
// Try a different pattern: /sphere/chat/messages with roomId as query param
var components = URLComponents(
url: baseURL.appendingPathComponent("/sphere/chat/\(chatRoomId)/messages"),
resolvingAgainstBaseURL: false
)!
var queryItems = [
URLQueryItem(name: "take", value: String(take)),
]
if let before = before {
queryItems.append(URLQueryItem(name: "before", value: ISO8601DateFormatter().string(from: before)))
}
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
_ = String(data: data, encoding: .utf8) ?? "Unable to decode response body"
if httpResponse.statusCode != 200 {
print("[watchOS] fetchChatMessages failed with status \(httpResponse.statusCode)")
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
}
}
// Check if data is empty
if data.isEmpty {
print("[watchOS] fetchChatMessages received empty response data")
return []
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let messages = try decoder.decode([SnChatMessage].self, from: data)
print("[watchOS] fetchChatMessages successfully decoded \(messages.count) messages")
return messages
} catch {
print("error: ", error)
throw error
}
}
// MARK: - WebSocket
private var webSocketTask: URLSessionWebSocketTask?
private var heartbeatTimer: Timer?
private var reconnectTimer: Timer?
private var isDisconnectingManually = false
private var lastToken: String?
private var lastServerUrl: String?
private var heartbeatAt: Date?
var heartbeatDelay: TimeInterval?
private let connectLock = NSLock()
private let packetSubject = PassthroughSubject<WebSocketPacket, Error>()
private let stateSubject = CurrentValueSubject<WebSocketState, Never>(.disconnected) // Changed to CurrentValueSubject
private var currentConnectionState: WebSocketState = .disconnected { // New property
didSet {
// Only send updates if the state has actually changed
if oldValue != currentConnectionState {
stateSubject.send(currentConnectionState)
}
}
}
var packetStream: AnyPublisher<WebSocketPacket, Error> {
packetSubject.eraseToAnyPublisher()
}
var stateStream: AnyPublisher<WebSocketState, Never> {
stateSubject.eraseToAnyPublisher()
}
func connectWebSocket(token: String, serverUrl: String) {
webSocketQueue.async { [weak self] in
guard let self = self else { return }
self.connectLock.lock()
defer { self.connectLock.unlock() }
// Prevent redundant connection attempts
if self.currentConnectionState == .connecting || self.currentConnectionState == .connected {
print("[WebSocket] Already connecting or connected, ignoring new connect request.")
return
}
self.currentConnectionState = .connecting
// Ensure any existing task is cancelled before starting a new one
self.webSocketTask?.cancel(with: .goingAway, reason: nil)
self.webSocketTask = nil
self.isDisconnectingManually = false // Reset this flag for a new connection attempt
self.lastToken = token
self.lastServerUrl = serverUrl
guard var urlComponents = URLComponents(string: serverUrl) else {
self.currentConnectionState = .error("Invalid server URL")
return
}
urlComponents.scheme = urlComponents.scheme?.replacingOccurrences(of: "http", with: "ws")
urlComponents.path = "/ws"
urlComponents.queryItems = [URLQueryItem(name: "deviceAlt", value: "watch")]
guard let url = urlComponents.url else {
self.currentConnectionState = .error("Invalid WebSocket URL")
return
}
var request = URLRequest(url: url)
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
print("[WebSocket] Trying connecting to \(url)")
self.webSocketTask = self.session.webSocketTask(with: request)
self.webSocketTask?.resume()
self.listenForWebSocketMessages()
self.scheduleHeartbeat()
self.currentConnectionState = .connected
}
}
private func listenForWebSocketMessages() {
// Ensure webSocketTask is still valid before attempting to receive
guard let task = webSocketTask else {
print("[WebSocket] listenForWebSocketMessages: webSocketTask is nil, stopping listen.")
return
}
task.receive { [weak self] result in
guard let self = self else { return }
switch result {
case .failure(let error):
print("[WebSocket] Error in receiving message: \(error)")
// Only attempt to reconnect if not manually disconnecting
if !self.isDisconnectingManually {
self.currentConnectionState = .error(error.localizedDescription)
self.scheduleReconnect()
} else {
// If manually disconnecting, just ensure state is disconnected
self.currentConnectionState = .disconnected
}
case .success(let message):
switch message {
case .string(let text):
self.handleWebSocketMessage(text: text)
case .data(let data):
if let text = String(data: data, encoding: .utf8) {
self.handleWebSocketMessage(text: text)
}
@unknown default:
break
}
// Continue listening for next message only if task is still valid
if self.webSocketTask === task { // Check if it's the same task
self.listenForWebSocketMessages()
} else {
print("[WebSocket] listenForWebSocketMessages: Task changed, stopping listen for old task.")
}
}
}
}
private func handleWebSocketMessage(text: String) {
guard let data = text.data(using: .utf8) else {
print("[WebSocket] Could not convert message to data")
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let type = json["type"] as? String
{
let packet = WebSocketPacket(
type: type,
data: json["data"] as? [String: Any],
endpoint: json["endpoint"] as? String,
errorMessage: json["errorMessage"] as? String
)
print("[WebSocket] Received packet: \(packet.type) \(packet.errorMessage ?? "")")
if packet.type == "error.dupe" {
self.currentConnectionState = .duplicateDevice
self.disconnectWebSocket()
return
}
if packet.type == "pong" {
if let beatAt = self.heartbeatAt {
let now = Date()
self.heartbeatDelay = now.timeIntervalSince(beatAt)
print("[WebSocket] Server respond last heartbeat for \((self.heartbeatDelay ?? 0) * 1000) ms")
}
}
self.packetSubject.send(packet)
}
} catch {
print("[WebSocket] Could not parse message json: \(error.localizedDescription)")
}
}
private func scheduleReconnect() {
reconnectTimer?.invalidate()
reconnectTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
guard let self = self, let token = self.lastToken, let serverUrl = self.lastServerUrl else { return }
print("[WebSocket] Attempting to reconnect...")
// No need to call disconnectWebSocket here, connectWebSocket will handle cancelling old task
self.isDisconnectingManually = false // Reset for the new connection attempt
self.connectWebSocket(token: token, serverUrl: serverUrl)
}
}
private func scheduleHeartbeat() {
heartbeatTimer?.invalidate()
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in
self?.beatTheHeart()
}
}
private func beatTheHeart() {
heartbeatAt = Date()
print("[WebSocket] We\'re beating the heart! \(String(describing: self.heartbeatAt))")
sendWebSocketMessage(message: "{\"type\":\"ping\"}")
}
func sendWebSocketMessage(message: String) {
webSocketTask?.send(.string(message)) { error in
if let error = error {
print("[WebSocket] Error sending message: \(error.localizedDescription)")
}
}
}
func disconnectWebSocket() {
isDisconnectingManually = true
reconnectTimer?.invalidate()
heartbeatTimer?.invalidate()
// Cancel the task and then nil it out
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil // Set to nil immediately after cancelling
self.currentConnectionState = .disconnected
}
}

View File

@@ -0,0 +1,58 @@
//
// AppState.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/29.
//
import SwiftUI
import Combine
// MARK: - App State
@MainActor
class AppState: ObservableObject {
@Published var token: String? = nil
@Published var serverUrl: String? = nil
@Published var isReady = false
@Published var errorMessage: String? = nil
let networkService = NetworkService()
private var wcService = WatchConnectivityService()
private var cancellables = Set<AnyCancellable>()
private var hasAttemptedConnection = false
init() {
wcService.$token.combineLatest(wcService.$serverUrl, wcService.$isFetched, wcService.$errorMessage)
.receive(on: DispatchQueue.main)
.sink { [weak self] (token: String?, serverUrl: String?, isFetched: Bool?, errorMessage: String?) in
guard let self = self else { return }
self.token = token
self.serverUrl = serverUrl
self.errorMessage = errorMessage
if let token = token, let serverUrl = serverUrl, !token.isEmpty, !serverUrl.isEmpty {
self.isReady = true
// Only connect once when we have valid credentials and tried fetch from phone
if !self.hasAttemptedConnection && isFetched == true {
self.hasAttemptedConnection = true
print("[AppState] Connecting WebSocket to server: \(serverUrl)")
self.networkService.connectWebSocket(token: token, serverUrl: serverUrl)
}
} else {
self.isReady = false
if self.hasAttemptedConnection {
self.hasAttemptedConnection = false
// Disconnect WebSocket if token or serverUrl become invalid
self.networkService.disconnectWebSocket()
}
}
}
.store(in: &cancellables)
}
func requestData() {
wcService.requestDataFromPhone()
}
}

View File

@@ -0,0 +1,113 @@
import WatchConnectivity
import Combine
import Foundation
class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject {
@Published var token: String?
@Published var serverUrl: String?
@Published var isFetched: Bool?
@Published var errorMessage: String?
private let session: WCSession
private let userDefaults = UserDefaults.standard
private let tokenKey = "token"
private let serverUrlKey = "serverUrl"
override init() {
self.session = .default
super.init()
print("[watchOS] Activating WCSession")
self.session.delegate = self
self.session.activate()
// Load cached data
self.token = userDefaults.string(forKey: tokenKey)
self.serverUrl = userDefaults.string(forKey: serverUrlKey)
self.isFetched = false
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("[watchOS] WCSession activation failed with error: \(error.localizedDescription)")
DispatchQueue.main.async {
self.errorMessage = "WCSession activation failed: \(error.localizedDescription)"
}
return
}
print("[watchOS] WCSession activated with state: \(activationState.rawValue)")
if activationState == .activated {
requestDataFromPhone()
}
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
print("[watchOS] Received application context: \(applicationContext)")
DispatchQueue.main.async {
if let token = applicationContext["token"] as? String {
self.token = token
self.userDefaults.set(token, forKey: self.tokenKey)
}
if let serverUrl = applicationContext["serverUrl"] as? String {
self.serverUrl = serverUrl
self.userDefaults.set(serverUrl, forKey: self.serverUrlKey)
}
self.isFetched = true
self.errorMessage = nil
}
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
print("[watchOS] Received message: \(message)")
DispatchQueue.main.async {
if let token = message["token"] as? String {
self.token = token
self.userDefaults.set(token, forKey: self.tokenKey)
}
if let serverUrl = message["serverUrl"] as? String {
self.serverUrl = serverUrl
self.userDefaults.set(serverUrl, forKey: self.serverUrlKey)
}
}
}
func requestDataFromPhone() {
// Check if we already have valid data to avoid unnecessary requests
if let token = self.token, let serverUrl = self.serverUrl, !token.isEmpty, !serverUrl.isEmpty {
print("[watchOS] Skipped fetch - already have valid data")
self.isFetched = true
return
}
guard session.activationState == .activated else {
print("[watchOS] Session not activated yet, state: \(session.activationState.rawValue)")
DispatchQueue.main.async {
self.errorMessage = "Session not ready yet"
}
return
}
print("[watchOS] Requesting data from phone")
session.sendMessage(["request": "data"]) { [weak self] response in
guard let self = self else { return }
print("[watchOS] Received reply: \(response)")
DispatchQueue.main.async {
self.isFetched = true
if let token = response["token"] as? String {
self.token = token
self.userDefaults.set(token, forKey: self.tokenKey)
}
if let serverUrl = response["serverUrl"] as? String {
self.serverUrl = serverUrl
self.userDefaults.set(serverUrl, forKey: self.serverUrlKey)
}
self.errorMessage = nil // Clear any previous errors
}
} errorHandler: { error in
print("[watchOS] sendMessage failed with error: \(error.localizedDescription)")
DispatchQueue.main.async {
self.errorMessage = "Failed to get data from phone: \(error.localizedDescription)"
// Don't set isFetched = true on error - allow retry
}
}
}
}

View File

@@ -0,0 +1,284 @@
//
// AccountView.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/30.
//
import SwiftUI
struct AccountView: View {
@EnvironmentObject var appState: AppState
@State private var user: SnAccount?
@State private var status: SnAccountStatus?
@State private var isLoading = false
@State private var error: Error?
@State private var showingClearConfirmation = false
@StateObject private var profileImageLoader = ImageLoader()
@StateObject private var bannerImageLoader = ImageLoader()
private let networkService = NetworkService()
var body: some View {
ScrollView {
if isLoading {
ProgressView()
.padding()
} else if let error = error {
VStack {
Text("Failed to load account")
.foregroundColor(.red)
Text(error.localizedDescription)
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
} else if let user = user {
VStack(spacing: 16) {
// Banner
if user.profile.background != nil {
if bannerImageLoader.isLoading {
ProgressView()
.frame(height: 80)
} else if let bannerImage = bannerImageLoader.image {
bannerImage
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 80)
.clipped()
.cornerRadius(8)
} else if bannerImageLoader.errorMessage != nil {
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(height: 80)
.cornerRadius(8)
} else {
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(height: 80)
.cornerRadius(8)
}
}
// Profile Picture
HStack(spacing: 16)
{
if profileImageLoader.isLoading {
ProgressView()
.frame(width: 60, height: 60)
} else if let profileImage = profileImageLoader.image {
profileImage
.resizable()
.frame(width: 60, height: 60)
.clipShape(Circle())
} else if profileImageLoader.errorMessage != nil {
Circle()
.fill(Color.red.opacity(0.3))
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "exclamationmark.triangle")
.resizable()
.scaledToFit()
.foregroundColor(.red)
)
} else {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "person.circle.fill")
.resizable()
.scaledToFit()
.foregroundColor(.gray)
)
}
// Username and Handle
VStack(alignment: .leading) {
Text(user.nick)
.font(.headline)
Text("@\(user.name)")
.font(.caption)
.foregroundColor(.secondary)
}
}
// Status
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Status")
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
if status?.isCustomized == true {
Button(action: {
showingClearConfirmation = true
}) {
ZStack {
Circle()
.fill(Color.red.opacity(0.1))
.frame(width: 28, height: 28)
Image(systemName: "trash")
.foregroundColor(.red)
}
}
.buttonStyle(.plain)
.frame(width: 28, height: 28)
}
NavigationLink(
destination: StatusCreationView(initialStatus: status?.isCustomized == true ? status : nil)
.environmentObject(appState)
) {
ZStack {
Circle()
.fill(Color.blue.opacity(0.1))
.frame(width: 28, height: 28)
Image(systemName: "pencil")
.foregroundColor(.blue)
}
}
.buttonStyle(.plain)
.frame(width: 28, height: 28)
}
if let status = status {
VStack(alignment: .leading, spacing: 4) {
HStack {
Circle()
.fill(status.isOnline ? Color.green : Color.gray)
.frame(width: 8, height: 8)
Text(status.label.isEmpty ? "No status" : status.label)
.font(.body)
}
if status.isInvisible {
Text("Invisible")
.font(.caption)
.foregroundColor(.secondary)
}
if status.isNotDisturb {
Text("Do Not Disturb")
.font(.caption)
.foregroundColor(.secondary)
}
if let clearedAt = status.clearedAt {
Text("Clears: \(clearedAt.formatted(date: .abbreviated, time: .shortened))")
.font(.caption)
.foregroundColor(.secondary)
}
}
} else {
Text("No status set")
.font(.body)
.foregroundColor(.secondary)
}
}
// Level and Progress
VStack(alignment: .leading, spacing: 8) {
Text("Level \(user.profile.level)")
.font(.title3)
.bold()
ProgressView(value: user.profile.levelingProgress)
.progressViewStyle(LinearProgressViewStyle())
.frame(height: 8)
Text("Experience: \(user.profile.experience)")
.font(.caption)
.foregroundColor(.secondary)
}
// Bio
if let bio = user.profile.bio, !bio.isEmpty {
Text(bio)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.frame(alignment: .leading)
} else {
Text("No bio available")
.font(.body)
.foregroundColor(.secondary)
.frame(alignment: .leading)
}
// Member since
Text("Joined at \(user.createdAt.formatted(.dateTime.month(.abbreviated).year()))")
.font(.caption)
.foregroundColor(.secondary)
.frame(alignment: .leading)
}
.padding()
// Load images when user data is available
.task(id: user.profile.picture?.id) {
if let serverUrl = appState.serverUrl, let pictureId = user.profile.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token {
await profileImageLoader.loadImage(from: imageUrl, token: token)
}
}
.task(id: user.profile.background?.id) {
if let serverUrl = appState.serverUrl, let backgroundId = user.profile.background?.id, let imageUrl = getAttachmentUrl(for: backgroundId, serverUrl: serverUrl), let token = appState.token {
await bannerImageLoader.loadImage(from: imageUrl, token: token)
}
}
} else {
Text("No account data")
.padding()
}
}
.navigationTitle("Account")
.confirmationDialog("Clear Status", isPresented: $showingClearConfirmation) {
Button("Clear Status", role: .destructive) {
Task {
await clearStatus()
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to clear your status? This action cannot be undone.")
}
.onAppear {
Task.detached {
await loadUserProfile()
}
}
}
private func loadUserProfile() async {
guard let token = appState.token, let serverUrl = appState.serverUrl else {
error = NSError(domain: "AccountView", code: 1, userInfo: [NSLocalizedDescriptionKey: "Authentication not available"])
return
}
isLoading = true
error = nil
do {
user = try await networkService.fetchUserProfile(token: token, serverUrl: serverUrl)
status = try await networkService.fetchAccountStatus(token: token, serverUrl: serverUrl)
} catch {
self.error = error
}
isLoading = false
}
private func clearStatus() async {
guard let token = appState.token, let serverUrl = appState.serverUrl else {
error = NSError(domain: "AccountView", code: 1, userInfo: [NSLocalizedDescriptionKey: "Authentication not available"])
return
}
do {
try await networkService.clearStatus(token: token, serverUrl: serverUrl)
// Refresh status after clearing
status = try await networkService.fetchAccountStatus(token: token, serverUrl: serverUrl)
} catch {
self.error = error
}
}
}
#Preview {
AccountView()
.environmentObject(AppState())
}

View File

@@ -0,0 +1,62 @@
//
// AppInfoHeader.swift
// Runner
//
// Created by LittleSheep on 2025/10/30.
//
import Combine
import SwiftUI
struct AppInfoHeaderView : View {
@EnvironmentObject var appState: AppState // Access AppState
@State private var webSocketConnectionState: WebSocketState = .disconnected // New state for WebSocket status
@State private var cancellables = Set<AnyCancellable>() // For managing subscriptions
var body: some View {
VStack(alignment: .leading) {
HStack(spacing: 12) {
Image("Logo")
.resizable()
.frame(width: 40, height: 40)
VStack(alignment: .leading) {
Text("Solian").font(.headline)
Text("for Apple Watch").font(.system(size: 11))
// Display WebSocket connection status
Text(webSocketStatusMessage)
.font(.system(size: 10))
.foregroundColor(.secondary)
}
}
}
.onAppear {
setupWebSocketListeners()
}
.onDisappear {
cancellables.forEach { $0.cancel() }
cancellables.removeAll()
}
}
private var webSocketStatusMessage: String {
switch webSocketConnectionState {
case .connected: return "Connected"
case .connecting: return "Connecting..."
case .disconnected: return "Disconnected"
case .serverDown: return "Server Down"
case .duplicateDevice: return "Duplicate Device"
case .error(let msg): return "Error: \(msg)"
}
}
private func setupWebSocketListeners() {
appState.networkService.stateStream
.receive(on: DispatchQueue.main)
.sink { state in
webSocketConnectionState = state
}
.store(in: &cancellables)
}
}

View File

@@ -46,26 +46,34 @@ struct AttachmentView: View {
}
} else if mimeType.starts(with: "video") {
if let serverUrl = appState.serverUrl, let videoUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl) {
let thumbnailUrl = videoUrl.appendingPathComponent("thumbnail") // Construct thumbnail URL
NavigationLink(destination: VideoPlayerView(videoUrl: videoUrl)) {
AsyncImage(url: thumbnailUrl) { phase in
if let image = phase.image {
if imageLoader.isLoading {
ProgressView()
} else if let image = imageLoader.image {
ZStack {
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity)
.cornerRadius(8)
} else if phase.error != nil {
Image(systemName: "play.rectangle.fill") // Placeholder for video thumbnail
Image(systemName: "play.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity)
.foregroundColor(.gray)
.cornerRadius(8)
} else {
ProgressView()
.cornerRadius(8)
.scaledToFit()
.frame(width: 36, height: 36)
.foregroundColor(.white)
.shadow(color: .black.opacity(0.6), radius: 4, x: 0, y: 2)
}
} else if imageLoader.errorMessage != nil {
Image(systemName: "play.rectangle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity)
.foregroundColor(.gray)
.cornerRadius(8)
} else {
ProgressView()
.cornerRadius(8)
}
}
.buttonStyle(PlainButtonStyle())
@@ -90,6 +98,11 @@ struct AttachmentView: View {
if attachment.mimeType?.starts(with: "image") == true {
await imageLoader.loadImage(from: attachmentUrl, token: token)
}
if attachment.mimeType?.starts(with: "video") == true {
let thumbnailUrl = attachmentUrl
.appending(queryItems: [URLQueryItem(name: "thumbnail", value: "true")]) // Construct thumbnail URL
await imageLoader.loadImage(from: thumbnailUrl, token: token)
}
}
}
}

View File

@@ -0,0 +1,785 @@
//
// ChatView.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/30.
//
import SwiftUI
struct ChatView: View {
@EnvironmentObject var appState: AppState
@State private var selectedTab = 0
@State private var chatRooms: [SnChatRoom] = []
@State private var chatInvites: [SnChatMember] = []
@State private var isLoading = false
@State private var error: Error?
@State private var showingInvites = false
private let tabs = ["All", "Direct", "Group"]
var body: some View {
TabView(selection: $selectedTab) {
ForEach(0..<tabs.count, id: \.self) { index in
VStack {
if isLoading {
ProgressView()
} else if error != nil {
VStack {
Text("Error loading chats")
.font(.caption)
Button("Retry") {
Task {
await loadChatRooms()
}
}
.font(.caption2)
}
} else {
ChatRoomListView(
chatRooms: filteredChatRooms(for: index),
selectedTab: index
)
}
}
.tabItem {
Text(tabs[index])
}
.tag(index)
}
}
.tabViewStyle(.page)
.navigationTitle("Chat")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingInvites = true
} label: {
ZStack {
Image(systemName: "envelope")
if !chatInvites.isEmpty {
Circle()
.fill(Color.red)
.frame(width: 8, height: 8)
.offset(x: 8, y: -8)
}
}
}
}
}
.sheet(isPresented: $showingInvites) {
ChatInvitesView(invites: $chatInvites, appState: appState)
}
.onAppear {
Task.detached {
await loadChatRooms()
await loadChatInvites()
}
}
}
private func filteredChatRooms(for tabIndex: Int) -> [SnChatRoom] {
switch tabIndex {
case 0: // All
return chatRooms
case 1: // Direct
return chatRooms.filter { $0.type == 1 }
case 2: // Group
return chatRooms.filter { $0.type != 1 }
default:
return chatRooms
}
}
private func loadChatRooms() async {
guard let token = appState.token, let serverUrl = appState.serverUrl else { return }
isLoading = true
error = nil
do {
let response = try await appState.networkService.fetchChatRooms(token: token, serverUrl: serverUrl)
chatRooms = response.rooms
} catch {
self.error = error
}
isLoading = false
}
private func loadChatInvites() async {
guard let token = appState.token, let serverUrl = appState.serverUrl else { return }
do {
let response = try await appState.networkService.fetchChatInvites(token: token, serverUrl: serverUrl)
chatInvites = response.invites
} catch {
// Handle error silently for invites
}
}
}
struct ChatRoomListView: View {
let chatRooms: [SnChatRoom]
let selectedTab: Int
var body: some View {
if chatRooms.isEmpty {
VStack {
Image(systemName: "message")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No chats yet")
.font(.caption)
.foregroundColor(.secondary)
}
} else {
List(chatRooms) { room in
ChatRoomListItem(room: room)
}
.listStyle(.plain)
}
}
}
struct ChatRoomListItem: View {
let room: SnChatRoom
@EnvironmentObject var appState: AppState
@StateObject private var avatarLoader = ImageLoader()
private var displayName: String {
if room.type == 1, let members = room.members, !members.isEmpty {
// For direct messages, show the other member's name
return members[0].account.nick
} else {
// For group chats, show room name or fallback
return room.name ?? "Group Chat"
}
}
private var subtitle: String {
if room.type == 1, let members = room.members, members.count > 1 {
// For direct messages, show member usernames
return members.map { "@\($0.account.name)" }.joined(separator: ", ")
} else if let description = room.description {
// For group chats with description
return description
} else {
// Fallback
return ""
}
}
private var avatarPictureId: String? {
if room.type == 1, let members = room.members, !members.isEmpty {
// For direct messages, use the other member's avatar
return members[0].account.profile.picture?.id
} else {
// For group chats, use room picture
return room.picture?.id
}
}
var body: some View {
NavigationLink(
destination: ChatRoomView(room: room)
.environmentObject(appState)
) {
HStack {
// Avatar using ImageLoader pattern
Group {
if avatarLoader.isLoading {
ProgressView()
.frame(width: 32, height: 32)
} else if let image = avatarLoader.image {
image
.resizable()
.frame(width: 32, height: 32)
.clipShape(Circle())
} else if avatarLoader.errorMessage != nil {
// Error state - show fallback
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 32, height: 32)
.overlay(
Text(displayName.prefix(1).uppercased())
.font(.system(size: 12, weight: .medium))
.foregroundColor(.primary)
)
} else {
// No image available - show initial
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 32, height: 32)
.overlay(
Text(displayName.prefix(1).uppercased())
.font(.system(size: 12, weight: .medium))
.foregroundColor(.primary)
)
}
}
.task(id: avatarPictureId) {
if let serverUrl = appState.serverUrl,
let pictureId = avatarPictureId,
let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl),
let token = appState.token {
await avatarLoader.loadImage(from: imageUrl, token: token)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(displayName)
.font(.system(size: 14, weight: .medium))
.lineLimit(1)
if !subtitle.isEmpty {
Text(subtitle)
.font(.system(size: 12))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
Spacer()
// Unread count badge placeholder
// In a full implementation, this would show unread count
}
.padding(.vertical, 4)
}
}
}
import Combine
import SwiftUI
struct ChatRoomView: View {
let room: SnChatRoom
@EnvironmentObject var appState: AppState
@State private var messages: [SnChatMessage] = []
@State private var isLoading = false
@State private var error: Error?
@State private var wsState: WebSocketState = .disconnected // New state for WebSocket status
@State private var hasLoadedMessages = false // Track if messages have been loaded
@State private var messageText = "" // Text input for sending messages
@State private var isSending = false // Track sending state
@State private var isInputHidden = false // Track if input should be hidden during scrolling
@State private var scrollTimer: Timer? // Timer to show input after scrolling stops
@State private var cancellables = Set<AnyCancellable>() // For managing subscriptions
var body: some View {
VStack {
// Display WebSocket connection status
if (wsState != .connected)
{
Text(webSocketStatusMessage)
.font(.caption2)
.foregroundColor(.secondary)
.padding(.vertical, 2)
.animation(.easeInOut, value: wsState) // Animate status changes
.transition(.opacity)
}
if isLoading {
ProgressView()
} else if error != nil {
VStack {
Text("Error loading messages")
.font(.caption)
Button("Retry") {
Task {
await loadMessages()
}
}
.font(.caption2)
}
} else if messages.isEmpty {
VStack {
Image(systemName: "bubble.left")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No messages yet")
.font(.caption)
.foregroundColor(.secondary)
}
} else {
ScrollViewReader { scrollView in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(messages) { message in
ChatMessageItem(message: message)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
.padding(.bottom, 8)
}
.onAppear {
// Scroll to bottom when messages load
if let lastMessage = messages.last {
scrollView.scrollTo(lastMessage.id, anchor: .bottom)
}
}
.onChange(of: messages.count) { _, _ in
// Scroll to bottom when new messages arrive
if let lastMessage = messages.last {
withAnimation {
scrollView.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
.onScrollPhaseChange { _, phase in
switch phase {
case .interacting:
if !isInputHidden {
withAnimation(.easeOut(duration: 0.2)) {
isInputHidden = true
}
}
case .idle:
withAnimation(.easeIn(duration: 0.3)) {
isInputHidden = false
}
default: break
}
}
}
}
// Message input area
if !isInputHidden {
HStack(spacing: 8) {
TextField("Send message...", text: $messageText)
.font(.system(size: 14))
.disabled(isSending)
.frame(height: 40)
Button {
Task {
await sendMessage()
}
} label: {
if isSending {
ProgressView()
.frame(width: 20, height: 20)
} else {
Image(systemName: "arrow.up.circle.fill")
.resizable()
.frame(width: 20, height: 20)
}
}
.labelStyle(.iconOnly)
.buttonStyle(.automatic)
.disabled(messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
.frame(width: 40, height: 40)
}
.padding(.horizontal)
.padding(.top, 8)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.navigationTitle(room.name ?? "Chat")
.task {
await loadMessages()
}
.onAppear {
setupWebSocketListeners()
}
.onDisappear {
cancellables.forEach { $0.cancel() }
cancellables.removeAll()
scrollTimer?.invalidate()
scrollTimer = nil
}
}
private var webSocketStatusMessage: String {
switch wsState {
case .connected: return "Connected"
case .connecting: return "Connecting..."
case .disconnected: return "Disconnected"
case .serverDown: return "Server Down"
case .duplicateDevice: return "Duplicate Device"
case .error(let msg): return "Error: \(msg)"
}
}
private func loadMessages() async {
// Prevent reloading if already loaded
guard !hasLoadedMessages else { return }
guard let token = appState.token, let serverUrl = appState.serverUrl else {
isLoading = false
return
}
isLoading = true
error = nil
do {
let messages = try await appState.networkService.fetchChatMessages(
chatRoomId: room.id,
token: token,
serverUrl: serverUrl
)
// Sort with newest messages first (for flipped list, newest will appear at bottom)
self.messages = messages.sorted { $0.createdAt < $1.createdAt }
hasLoadedMessages = true
} catch {
print("[watchOS] Error loading messages: \(error.localizedDescription)")
self.error = error
}
isLoading = false
}
private func sendMessage() async {
let content = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty,
let token = appState.token,
let serverUrl = appState.serverUrl else { return }
isSending = true
do {
// Generate a nonce for the message
let nonce = UUID().uuidString
// Prepare the request data
let messageData: [String: Any] = [
"content": content,
"attachments_id": [], // Empty for now, can be extended for attachments
"meta": [:],
"nonce": nonce
]
// Create the URL
guard let url = URL(string: "\(serverUrl)/sphere/chat/\(room.id)/messages") else {
throw URLError(.badURL)
}
// Create the request
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: messageData, options: [])
// Send the request
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
// Parse the response to get the sent message
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
let sentMessage = try decoder.decode(SnChatMessage.self, from: data)
// Add the message to the local list
messages.append(sentMessage)
// Clear the input
messageText = ""
} catch {
print("[watchOS] Error sending message: \(error.localizedDescription)")
// Could show an error alert here
}
isSending = false
}
private func sendReadReceipt() {
let data: [String: Any] = ["chat_room_id": room.id]
let packet: [String: Any] = ["type": "messages.read", "data": data, "endpoint": "sphere"]
if let jsonData = try? JSONSerialization.data(withJSONObject: packet, options: []),
let jsonString = String(data: jsonData, encoding: .utf8) {
appState.networkService.sendWebSocketMessage(message: jsonString)
}
}
private func setupWebSocketListeners() {
// Listen for WebSocket packets (new messages)
appState.networkService.packetStream
.receive(on: DispatchQueue.main) // Ensure UI updates on main thread
.sink(receiveCompletion: { completion in
if case .failure(let err) = completion {
print("[ChatRoomView] WebSocket packet stream error: \(err.localizedDescription)")
}
}, receiveValue: { packet in
if ["messages.new", "messages.update", "messages.delete"].contains(packet.type),
let messageData = packet.data {
do {
let jsonData = try JSONSerialization.data(withJSONObject: messageData, options: [])
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
let message = try decoder.decode(SnChatMessage.self, from: jsonData)
if message.chatRoomId == room.id {
switch packet.type {
case "messages.new":
if message.type.hasPrefix("call") {
// TODO: Handle ongoing call
}
if !messages.contains(where: { $0.id == message.id }) {
messages.append(message)
}
sendReadReceipt()
case "messages.update":
if let index = messages.firstIndex(where: { $0.id == message.id }) {
messages[index] = message
}
case "messages.delete":
messages.removeAll(where: { $0.id == message.id })
default:
break
}
}
} catch {
print("[ChatRoomView] Error decoding message from websocket: \(error.localizedDescription)")
}
}
})
.store(in: &cancellables)
// Listen for WebSocket connection state changes
appState.networkService.stateStream
.receive(on: DispatchQueue.main) // Ensure UI updates on main thread
.sink { state in
wsState = state
}
.store(in: &cancellables)
}
}
struct ChatMessageItem: View {
let message: SnChatMessage
@EnvironmentObject var appState: AppState
@StateObject private var avatarLoader = ImageLoader()
private var avatarPictureId: String? {
message.sender.account.profile.picture?.id
}
var body: some View {
HStack(alignment: .top, spacing: 8) {
// Avatar
Group {
if avatarLoader.isLoading {
ProgressView()
.frame(width: 24, height: 24)
} else if let image = avatarLoader.image {
image
.resizable()
.frame(width: 24, height: 24)
.clipShape(Circle())
} else {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 24, height: 24)
.overlay(
Text(message.sender.account.nick.prefix(1).uppercased())
.font(.system(size: 10, weight: .medium))
.foregroundColor(.primary)
)
}
}
.task(id: avatarPictureId) {
if let serverUrl = appState.serverUrl,
let pictureId = avatarPictureId,
let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl),
let token = appState.token {
await avatarLoader.loadImage(from: imageUrl, token: token)
}
}
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(message.sender.account.nick)
.font(.system(size: 12, weight: .medium))
Spacer()
Text(message.createdAt, style: .time)
.font(.system(size: 10))
.foregroundColor(.secondary)
}
if let content = message.content, !content.isEmpty {
Text(content)
.font(.system(size: 14))
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
}
if !message.attachments.isEmpty {
AttachmentView(attachment: message.attachments[0])
if message.attachments.count > 1 {
HStack(spacing: 8) {
Image(systemName: "paperclip.circle.fill")
.frame(width: 12, height: 12)
.foregroundStyle(.gray)
Text("\(message.attachments.count - 1)+ attachments")
.font(.footnote)
.foregroundStyle(.gray)
}
}
}
}
}
.padding(.vertical, 4)
}
}
struct ChatInvitesView: View {
@Binding var invites: [SnChatMember]
let appState: AppState
@Environment(\.dismiss) private var dismiss
@State private var isLoading = false
var body: some View {
NavigationView {
VStack {
if invites.isEmpty {
VStack {
Image(systemName: "envelope.open")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No invites")
.font(.caption)
.foregroundColor(.secondary)
}
} else {
List(invites) { invite in
ChatInviteItem(invite: invite, appState: appState, invites: $invites)
}
.listStyle(.plain)
}
}
.navigationTitle("Invites")
.navigationBarTitleDisplayMode(.inline)
}
}
}
struct ChatInviteItem: View {
let invite: SnChatMember
let appState: AppState
@Binding var invites: [SnChatMember]
@State private var isAccepting = false
@State private var isDeclining = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 24, height: 24)
.overlay(
Text((invite.chatRoom?.name ?? "C").prefix(1).uppercased())
.font(.system(size: 10, weight: .medium))
.foregroundColor(.primary)
)
VStack(alignment: .leading, spacing: 2) {
Text(invite.chatRoom?.name ?? "Unknown Chat")
.font(.system(size: 14, weight: .medium))
.lineLimit(1)
HStack(spacing: 4) {
Text(invite.role == 100 ? "Owner" : invite.role >= 50 ? "Moderator" : "Member")
.font(.system(size: 12))
.foregroundColor(.secondary)
if invite.chatRoom?.type == 1 {
Text("Direct")
.font(.system(size: 12))
.foregroundColor(.blue)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.1))
.cornerRadius(4)
}
}
}
Spacer()
}
HStack(spacing: 8) {
Button {
Task {
await acceptInvite()
}
} label: {
if isAccepting {
ProgressView()
.frame(width: 20, height: 20)
} else {
Image(systemName: "checkmark")
.frame(width: 20, height: 20)
}
}
.disabled(isAccepting || isDeclining)
Button {
Task {
await declineInvite()
}
} label: {
if isDeclining {
ProgressView()
.frame(width: 20, height: 20)
} else {
Image(systemName: "xmark")
.frame(width: 20, height: 20)
}
}
.disabled(isAccepting || isDeclining)
}
}
.padding(.vertical, 8)
}
private func acceptInvite() async {
guard let token = appState.token,
let serverUrl = appState.serverUrl,
let chatRoomId = invite.chatRoom?.id else { return }
isAccepting = true
do {
try await appState.networkService.acceptChatInvite(chatRoomId: chatRoomId, token: token, serverUrl: serverUrl)
// Remove from invites list
invites.removeAll { $0.id == invite.id }
} catch {
// Handle error - could show alert
print("Failed to accept invite: \(error)")
}
isAccepting = false
}
private func declineInvite() async {
guard let token = appState.token,
let serverUrl = appState.serverUrl,
let chatRoomId = invite.chatRoom?.id else { return }
isDeclining = true
do {
try await appState.networkService.declineChatInvite(chatRoomId: chatRoomId, token: token, serverUrl: serverUrl)
// Remove from invites list
invites.removeAll { $0.id == invite.id }
} catch {
// Handle error - could show alert
print("Failed to decline invite: \(error)")
}
isDeclining = false
}
}

View File

@@ -9,7 +9,7 @@ import SwiftUI
// The main view with the TabView for filtering.
struct ExploreView: View {
@StateObject private var appState = AppState()
@EnvironmentObject private var appState: AppState
@State private var isComposing = false
@State private var selectedTab: String = "Explore"
@@ -22,18 +22,21 @@ struct ExploreView: View {
.tabItem {
Label("Explore", systemImage: "safari")
}
.labelStyle(.titleOnly)
ActivityListView(filter: "Subscriptions")
.tag("Subscriptions")
.tabItem {
Label("Subscriptions", systemImage: "star")
}
.labelStyle(.titleOnly)
ActivityListView(filter: "Friends")
.tag("Friends")
.tabItem {
Label("Friends", systemImage: "person.2")
}
.labelStyle(.titleOnly)
}
.navigationTitle(selectedTab)
.toolbar {
@@ -43,17 +46,22 @@ struct ExploreView: View {
}
}
}
.environmentObject(appState)
} else {
ProgressView { Text("Connecting to phone...") }
.onAppear {
VStack {
ProgressView { Text("Syncing...") }
Button("Retry") {
appState.requestData()
}
}
}
}
.sheet(isPresented: $isComposing) {
ComposePostView()
.environmentObject(appState)
}
.alert("Error", isPresented: .constant(appState.errorMessage != nil), actions: {
Button("OK") { appState.errorMessage = nil }
}, message: {
Text(appState.errorMessage ?? "")
})
}
}
}

View File

@@ -15,28 +15,26 @@ struct PostRowView: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token {
if imageLoader.isLoading {
ProgressView()
.frame(width: 24, height: 24)
} else if let image = imageLoader.image {
image
.resizable()
.frame(width: 24, height: 24)
.clipShape(Circle())
} else if let errorMessage = imageLoader.errorMessage {
Text("Failed: \(errorMessage)")
.font(.caption)
.foregroundColor(.red)
.frame(width: 24, height: 24)
} else {
// Placeholder if no image and not loading
Image(systemName: "person.circle.fill")
.resizable()
.frame(width: 24, height: 24)
.clipShape(Circle())
.foregroundColor(.gray)
}
if imageLoader.isLoading {
ProgressView()
.frame(width: 24, height: 24)
} else if let image = imageLoader.image {
image
.resizable()
.frame(width: 24, height: 24)
.clipShape(Circle())
} else if let errorMessage = imageLoader.errorMessage {
Text("Failed: \(errorMessage)")
.font(.caption)
.foregroundColor(.red)
.frame(width: 24, height: 24)
} else {
// Placeholder if no image and not loading
Image(systemName: "person.circle.fill")
.resizable()
.frame(width: 24, height: 24)
.clipShape(Circle())
.foregroundColor(.gray)
}
Text(post.publisher.nick ?? post.publisher.name)
.font(.subheadline)
@@ -57,7 +55,21 @@ struct PostRowView: View {
Text(content)
.font(.body)
}
}
if !post.attachments.isEmpty {
AttachmentView(attachment: post.attachments[0])
if post.attachments.count > 1 {
HStack(spacing: 8) {
Image(systemName: "paperclip.circle.fill")
.frame(width: 12, height: 12)
.foregroundStyle(.gray)
Text("\(post.attachments.count - 1)+ attachments")
.font(.footnote)
.foregroundStyle(.gray)
}
}
}
}.padding(.vertical)
}
}
@@ -70,32 +82,31 @@ struct PostDetailView: View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
HStack {
if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token {
if publisherImageLoader.isLoading {
ProgressView()
.frame(width: 32, height: 32)
} else if let image = publisherImageLoader.image {
image
.resizable()
.frame(width: 32, height: 32)
.clipShape(Circle())
} else if let errorMessage = publisherImageLoader.errorMessage {
Text("Failed: \(errorMessage)")
.font(.caption)
.foregroundColor(.red)
.frame(width: 32, height: 32)
} else {
Image(systemName: "person.circle.fill")
.resizable()
.frame(width: 32, height: 32)
.clipShape(Circle())
.foregroundColor(.gray)
}
if publisherImageLoader.isLoading {
ProgressView()
.frame(width: 32, height: 32)
} else if let image = publisherImageLoader.image {
image
.resizable()
.frame(width: 32, height: 32)
.clipShape(Circle())
} else if let errorMessage = publisherImageLoader.errorMessage {
Text("Failed: \(errorMessage)")
.font(.caption)
.foregroundColor(.red)
.frame(width: 32, height: 32)
} else {
Image(systemName: "person.circle.fill")
.resizable()
.frame(width: 32, height: 32)
.clipShape(Circle())
.foregroundColor(.gray)
}
Text("@\(post.publisher.name)")
.font(.headline)
}
.task(id: post.publisher.picture?.id) { // Use task(id:) to reload image when pictureId changes
// Use task(id:) to reload image when pictureId changes
.task(id: post.publisher.picture?.id) {
if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token {
await publisherImageLoader.loadImage(from: imageUrl, token: token)
}
@@ -113,7 +124,6 @@ struct PostDetailView: View {
}
if !post.attachments.isEmpty {
Divider()
Text("Attachments").font(.headline)
ForEach(post.attachments) { attachment in
AttachmentView(attachment: attachment)
@@ -121,13 +131,12 @@ struct PostDetailView: View {
}
if !post.tags.isEmpty {
Divider()
Text("Tags").font(.headline)
FlowLayout(alignment: .leading, spacing: 4) {
ForEach(post.tags) { tag in
Text("#\(tag.name ?? tag.slug)")
.font(.caption)
.padding(.horizontal, 6)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Capsule().fill(Color.accentColor.opacity(0.2)))
.cornerRadius(5)

View File

@@ -0,0 +1,132 @@
//
// StatusCreationView.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/30.
//
import SwiftUI
struct StatusCreationView: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) var dismiss
let initialStatus: SnAccountStatus?
@State private var attitude: Int
@State private var isInvisible: Bool
@State private var isNotDisturb: Bool
@State private var label: String
@State private var isSubmitting: Bool = false
@State private var error: Error? = nil
private let networkService = NetworkService()
init(initialStatus: SnAccountStatus? = nil) {
self.initialStatus = initialStatus
_attitude = State(initialValue: initialStatus?.attitude ?? 1)
_isInvisible = State(initialValue: initialStatus?.isInvisible ?? false)
_isNotDisturb = State(initialValue: initialStatus?.isNotDisturb ?? false)
_label = State(initialValue: initialStatus?.label ?? "")
}
var body: some View {
ScrollView {
VStack(spacing: 16) {
// Title
Text("Set Status")
.font(.headline)
.padding(.top)
// Label TextField
TextField("Status label", text: $label)
.textFieldStyle(.automatic)
.padding(.horizontal)
// Attitude Picker
VStack(alignment: .leading, spacing: 8) {
Text("Mood")
.font(.subheadline)
.foregroundColor(.secondary)
Picker("Attitude", selection: $attitude) {
Text("😊 Positive").tag(0)
Text("😐 Neutral").tag(1)
Text("😢 Negative").tag(2)
}
.pickerStyle(.wheel)
.frame(height: 80)
}
.padding(.horizontal)
// Toggles
VStack(spacing: 12) {
Toggle("Invisible", isOn: $isInvisible)
.padding(.horizontal)
Toggle("Do Not Disturb", isOn: $isNotDisturb)
.padding(.horizontal)
}
// Error message
if let error = error {
Text("Error: \(error.localizedDescription)")
.foregroundColor(.red)
.font(.caption)
.padding(.horizontal)
}
// Buttons
HStack(spacing: 12) {
Button("Cancel") {
dismiss()
}
.buttonStyle(.automatic)
Button(isSubmitting ? "Saving..." : "Save") {
Task {
await submitStatus()
}
}
.buttonStyle(.automatic)
.disabled(isSubmitting)
}
.padding(.horizontal)
.padding(.bottom)
}
}
.navigationTitle("Status")
.navigationBarTitleDisplayMode(.inline)
}
private func submitStatus() async {
guard let token = appState.token, let serverUrl = appState.serverUrl else {
error = NSError(domain: "StatusCreationView", code: 1, userInfo: [NSLocalizedDescriptionKey: "Authentication not available"])
return
}
isSubmitting = true
error = nil
do {
_ = try await networkService.createOrUpdateStatus(
attitude: attitude,
isInvisible: isInvisible,
isNotDisturb: isNotDisturb,
label: label.isEmpty ? nil : label,
token: token,
serverUrl: serverUrl
)
dismiss()
} catch {
self.error = error
}
isSubmitting = false
}
}
#Preview {
StatusCreationView()
.environmentObject(AppState())
}

View File

@@ -9,6 +9,7 @@ import UserNotifications
import Intents
import Kingfisher
import UniformTypeIdentifiers
import KingfisherWebP
enum ParseNotificationPayloadError: Error {
case missingMetadata(String)
@@ -24,6 +25,11 @@ class NotificationService: UNNotificationServiceExtension {
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
KingfisherManager.shared.defaultOptions += [
.processor(WebPProcessor.default),
.cacheSerializer(WebPSerializer.default)
]
self.contentHandler = contentHandler
guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
contentHandler(request.content)
@@ -57,37 +63,58 @@ class NotificationService: UNNotificationServiceExtension {
guard let meta = content.userInfo["meta"] as? [AnyHashable: Any] else {
throw ParseNotificationPayloadError.missingMetadata("The notification has no meta.")
}
let pfpIdentifier = meta["pfp"] as? String
let metaCopy = meta as? [String: Any] ?? [:]
let pfpUrl = pfpIdentifier != nil ? getAttachmentUrl(for: pfpIdentifier!) : nil
let targetSize = 512
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
KingfisherManager.shared.retrieveImage(with: URL(string: pfpUrl!)!, options: [.processor(scaleProcessor)], completionHandler: { result in
var image: Data?
switch result {
case .success(let value):
image = value.image.pngData()
case .failure(let error):
print("Unable to get pfp url: \(error)")
}
let handle = INPersonHandle(value: "\(metaCopy["user_id"] ?? "")", type: .unknown)
let handle = INPersonHandle(value: "\(metaCopy["user_id"] ?? "")", type: .unknown)
let completeNotificationProcessing: (Data?) -> Void = { imageData in
let sender = INPerson(
personHandle: handle,
nameComponents: PersonNameComponents(nickname: "\(metaCopy["sender_name"] ?? "")"),
displayName: content.title,
image: image == nil ? nil : INImage(imageData: image!),
image: imageData == nil ? nil : INImage(imageData: imageData!),
contactIdentifier: nil,
customIdentifier: nil
)
content.categoryIdentifier = "CHAT_MESSAGE"
self.contentHandler?(content)
})
let intent = self.createMessageIntent(with: sender, meta: metaCopy, body: content.body)
self.donateInteraction(for: intent)
if let updatedContent = try? request.content.updating(from: intent) {
if let mutableContent = updatedContent.mutableCopy() as? UNMutableNotificationContent {
mutableContent.categoryIdentifier = "CHAT_MESSAGE"
self.contentHandler?(mutableContent)
} else {
self.contentHandler?(updatedContent)
}
} else {
content.categoryIdentifier = "CHAT_MESSAGE"
self.contentHandler?(content)
}
}
if let pfpUrl = pfpUrl, let url = URL(string: pfpUrl) {
let targetSize = 512
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
KingfisherManager.shared.retrieveImage(with: url, options: [
.processor(scaleProcessor)
], completionHandler: { result in
var image: Data?
switch result {
case .success(let value):
image = value.image.pngData()
case .failure(let error):
print("Unable to get pfp url: \(error)")
}
completeNotificationProcessing(image)
})
} else {
completeNotificationProcessing(nil)
}
}
private func handleDefaultNotification(content: UNMutableNotificationContent) throws {

View File

@@ -1,40 +0,0 @@
//
// ContentView.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/28.
//
import SwiftUI
// The root view of the app.
struct ContentView: View {
@StateObject private var appState = AppState()
@State private var selection: Panel? = .explore
enum Panel: Hashable {
case explore
case notifications
}
var body: some View {
NavigationSplitView {
List(selection: $selection) {
Label("Explore", systemImage: "globe").tag(Panel.explore)
Label("Notifications", systemImage: "bell").tag(Panel.notifications)
}
.listStyle(.automatic)
} detail: {
switch selection {
case .explore:
ExploreView()
.environmentObject(appState)
case .notifications:
NotificationView()
.environmentObject(appState)
case .none:
Text("Select a panel")
}
}
}
}

View File

@@ -1,15 +0,0 @@
//
// CustomPreviews.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/29.
//
import SwiftUI
#Preview {
NavigationStack {
ActivityListView(filter: "Preview", mockActivities: SnActivity.mock)
.environmentObject(AppState())
}
}

View File

@@ -1,35 +0,0 @@
//
// MockData.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/29.
//
import Foundation
#if DEBUG
extension SnActivity {
static var mock: [SnActivity] {
let mockPublisher = SnPublisher(id: "pub1", name: "Mock Publisher", nick: "mock_nick", description: "A publisher for testing", picture: SnCloudFile(id: "mock_avatar_id", mimeType: "image/png"))
let mockTag1 = SnPostTag(id: "tag1", slug: "swiftui", name: "SwiftUI")
let mockTag2 = SnPostTag(id: "tag2", slug: "watchos", name: "watchOS")
let mockAttachment1 = SnCloudFile(id: "mock_image_id_1", mimeType: "image/jpeg")
let mockAttachment2 = SnCloudFile(id: "mock_image_id_2", mimeType: "image/png")
let post1 = SnPost(id: "1", title: "Hello from a Mock Post!", content: "This is a mock post content. It can be a bit longer to see how it wraps.", publisher: mockPublisher, attachments: [mockAttachment1, mockAttachment2], tags: [mockTag1, mockTag2])
let activity1 = SnActivity(id: "1", type: "posts.new", data: .post(post1), createdAt: Date())
let realm1 = SnRealm(id: "r1", name: "SwiftUI Previews", description: "A place for designing in previews.")
let publisher1 = SnPublisher(id: "p1", name: "The Mock Times", nick: "mock_times", description: "All the news that's fit to mock.", picture: nil)
let article1 = SnWebArticle(id: "a1", title: "The Art of Mocking Data", url: "https://example.com")
let discoveryItem1 = DiscoveryItem(type: "realm", data: .realm(realm1))
let discoveryItem2 = DiscoveryItem(type: "publisher", data: .publisher(publisher1))
let discoveryItem3 = DiscoveryItem(type: "article", data: .article(article1))
let discoveryData = DiscoveryData(items: [discoveryItem1, discoveryItem2, discoveryItem3])
let activity2 = SnActivity(id: "2", type: "discovery", data: .discovery(discoveryData), createdAt: Date())
return [activity1, activity2]
}
}
#endif

View File

@@ -1,103 +0,0 @@
//
// NetworkService.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/29.
//
import Foundation
// MARK: - Network Service
class NetworkService {
private let session = URLSession.shared
func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> ActivityResponse {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
var components = URLComponents(url: baseURL.appendingPathComponent("/sphere/activities"), resolvingAgainstBaseURL: false)!
var queryItems = [URLQueryItem(name: "take", value: "20")]
if filter.lowercased() != "explore" {
queryItems.append(URLQueryItem(name: "filter", value: filter.lowercased()))
}
if let cursor = cursor {
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
}
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, _) = try await session.data(for: request)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
let activities = try decoder.decode([SnActivity].self, from: data)
let hasMore = (activities.first?.type ?? "empty") != "empty"
let nextCursor = activities.isEmpty ? nil : activities.map { $0.createdAt }.min()?.ISO8601Format()
return ActivityResponse(activities: activities, hasMore: hasMore, nextCursor: nextCursor)
}
func createPost(title: String, content: String, token: String, serverUrl: String) async throws {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
let url = baseURL.appendingPathComponent("/sphere/posts")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let body: [String: Any] = ["title": title, "content": content]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 {
let responseBody = String(data: data, encoding: .utf8) ?? ""
print("[watchOS] createPost failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
}
}
func fetchNotifications(offset: Int = 0, take: Int = 20, token: String, serverUrl: String) async throws -> NotificationResponse {
guard let baseURL = URL(string: serverUrl) else {
throw URLError(.badURL)
}
var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)!
var queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))]
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
let notifications = try decoder.decode([SnNotification].self, from: data)
let httpResponse = response as? HTTPURLResponse
let total = Int(httpResponse?.value(forHTTPHeaderField: "X-Total") ?? "0") ?? 0
let hasMore = offset + notifications.count < total
return NotificationResponse(notifications: notifications, total: total, hasMore: hasMore)
}
}

View File

@@ -1,38 +0,0 @@
//
// AppState.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/29.
//
import SwiftUI
import Combine
// MARK: - App State
@MainActor
class AppState: ObservableObject {
@Published var token: String? = nil
@Published var serverUrl: String? = nil
@Published var isReady = false
private var wcService = WatchConnectivityService()
private var cancellables = Set<AnyCancellable>()
init() {
wcService.$token.combineLatest(wcService.$serverUrl)
.receive(on: DispatchQueue.main)
.sink { [weak self] token, serverUrl in
self?.token = token
self?.serverUrl = serverUrl
if token != nil && serverUrl != nil {
self?.isReady = true
}
}
.store(in: &cancellables)
}
func requestData() {
wcService.requestDataFromPhone()
}
}

View File

@@ -1,84 +0,0 @@
//
// WatchConnectivityService.swift
// WatchRunner Watch App
//
// Created by LittleSheep on 2025/10/29.
//
import Foundation
import WatchConnectivity
import Combine
// MARK: - Watch Connectivity
class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject {
@Published var token: String?
@Published var serverUrl: String?
private let session: WCSession
private let userDefaults = UserDefaults.standard
private let tokenKey = "token"
private let serverUrlKey = "serverUrl"
override init() {
self.session = .default
super.init()
print("[watchOS] Activating WCSession")
self.session.delegate = self
self.session.activate()
// Load cached data
self.token = userDefaults.string(forKey: tokenKey)
self.serverUrl = userDefaults.string(forKey: serverUrlKey)
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("[watchOS] WCSession activation failed with error: \(error.localizedDescription)")
return
}
print("[watchOS] WCSession activated with state: \(activationState.rawValue)")
if activationState == .activated {
requestDataFromPhone()
}
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
print("[watchOS] Received message: \(message)")
DispatchQueue.main.async {
if let token = message["token"] as? String {
self.token = token
self.userDefaults.set(token, forKey: self.tokenKey)
}
if let serverUrl = message["serverUrl"] as? String {
self.serverUrl = serverUrl
self.userDefaults.set(serverUrl, forKey: self.serverUrlKey)
}
}
}
func requestDataFromPhone() {
guard session.isReachable else {
print("[watchOS] Phone is not reachable")
return
}
print("[watchOS] Requesting data from phone")
session.sendMessage(["request": "data"]) { [weak self] response in
guard let self = self else { return }
print("[watchOS] Received reply: \(response)")
DispatchQueue.main.async {
if let token = response["token"] as? String {
self.token = token
self.userDefaults.set(token, forKey: self.tokenKey)
}
if let serverUrl = response["serverUrl"] as? String {
self.serverUrl = serverUrl
self.userDefaults.set(serverUrl, forKey: self.serverUrlKey)
}
}
} errorHandler: { error in
print("[watchOS] sendMessage failed with error: \(error.localizedDescription)")
}
}
}

View File

@@ -0,0 +1,10 @@
<?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>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>

View File

@@ -19,8 +19,8 @@ sealed class SnNotableDay with _$SnNotableDay {
}
@freezed
sealed class SnActivity with _$SnActivity {
const factory SnActivity({
sealed class SnTimelineEvent with _$SnTimelineEvent {
const factory SnTimelineEvent({
required String id,
required String type,
required String resourceIdentifier,
@@ -28,10 +28,10 @@ sealed class SnActivity with _$SnActivity {
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnActivity;
}) = _SnTimelineEvent;
factory SnActivity.fromJson(Map<String, dynamic> json) =>
_$SnActivityFromJson(json);
factory SnTimelineEvent.fromJson(Map<String, dynamic> json) =>
_$SnTimelineEventFromJson(json);
}
@freezed
@@ -74,3 +74,29 @@ sealed class SnEventCalendarEntry with _$SnEventCalendarEntry {
factory SnEventCalendarEntry.fromJson(Map<String, dynamic> json) =>
_$SnEventCalendarEntryFromJson(json);
}
@freezed
sealed class SnPresenceActivity with _$SnPresenceActivity {
const factory SnPresenceActivity({
required String id,
required int type,
required String? manualId,
required String? title,
required String? subtitle,
required String? caption,
required String? titleUrl,
required String? subtitleUrl,
required String? smallImage,
required String? largeImage,
required Map<String, dynamic>? meta,
required int leaseMinutes,
required DateTime leaseExpiresAt,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnPresenceActivity;
factory SnPresenceActivity.fromJson(Map<String, dynamic> json) =>
_$SnPresenceActivityFromJson(json);
}

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