🔀 Merge pull request '添加 Solian for Apple Watch' (#8) from features/watchos-app into v3
Reviewed-on: #8
This commit is contained in:
		
							
								
								
									
										15
									
								
								ios/Podfile
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								ios/Podfile
									
									
									
									
									
								
							| @@ -1,6 +1,3 @@ | |||||||
| # Uncomment this line to define a global platform for your project |  | ||||||
| platform :ios, '15.0' |  | ||||||
|  |  | ||||||
| # CocoaPods analytics sends network stats synchronously affecting flutter build latency. | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. | ||||||
| ENV['COCOAPODS_DISABLE_STATS'] = 'true' | ENV['COCOAPODS_DISABLE_STATS'] = 'true' | ||||||
|  |  | ||||||
| @@ -28,6 +25,8 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe | |||||||
| flutter_ios_podfile_setup | flutter_ios_podfile_setup | ||||||
|  |  | ||||||
| target 'Runner' do | target 'Runner' do | ||||||
|  |   platform :ios, '15.0' | ||||||
|  |  | ||||||
|   use_frameworks! |   use_frameworks! | ||||||
|   use_modular_headers! |   use_modular_headers! | ||||||
|  |  | ||||||
| @@ -50,6 +49,16 @@ target 'Runner' do | |||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | target 'WatchRunner Watch App' do | ||||||
|  |   platform :watchos, '11.0' | ||||||
|  |  | ||||||
|  |   use_frameworks! | ||||||
|  |   use_modular_headers! | ||||||
|  |  | ||||||
|  |   pod 'Kingfisher', '~> 8.0' | ||||||
|  |   pod 'KingfisherWebP' | ||||||
|  | end | ||||||
|  |  | ||||||
| post_install do |installer| | post_install do |installer| | ||||||
|   installer.pods_project.targets.each do |target| |   installer.pods_project.targets.each do |target| | ||||||
|     flutter_additional_ios_build_settings(target) |     flutter_additional_ios_build_settings(target) | ||||||
|   | |||||||
| @@ -219,6 +219,21 @@ PODS: | |||||||
|   - irondash_engine_context (0.0.1): |   - irondash_engine_context (0.0.1): | ||||||
|     - Flutter |     - Flutter | ||||||
|   - Kingfisher (8.6.0) |   - Kingfisher (8.6.0) | ||||||
|  |   - KingfisherWebP (1.7.2): | ||||||
|  |     - Kingfisher (~> 8.0) | ||||||
|  |     - libwebp (>= 1.1.0) | ||||||
|  |   - libwebp (1.5.0): | ||||||
|  |     - libwebp/demux (= 1.5.0) | ||||||
|  |     - libwebp/mux (= 1.5.0) | ||||||
|  |     - libwebp/sharpyuv (= 1.5.0) | ||||||
|  |     - libwebp/webp (= 1.5.0) | ||||||
|  |   - libwebp/demux (1.5.0): | ||||||
|  |     - libwebp/webp | ||||||
|  |   - libwebp/mux (1.5.0): | ||||||
|  |     - libwebp/demux | ||||||
|  |   - libwebp/sharpyuv (1.5.0) | ||||||
|  |   - libwebp/webp (1.5.0): | ||||||
|  |     - libwebp/sharpyuv | ||||||
|   - livekit_client (2.5.3): |   - livekit_client (2.5.3): | ||||||
|     - Flutter |     - Flutter | ||||||
|     - flutter_webrtc |     - flutter_webrtc | ||||||
| @@ -333,6 +348,7 @@ DEPENDENCIES: | |||||||
|   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) |   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) | ||||||
|   - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) |   - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) | ||||||
|   - Kingfisher (~> 8.0) |   - Kingfisher (~> 8.0) | ||||||
|  |   - KingfisherWebP | ||||||
|   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) |   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) | ||||||
|   - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) |   - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) | ||||||
|   - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) |   - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) | ||||||
| @@ -375,6 +391,8 @@ SPEC REPOS: | |||||||
|     - GoogleDataTransport |     - GoogleDataTransport | ||||||
|     - GoogleUtilities |     - GoogleUtilities | ||||||
|     - Kingfisher |     - Kingfisher | ||||||
|  |     - KingfisherWebP | ||||||
|  |     - libwebp | ||||||
|     - nanopb |     - nanopb | ||||||
|     - OrderedSet |     - OrderedSet | ||||||
|     - PromisesObjC |     - PromisesObjC | ||||||
| @@ -520,6 +538,8 @@ SPEC CHECKSUMS: | |||||||
|   image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 |   image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 | ||||||
|   irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 |   irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 | ||||||
|   Kingfisher: 64278f126a815d0e2d391cdf71311b85882c4de0 |   Kingfisher: 64278f126a815d0e2d391cdf71311b85882c4de0 | ||||||
|  |   KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402 | ||||||
|  |   libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 | ||||||
|   livekit_client: 86c8af579274e4b7a215185a8080db2d4e176f40 |   livekit_client: 86c8af579274e4b7a215185a8080db2d4e176f40 | ||||||
|   local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb |   local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb | ||||||
|   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 |   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 | ||||||
| @@ -551,6 +571,6 @@ SPEC CHECKSUMS: | |||||||
|   wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 |   wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 | ||||||
|   WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e |   WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e | ||||||
|  |  | ||||||
| PODFILE CHECKSUM: c818292390b02fa379036ea099713a332bd7193f | PODFILE CHECKSUM: 3096dc559be56aca856e757e1dc65ca039801e2e | ||||||
|  |  | ||||||
| COCOAPODS: 1.16.2 | COCOAPODS: 1.16.2 | ||||||
|   | |||||||
| @@ -3,13 +3,14 @@ | |||||||
| 	archiveVersion = 1; | 	archiveVersion = 1; | ||||||
| 	classes = { | 	classes = { | ||||||
| 	}; | 	}; | ||||||
| 	objectVersion = 54; | 	objectVersion = 77; | ||||||
| 	objects = { | 	objects = { | ||||||
|  |  | ||||||
| /* Begin PBXBuildFile section */ | /* Begin PBXBuildFile section */ | ||||||
| 		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; | 		1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; | ||||||
| 		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; | 		331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; | ||||||
| 		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; | 		3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; | ||||||
|  | 		7310A7DF2EB10963002C0FD3 /* WatchRunner Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||||||
| 		73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; }; | 		73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; }; | ||||||
| 		73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | 		73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||||||
| 		73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | 		73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; | ||||||
| @@ -20,6 +21,7 @@ | |||||||
| 		97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; | 		97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; | ||||||
| 		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; | 		97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; | ||||||
| 		97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; | 		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 */; }; | 		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 */; }; | 		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 */; }; | 		D1772CE196985AE8E8C9F2E5 /* Pods_SolianNotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */; }; | ||||||
| @@ -58,6 +60,17 @@ | |||||||
| /* End PBXContainerItemProxy section */ | /* End PBXContainerItemProxy section */ | ||||||
|  |  | ||||||
| /* Begin PBXCopyFilesBuildPhase section */ | /* Begin PBXCopyFilesBuildPhase section */ | ||||||
|  | 		7310A7DE2EB10963002C0FD3 /* Embed Watch Content */ = { | ||||||
|  | 			isa = PBXCopyFilesBuildPhase; | ||||||
|  | 			buildActionMask = 2147483647; | ||||||
|  | 			dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; | ||||||
|  | 			dstSubfolderSpec = 16; | ||||||
|  | 			files = ( | ||||||
|  | 				7310A7DF2EB10963002C0FD3 /* WatchRunner Watch App.app in Embed Watch Content */, | ||||||
|  | 			); | ||||||
|  | 			name = "Embed Watch Content"; | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 		}; | ||||||
| 		73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */ = { | 		73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */ = { | ||||||
| 			isa = PBXCopyFilesBuildPhase; | 			isa = PBXCopyFilesBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -84,6 +97,7 @@ | |||||||
| /* End PBXCopyFilesBuildPhase section */ | /* End PBXCopyFilesBuildPhase section */ | ||||||
|  |  | ||||||
| /* Begin PBXFileReference section */ | /* Begin PBXFileReference section */ | ||||||
|  | 		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>"; }; | 		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>"; }; | 		1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; | ||||||
| 		1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; | 		1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; | ||||||
| @@ -100,6 +114,7 @@ | |||||||
| 		39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolianNotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; | 		39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SolianNotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||||
| 		3A1C47BD29CC6AC2587D4DBE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; | 		3A1C47BD29CC6AC2587D4DBE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; | ||||||
| 		3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; | 		3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; | ||||||
|  | 		7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "WatchRunner Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||||
| 		737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; | 		737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; | ||||||
| 		73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianBroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; | 		73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianBroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||||
| 		73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; }; | 		73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; }; | ||||||
| @@ -111,6 +126,8 @@ | |||||||
| 		74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; | 		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>"; }; | 		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; }; | 		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>"; }; | 		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>"; }; | 		9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; | ||||||
| 		9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; | 		9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; | ||||||
| @@ -120,6 +137,7 @@ | |||||||
| 		97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; | 		97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; | ||||||
| 		97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; | 		97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; | ||||||
| 		9AE244813FCDFAA941430393 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; }; | 		9AE244813FCDFAA941430393 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; }; | ||||||
|  | 		A2EB1DAFDE9B8E6D88BBF7A3 /* Pods-WatchRunner Watch App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WatchRunner Watch App.release.xcconfig"; path = "Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App.release.xcconfig"; sourceTree = "<group>"; }; | ||||||
| 		A499FDB2082EB000933AA8C5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; | 		A499FDB2082EB000933AA8C5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; | ||||||
| 		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>"; }; | 		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; }; | 		AA0CA8A3E15DEE023BB27438 /* Pods_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||||
| @@ -130,6 +148,13 @@ | |||||||
| /* End PBXFileReference section */ | /* End PBXFileReference section */ | ||||||
|  |  | ||||||
| /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | ||||||
|  | 		7355265E2EB3A8870013AFE4 /* Exceptions for "WatchRunner Watch App" folder in "WatchRunner Watch App" target */ = { | ||||||
|  | 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | ||||||
|  | 			membershipExceptions = ( | ||||||
|  | 				"WatchRunner-Watch-App-Info.plist", | ||||||
|  | 			); | ||||||
|  | 			target = 7310A7D32EB10962002C0FD3 /* WatchRunner Watch App */; | ||||||
|  | 		}; | ||||||
| 		73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */ = { | 		73ACDFCA2E3D0E6100B63535 /* Exceptions for "SolianBroadcastExtension" folder in "SolianBroadcastExtension" target */ = { | ||||||
| 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | ||||||
| 			membershipExceptions = ( | 			membershipExceptions = ( | ||||||
| @@ -162,6 +187,14 @@ | |||||||
| /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ | ||||||
|  |  | ||||||
| /* Begin PBXFileSystemSynchronizedRootGroup section */ | /* Begin PBXFileSystemSynchronizedRootGroup section */ | ||||||
|  | 		7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */ = { | ||||||
|  | 			isa = PBXFileSystemSynchronizedRootGroup; | ||||||
|  | 			exceptions = ( | ||||||
|  | 				7355265E2EB3A8870013AFE4 /* Exceptions for "WatchRunner Watch App" folder in "WatchRunner Watch App" target */, | ||||||
|  | 			); | ||||||
|  | 			path = "WatchRunner Watch App"; | ||||||
|  | 			sourceTree = "<group>"; | ||||||
|  | 		}; | ||||||
| 		73268D272DEB012A0076E970 /* Services */ = { | 		73268D272DEB012A0076E970 /* Services */ = { | ||||||
| 			isa = PBXFileSystemSynchronizedRootGroup; | 			isa = PBXFileSystemSynchronizedRootGroup; | ||||||
| 			exceptions = ( | 			exceptions = ( | ||||||
| @@ -205,6 +238,14 @@ | |||||||
| 			); | 			); | ||||||
| 			runOnlyForDeploymentPostprocessing = 0; | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7D12EB10962002C0FD3 /* Frameworks */ = { | ||||||
|  | 			isa = PBXFrameworksBuildPhase; | ||||||
|  | 			buildActionMask = 2147483647; | ||||||
|  | 			files = ( | ||||||
|  | 				A1D34487886D362AC8B99B2E /* Pods_WatchRunner_Watch_App.framework in Frameworks */, | ||||||
|  | 			); | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 		}; | ||||||
| 		73ACDFA82E3D0E6100B63535 /* Frameworks */ = { | 		73ACDFA82E3D0E6100B63535 /* Frameworks */ = { | ||||||
| 			isa = PBXFrameworksBuildPhase; | 			isa = PBXFrameworksBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -258,6 +299,7 @@ | |||||||
| 				7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */, | 				7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */, | ||||||
| 				73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */, | 				73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */, | ||||||
| 				73ACDFB82E3D0E6100B63535 /* UIKit.framework */, | 				73ACDFB82E3D0E6100B63535 /* UIKit.framework */, | ||||||
|  | 				802C1CFCA7F1E069AAEFB454 /* Pods_WatchRunner_Watch_App.framework */, | ||||||
| 			); | 			); | ||||||
| 			name = Frameworks; | 			name = Frameworks; | ||||||
| 			sourceTree = "<group>"; | 			sourceTree = "<group>"; | ||||||
| @@ -280,6 +322,9 @@ | |||||||
| 				17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */, | 				17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */, | ||||||
| 				27C66EFB5A705F1A822C3EB0 /* Pods-SolianShareExtension.release.xcconfig */, | 				27C66EFB5A705F1A822C3EB0 /* Pods-SolianShareExtension.release.xcconfig */, | ||||||
| 				A85FF612AE7623A9934E57CE /* Pods-SolianShareExtension.profile.xcconfig */, | 				A85FF612AE7623A9934E57CE /* Pods-SolianShareExtension.profile.xcconfig */, | ||||||
|  | 				86D60BA96DA647E1B11AA7F0 /* Pods-WatchRunner Watch App.debug.xcconfig */, | ||||||
|  | 				A2EB1DAFDE9B8E6D88BBF7A3 /* Pods-WatchRunner Watch App.release.xcconfig */, | ||||||
|  | 				103EA2362B9E9F127016A1F1 /* Pods-WatchRunner Watch App.profile.xcconfig */, | ||||||
| 			); | 			); | ||||||
| 			path = Pods; | 			path = Pods; | ||||||
| 			sourceTree = "<group>"; | 			sourceTree = "<group>"; | ||||||
| @@ -303,6 +348,7 @@ | |||||||
| 				73CDD67B2DEC00480059D95D /* SolianNotificationService */, | 				73CDD67B2DEC00480059D95D /* SolianNotificationService */, | ||||||
| 				73C305CF2E0BE878009035B9 /* SolianShareExtension */, | 				73C305CF2E0BE878009035B9 /* SolianShareExtension */, | ||||||
| 				73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */, | 				73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */, | ||||||
|  | 				7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */, | ||||||
| 				97C146EF1CF9000F007C117D /* Products */, | 				97C146EF1CF9000F007C117D /* Products */, | ||||||
| 				331C8082294A63A400263BE5 /* RunnerTests */, | 				331C8082294A63A400263BE5 /* RunnerTests */, | ||||||
| 				91E124CE95BCB4DCD890160D /* Pods */, | 				91E124CE95BCB4DCD890160D /* Pods */, | ||||||
| @@ -319,6 +365,7 @@ | |||||||
| 				73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */, | 				73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */, | ||||||
| 				73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */, | 				73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */, | ||||||
| 				73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */, | 				73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */, | ||||||
|  | 				7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */, | ||||||
| 			); | 			); | ||||||
| 			name = Products; | 			name = Products; | ||||||
| 			sourceTree = "<group>"; | 			sourceTree = "<group>"; | ||||||
| @@ -363,6 +410,28 @@ | |||||||
| 			productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; | 			productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; | ||||||
| 			productType = "com.apple.product-type.bundle.unit-test"; | 			productType = "com.apple.product-type.bundle.unit-test"; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7D32EB10962002C0FD3 /* WatchRunner Watch App */ = { | ||||||
|  | 			isa = PBXNativeTarget; | ||||||
|  | 			buildConfigurationList = 7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "WatchRunner Watch App" */; | ||||||
|  | 			buildPhases = ( | ||||||
|  | 				DDEDA1BA6278B94F0F7B9B61 /* [CP] Check Pods Manifest.lock */, | ||||||
|  | 				7310A7D02EB10962002C0FD3 /* Sources */, | ||||||
|  | 				7310A7D12EB10962002C0FD3 /* Frameworks */, | ||||||
|  | 				7310A7D22EB10962002C0FD3 /* Resources */, | ||||||
|  | 				C74B07D6587D29C67A198025 /* [CP] Embed Pods Frameworks */, | ||||||
|  | 			); | ||||||
|  | 			buildRules = ( | ||||||
|  | 			); | ||||||
|  | 			dependencies = ( | ||||||
|  | 			); | ||||||
|  | 			fileSystemSynchronizedGroups = ( | ||||||
|  | 				7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */, | ||||||
|  | 			); | ||||||
|  | 			name = "WatchRunner Watch App"; | ||||||
|  | 			productName = "WatchRunner Watch App"; | ||||||
|  | 			productReference = 7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */; | ||||||
|  | 			productType = "com.apple.product-type.application"; | ||||||
|  | 		}; | ||||||
| 		73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */ = { | 		73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */ = { | ||||||
| 			isa = PBXNativeTarget; | 			isa = PBXNativeTarget; | ||||||
| 			buildConfigurationList = 73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */; | 			buildConfigurationList = 73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */; | ||||||
| @@ -434,6 +503,7 @@ | |||||||
| 				97C146EA1CF9000F007C117D /* Sources */, | 				97C146EA1CF9000F007C117D /* Sources */, | ||||||
| 				97C146EB1CF9000F007C117D /* Frameworks */, | 				97C146EB1CF9000F007C117D /* Frameworks */, | ||||||
| 				73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */, | 				73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */, | ||||||
|  | 				7310A7DE2EB10963002C0FD3 /* Embed Watch Content */, | ||||||
| 				97C146EC1CF9000F007C117D /* Resources */, | 				97C146EC1CF9000F007C117D /* Resources */, | ||||||
| 				9705A1C41CF9048500538489 /* Embed Frameworks */, | 				9705A1C41CF9048500538489 /* Embed Frameworks */, | ||||||
| 				3B06AD1E1E4923F5004D2608 /* Thin Binary */, | 				3B06AD1E1E4923F5004D2608 /* Thin Binary */, | ||||||
| @@ -463,7 +533,7 @@ | |||||||
| 			isa = PBXProject; | 			isa = PBXProject; | ||||||
| 			attributes = { | 			attributes = { | ||||||
| 				BuildIndependentTargetsInParallel = YES; | 				BuildIndependentTargetsInParallel = YES; | ||||||
| 				LastSwiftUpdateCheck = 1640; | 				LastSwiftUpdateCheck = 2600; | ||||||
| 				LastUpgradeCheck = 1510; | 				LastUpgradeCheck = 1510; | ||||||
| 				ORGANIZATIONNAME = ""; | 				ORGANIZATIONNAME = ""; | ||||||
| 				TargetAttributes = { | 				TargetAttributes = { | ||||||
| @@ -471,6 +541,9 @@ | |||||||
| 						CreatedOnToolsVersion = 14.0; | 						CreatedOnToolsVersion = 14.0; | ||||||
| 						TestTargetID = 97C146ED1CF9000F007C117D; | 						TestTargetID = 97C146ED1CF9000F007C117D; | ||||||
| 					}; | 					}; | ||||||
|  | 					7310A7D32EB10962002C0FD3 = { | ||||||
|  | 						CreatedOnToolsVersion = 26.0.1; | ||||||
|  | 					}; | ||||||
| 					73ACDFAA2E3D0E6100B63535 = { | 					73ACDFAA2E3D0E6100B63535 = { | ||||||
| 						CreatedOnToolsVersion = 16.4; | 						CreatedOnToolsVersion = 16.4; | ||||||
| 					}; | 					}; | ||||||
| @@ -504,6 +577,7 @@ | |||||||
| 				73CDD6792DEC00480059D95D /* SolianNotificationService */, | 				73CDD6792DEC00480059D95D /* SolianNotificationService */, | ||||||
| 				73C305CD2E0BE878009035B9 /* SolianShareExtension */, | 				73C305CD2E0BE878009035B9 /* SolianShareExtension */, | ||||||
| 				73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */, | 				73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */, | ||||||
|  | 				7310A7D32EB10962002C0FD3 /* WatchRunner Watch App */, | ||||||
| 			); | 			); | ||||||
| 		}; | 		}; | ||||||
| /* End PBXProject section */ | /* End PBXProject section */ | ||||||
| @@ -516,6 +590,13 @@ | |||||||
| 			); | 			); | ||||||
| 			runOnlyForDeploymentPostprocessing = 0; | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7D22EB10962002C0FD3 /* Resources */ = { | ||||||
|  | 			isa = PBXResourcesBuildPhase; | ||||||
|  | 			buildActionMask = 2147483647; | ||||||
|  | 			files = ( | ||||||
|  | 			); | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 		}; | ||||||
| 		73ACDFA92E3D0E6100B63535 /* Resources */ = { | 		73ACDFA92E3D0E6100B63535 /* Resources */ = { | ||||||
| 			isa = PBXResourcesBuildPhase; | 			isa = PBXResourcesBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -683,6 +764,45 @@ | |||||||
| 			shellPath = /bin/sh; | 			shellPath = /bin/sh; | ||||||
| 			shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; | 			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; | ||||||
|  | 			files = ( | ||||||
|  | 			); | ||||||
|  | 			inputFileListPaths = ( | ||||||
|  | 			); | ||||||
|  | 			inputPaths = ( | ||||||
|  | 				"${PODS_PODFILE_DIR_PATH}/Podfile.lock", | ||||||
|  | 				"${PODS_ROOT}/Manifest.lock", | ||||||
|  | 			); | ||||||
|  | 			name = "[CP] Check Pods Manifest.lock"; | ||||||
|  | 			outputFileListPaths = ( | ||||||
|  | 			); | ||||||
|  | 			outputPaths = ( | ||||||
|  | 				"$(DERIVED_FILE_DIR)/Pods-WatchRunner 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; | ||||||
|  | 		}; | ||||||
| 		E86CDE9D6464F4F52B910856 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { | 		E86CDE9D6464F4F52B910856 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = { | ||||||
| 			isa = PBXShellScriptBuildPhase; | 			isa = PBXShellScriptBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -734,6 +854,13 @@ | |||||||
| 			); | 			); | ||||||
| 			runOnlyForDeploymentPostprocessing = 0; | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7D02EB10962002C0FD3 /* Sources */ = { | ||||||
|  | 			isa = PBXSourcesBuildPhase; | ||||||
|  | 			buildActionMask = 2147483647; | ||||||
|  | 			files = ( | ||||||
|  | 			); | ||||||
|  | 			runOnlyForDeploymentPostprocessing = 0; | ||||||
|  | 		}; | ||||||
| 		73ACDFA72E3D0E6100B63535 /* Sources */ = { | 		73ACDFA72E3D0E6100B63535 /* Sources */ = { | ||||||
| 			isa = PBXSourcesBuildPhase; | 			isa = PBXSourcesBuildPhase; | ||||||
| 			buildActionMask = 2147483647; | 			buildActionMask = 2147483647; | ||||||
| @@ -943,6 +1070,147 @@ | |||||||
| 			}; | 			}; | ||||||
| 			name = Profile; | 			name = Profile; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7E02EB10963002C0FD3 /* Debug */ = { | ||||||
|  | 			isa = XCBuildConfiguration; | ||||||
|  | 			baseConfigurationReference = 86D60BA96DA647E1B11AA7F0 /* Pods-WatchRunner Watch App.debug.xcconfig */; | ||||||
|  | 			buildSettings = { | ||||||
|  | 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||||
|  | 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | ||||||
|  | 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | ||||||
|  | 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | ||||||
|  | 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | ||||||
|  | 				CLANG_ENABLE_OBJC_WEAK = YES; | ||||||
|  | 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | ||||||
|  | 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | ||||||
|  | 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||||||
|  | 				CODE_SIGN_STYLE = Automatic; | ||||||
|  | 				CURRENT_PROJECT_VERSION = 1; | ||||||
|  | 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||||
|  | 				ENABLE_PREVIEWS = YES; | ||||||
|  | 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | ||||||
|  | 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||||
|  | 				GENERATE_INFOPLIST_FILE = YES; | ||||||
|  | 				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; | ||||||
|  | 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | ||||||
|  | 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | ||||||
|  | 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | ||||||
|  | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
|  | 					"$(inherited)", | ||||||
|  | 					"@executable_path/Frameworks", | ||||||
|  | 				); | ||||||
|  | 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | ||||||
|  | 				MARKETING_VERSION = 1.0; | ||||||
|  | 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; | ||||||
|  | 				MTL_FAST_MATH = YES; | ||||||
|  | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.watchkitapp; | ||||||
|  | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
|  | 				SDKROOT = watchos; | ||||||
|  | 				SKIP_INSTALL = YES; | ||||||
|  | 				STRING_CATALOG_GENERATE_SYMBOLS = YES; | ||||||
|  | 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; | ||||||
|  | 				SWIFT_APPROACHABLE_CONCURRENCY = YES; | ||||||
|  | 				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; | ||||||
|  | 				SWIFT_EMIT_LOC_STRINGS = YES; | ||||||
|  | 				SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | ||||||
|  | 				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | ||||||
|  | 				SWIFT_VERSION = 5.0; | ||||||
|  | 				TARGETED_DEVICE_FAMILY = 4; | ||||||
|  | 				WATCHOS_DEPLOYMENT_TARGET = 26.0; | ||||||
|  | 			}; | ||||||
|  | 			name = Debug; | ||||||
|  | 		}; | ||||||
|  | 		7310A7E12EB10963002C0FD3 /* Release */ = { | ||||||
|  | 			isa = XCBuildConfiguration; | ||||||
|  | 			baseConfigurationReference = A2EB1DAFDE9B8E6D88BBF7A3 /* Pods-WatchRunner Watch App.release.xcconfig */; | ||||||
|  | 			buildSettings = { | ||||||
|  | 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||||
|  | 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | ||||||
|  | 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | ||||||
|  | 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | ||||||
|  | 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | ||||||
|  | 				CLANG_ENABLE_OBJC_WEAK = YES; | ||||||
|  | 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | ||||||
|  | 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | ||||||
|  | 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||||||
|  | 				CODE_SIGN_STYLE = Automatic; | ||||||
|  | 				CURRENT_PROJECT_VERSION = 1; | ||||||
|  | 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||||
|  | 				ENABLE_PREVIEWS = YES; | ||||||
|  | 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | ||||||
|  | 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||||
|  | 				GENERATE_INFOPLIST_FILE = YES; | ||||||
|  | 				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; | ||||||
|  | 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | ||||||
|  | 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | ||||||
|  | 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | ||||||
|  | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
|  | 					"$(inherited)", | ||||||
|  | 					"@executable_path/Frameworks", | ||||||
|  | 				); | ||||||
|  | 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | ||||||
|  | 				MARKETING_VERSION = 1.0; | ||||||
|  | 				MTL_FAST_MATH = YES; | ||||||
|  | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.watchkitapp; | ||||||
|  | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
|  | 				SDKROOT = watchos; | ||||||
|  | 				SKIP_INSTALL = YES; | ||||||
|  | 				STRING_CATALOG_GENERATE_SYMBOLS = YES; | ||||||
|  | 				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; | ||||||
|  | 			}; | ||||||
|  | 			name = Release; | ||||||
|  | 		}; | ||||||
|  | 		7310A7E22EB10963002C0FD3 /* Profile */ = { | ||||||
|  | 			isa = XCBuildConfiguration; | ||||||
|  | 			baseConfigurationReference = 103EA2362B9E9F127016A1F1 /* Pods-WatchRunner Watch App.profile.xcconfig */; | ||||||
|  | 			buildSettings = { | ||||||
|  | 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||||
|  | 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | ||||||
|  | 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | ||||||
|  | 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | ||||||
|  | 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | ||||||
|  | 				CLANG_ENABLE_OBJC_WEAK = YES; | ||||||
|  | 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | ||||||
|  | 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | ||||||
|  | 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | ||||||
|  | 				CODE_SIGN_STYLE = Automatic; | ||||||
|  | 				CURRENT_PROJECT_VERSION = 1; | ||||||
|  | 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||||
|  | 				ENABLE_PREVIEWS = YES; | ||||||
|  | 				ENABLE_USER_SCRIPT_SANDBOXING = NO; | ||||||
|  | 				GCC_C_LANGUAGE_STANDARD = gnu17; | ||||||
|  | 				GENERATE_INFOPLIST_FILE = YES; | ||||||
|  | 				INFOPLIST_FILE = "WatchRunner-Watch-App-Info.plist"; | ||||||
|  | 				INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; | ||||||
|  | 				INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; | ||||||
|  | 				INFOPLIST_KEY_WKCompanionAppBundleIdentifier = dev.solsynth.solian; | ||||||
|  | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
|  | 					"$(inherited)", | ||||||
|  | 					"@executable_path/Frameworks", | ||||||
|  | 				); | ||||||
|  | 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | ||||||
|  | 				MARKETING_VERSION = 1.0; | ||||||
|  | 				MTL_FAST_MATH = YES; | ||||||
|  | 				PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.solian.watchkitapp; | ||||||
|  | 				PRODUCT_NAME = "$(TARGET_NAME)"; | ||||||
|  | 				SDKROOT = watchos; | ||||||
|  | 				SKIP_INSTALL = YES; | ||||||
|  | 				STRING_CATALOG_GENERATE_SYMBOLS = YES; | ||||||
|  | 				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; | ||||||
|  | 			}; | ||||||
|  | 			name = Profile; | ||||||
|  | 		}; | ||||||
| 		73ACDFC42E3D0E6100B63535 /* Debug */ = { | 		73ACDFC42E3D0E6100B63535 /* Debug */ = { | ||||||
| 			isa = XCBuildConfiguration; | 			isa = XCBuildConfiguration; | ||||||
| 			buildSettings = { | 			buildSettings = { | ||||||
| @@ -1487,6 +1755,16 @@ | |||||||
| 			defaultConfigurationIsVisible = 0; | 			defaultConfigurationIsVisible = 0; | ||||||
| 			defaultConfigurationName = Release; | 			defaultConfigurationName = Release; | ||||||
| 		}; | 		}; | ||||||
|  | 		7310A7E32EB10963002C0FD3 /* Build configuration list for PBXNativeTarget "WatchRunner Watch App" */ = { | ||||||
|  | 			isa = XCConfigurationList; | ||||||
|  | 			buildConfigurations = ( | ||||||
|  | 				7310A7E02EB10963002C0FD3 /* Debug */, | ||||||
|  | 				7310A7E12EB10963002C0FD3 /* Release */, | ||||||
|  | 				7310A7E22EB10963002C0FD3 /* Profile */, | ||||||
|  | 			); | ||||||
|  | 			defaultConfigurationIsVisible = 0; | ||||||
|  | 			defaultConfigurationName = Release; | ||||||
|  | 		}; | ||||||
| 		73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */ = { | 		73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */ = { | ||||||
| 			isa = XCConfigurationList; | 			isa = XCConfigurationList; | ||||||
| 			buildConfigurations = ( | 			buildConfigurations = ( | ||||||
|   | |||||||
| @@ -1,9 +1,11 @@ | |||||||
| import Flutter | import Flutter | ||||||
| import UIKit | import UIKit | ||||||
|  | import WatchConnectivity | ||||||
|  |  | ||||||
| @main | @main | ||||||
| @objc class AppDelegate: FlutterAppDelegate { | @objc class AppDelegate: FlutterAppDelegate { | ||||||
|     let notifyDelegate = NotifyDelegate() |     let notifyDelegate = NotifyDelegate() | ||||||
|  |     private var watchConnectivityService: WatchConnectivityService? | ||||||
|      |      | ||||||
|     override func application( |     override func application( | ||||||
|         _ application: UIApplication, |         _ application: UIApplication, | ||||||
| @@ -28,6 +30,55 @@ import UIKit | |||||||
|          |          | ||||||
|         GeneratedPluginRegistrant.register(with: self) |         GeneratedPluginRegistrant.register(with: self) | ||||||
|          |          | ||||||
|  |         if WCSession.isSupported() { | ||||||
|  |             watchConnectivityService = WatchConnectivityService() | ||||||
|  |         } | ||||||
|  |          | ||||||
|         return super.application(application, didFinishLaunchingWithOptions: launchOptions) |         return super.application(application, didFinishLaunchingWithOptions: launchOptions) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class WatchConnectivityService: NSObject, WCSessionDelegate { | ||||||
|  |     private let session: WCSession | ||||||
|  |  | ||||||
|  |     override init() { | ||||||
|  |         self.session = .default | ||||||
|  |         super.init() | ||||||
|  |         print("[iOS] Activating WCSession") | ||||||
|  |         self.session.delegate = self | ||||||
|  |         self.session.activate() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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 activated with state: \(activationState.rawValue)") | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     func sessionDidBecomeInactive(_ session: WCSession) {} | ||||||
|  |      | ||||||
|  |     func sessionDidDeactivate(_ session: WCSession) { | ||||||
|  |         session.activate() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { | ||||||
|  |         print("[iOS] Received message: \(message)") | ||||||
|  |         if let request = message["request"] as? String, request == "data" { | ||||||
|  |             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] | ||||||
|  |             if let token = token { | ||||||
|  |                 data["token"] = token | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             print("[iOS] Replying with data: \(data)") | ||||||
|  |             replyHandler(data) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -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 | 
| @@ -0,0 +1,15 @@ | |||||||
|  | { | ||||||
|  |   "colors" : [ | ||||||
|  |     { | ||||||
|  |       "color" : { | ||||||
|  |         "platform" : "universal", | ||||||
|  |         "reference" : "systemIndigoColor" | ||||||
|  |       }, | ||||||
|  |       "idiom" : "universal" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,14 @@ | |||||||
|  | { | ||||||
|  |   "images" : [ | ||||||
|  |     { | ||||||
|  |       "filename" : "Icon-App-1024x1024@1x.png", | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "platform" : "watchos", | ||||||
|  |       "size" : "1024x1024" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 48 KiB | 
							
								
								
									
										6
									
								
								ios/WatchRunner Watch App/Assets.xcassets/Contents.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								ios/WatchRunner Watch App/Assets.xcassets/Contents.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | { | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										21
									
								
								ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "images" : [ | ||||||
|  |     { | ||||||
|  |       "filename" : "icon.png", | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "1x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "2x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "3x" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/icon.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/icon.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 70 KiB | 
							
								
								
									
										50
									
								
								ios/WatchRunner Watch App/ContentView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								ios/WatchRunner Watch App/ContentView.swift
									
									
									
									
									
										Normal 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") | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										88
									
								
								ios/WatchRunner Watch App/Layouts/FlowLayout.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								ios/WatchRunner Watch App/Layouts/FlowLayout.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | |||||||
|  | // | ||||||
|  | //  FlowLayout.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | // MARK: - Custom Layouts | ||||||
|  |  | ||||||
|  | struct FlowLayout: Layout { | ||||||
|  |     var alignment: HorizontalAlignment = .leading | ||||||
|  |     var spacing: CGFloat = 10 | ||||||
|  |  | ||||||
|  |     func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { | ||||||
|  |         let containerWidth = proposal.width ?? 0 | ||||||
|  |         let sizes = subviews.map { $0.sizeThatFits(.unspecified) } | ||||||
|  |  | ||||||
|  |         var currentX: CGFloat = 0 | ||||||
|  |         var currentY: CGFloat = 0 | ||||||
|  |         var lineHeight: CGFloat = 0 | ||||||
|  |         var totalHeight: CGFloat = 0 | ||||||
|  |  | ||||||
|  |         for size in sizes { | ||||||
|  |             if currentX + size.width > containerWidth { | ||||||
|  |                 // New line | ||||||
|  |                 currentX = 0 | ||||||
|  |                 currentY += lineHeight + spacing | ||||||
|  |                 totalHeight = currentY + size.height | ||||||
|  |                 lineHeight = 0 | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             currentX += size.width + spacing | ||||||
|  |             lineHeight = max(lineHeight, size.height) | ||||||
|  |         } | ||||||
|  |         totalHeight = currentY + lineHeight | ||||||
|  |  | ||||||
|  |         return CGSize(width: containerWidth, height: totalHeight) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { | ||||||
|  |         let containerWidth = bounds.width | ||||||
|  |         let sizes = subviews.map { $0.sizeThatFits(.unspecified) } | ||||||
|  |  | ||||||
|  |         var currentX: CGFloat = 0 | ||||||
|  |         var currentY: CGFloat = 0 | ||||||
|  |         var lineHeight: CGFloat = 0 | ||||||
|  |         var lineElements: [(offset: Int, size: CGSize)] = [] | ||||||
|  |  | ||||||
|  |         func placeLine() { | ||||||
|  |             let lineWidth = lineElements.map { $0.size.width }.reduce(0, +) + CGFloat(lineElements.count - 1) * spacing | ||||||
|  |             var startX: CGFloat = 0 | ||||||
|  |             switch alignment { | ||||||
|  |             case .leading: | ||||||
|  |                 startX = bounds.minX | ||||||
|  |             case .center: | ||||||
|  |                 startX = bounds.minX + (containerWidth - lineWidth) / 2 | ||||||
|  |             case .trailing: | ||||||
|  |                 startX = bounds.maxX - lineWidth | ||||||
|  |             default: | ||||||
|  |                 startX = bounds.minX | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var xOffset = startX | ||||||
|  |             for (offset, size) in lineElements { | ||||||
|  |                 subviews[offset].place(at: CGPoint(x: xOffset, y: bounds.minY + currentY), proposal: ProposedViewSize(size)) // Use bounds.minY + currentY | ||||||
|  |                 xOffset += size.width + spacing | ||||||
|  |             } | ||||||
|  |             lineElements.removeAll() // Clear elements for the next line | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         for (offset, size) in sizes.enumerated() { | ||||||
|  |             if currentX + size.width > containerWidth && !lineElements.isEmpty { | ||||||
|  |                 // New line | ||||||
|  |                 placeLine() | ||||||
|  |                 currentX = 0 | ||||||
|  |                 currentY += lineHeight + spacing | ||||||
|  |                 lineHeight = 0 | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             lineElements.append((offset, size)) | ||||||
|  |             currentX += size.width + spacing | ||||||
|  |             lineHeight = max(lineHeight, size.height) | ||||||
|  |         } | ||||||
|  |         placeLine() // Place the last line | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										365
									
								
								ios/WatchRunner Watch App/Models/Models.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										365
									
								
								ios/WatchRunner Watch App/Models/Models.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,365 @@ | |||||||
|  | //  Models.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import Foundation | ||||||
|  |  | ||||||
|  | // MARK: - Models | ||||||
|  |  | ||||||
|  | struct AppToken: Codable { | ||||||
|  |     let token: String | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnActivity: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let type: String | ||||||
|  |     let data: ActivityData? | ||||||
|  |     let createdAt: Date | ||||||
|  | } | ||||||
|  |  | ||||||
|  | enum ActivityData: Codable { | ||||||
|  |     case post(SnPost) | ||||||
|  |     case discovery(DiscoveryData) | ||||||
|  |     case unknown | ||||||
|  |  | ||||||
|  |     init(from decoder: Decoder) throws { | ||||||
|  |         let container = try decoder.singleValueContainer() | ||||||
|  |         if let post = try? container.decode(SnPost.self) { | ||||||
|  |             self = .post(post) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         if let discoveryData = try? container.decode(DiscoveryData.self) { | ||||||
|  |             self = .discovery(discoveryData) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         self = .unknown | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func encode(to encoder: Encoder) throws { | ||||||
|  |         // Not needed for decoding | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnPost: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let title: String? | ||||||
|  |     let content: String? | ||||||
|  |     let publisher: SnPublisher | ||||||
|  |     let attachments: [SnCloudFile] | ||||||
|  |     let tags: [SnPostTag] | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct DiscoveryData: Codable { | ||||||
|  |     let items: [DiscoveryItem] | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct DiscoveryItem: Codable, Identifiable { | ||||||
|  |     var id = UUID() | ||||||
|  |     let type: String | ||||||
|  |     let data: DiscoveryItemData | ||||||
|  |  | ||||||
|  |     enum CodingKeys: String, CodingKey { | ||||||
|  |         case type, data | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | enum DiscoveryItemData: Codable { | ||||||
|  |     case realm(SnRealm) | ||||||
|  |     case publisher(SnPublisher) | ||||||
|  |     case article(SnWebArticle) | ||||||
|  |     case unknown | ||||||
|  |  | ||||||
|  |     init(from decoder: Decoder) throws { | ||||||
|  |         let container = try decoder.singleValueContainer() | ||||||
|  |         if let realm = try? container.decode(SnRealm.self) { | ||||||
|  |             self = .realm(realm) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         if let publisher = try? container.decode(SnPublisher.self) { | ||||||
|  |             self = .publisher(publisher) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         if let article = try? container.decode(SnWebArticle.self) { | ||||||
|  |             self = .article(article) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         self = .unknown | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func encode(to encoder: Encoder) throws { | ||||||
|  |         // Not needed for decoding | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnRealm: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let name: String | ||||||
|  |     let description: String? | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnPublisher: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let name: String | ||||||
|  |     let nick: String? | ||||||
|  |     let description: String? | ||||||
|  |     let picture: SnCloudFile? | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnCloudFile: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let mimeType: String? | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnPostTag: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let slug: String | ||||||
|  |     let name: String? | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnWebArticle: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let title: String | ||||||
|  |     let url: String | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct SnNotification: Codable, Identifiable { | ||||||
|  |     let id: String | ||||||
|  |     let topic: String | ||||||
|  |     let title: String | ||||||
|  |     let subtitle: String | ||||||
|  |     let content: String | ||||||
|  |     let meta: [String: AnyCodable]? | ||||||
|  |     let priority: Int | ||||||
|  |     let viewedAt: Date? | ||||||
|  |     let accountId: String | ||||||
|  |     let createdAt: Date | ||||||
|  |     let updatedAt: Date | ||||||
|  |     let deletedAt: Date? | ||||||
|  |  | ||||||
|  |     enum CodingKeys: String, CodingKey { | ||||||
|  |         case id | ||||||
|  |         case topic | ||||||
|  |         case title | ||||||
|  |         case subtitle | ||||||
|  |         case content | ||||||
|  |         case meta | ||||||
|  |         case priority | ||||||
|  |         case viewedAt = "viewedAt" | ||||||
|  |         case accountId = "accountId" | ||||||
|  |         case createdAt = "createdAt" | ||||||
|  |         case updatedAt = "updatedAt" | ||||||
|  |         case deletedAt = "deletedAt" | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct AnyCodable: Codable { | ||||||
|  |     let value: Any | ||||||
|  |  | ||||||
|  |     init(_ value: Any) { | ||||||
|  |         self.value = value | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     init(from decoder: Decoder) throws { | ||||||
|  |         let container = try decoder.singleValueContainer() | ||||||
|  |         if let intValue = try? container.decode(Int.self) { | ||||||
|  |             value = intValue | ||||||
|  |         } else if let doubleValue = try? container.decode(Double.self) { | ||||||
|  |             value = doubleValue | ||||||
|  |         } else if let boolValue = try? container.decode(Bool.self) { | ||||||
|  |             value = boolValue | ||||||
|  |         } else if let stringValue = try? container.decode(String.self) { | ||||||
|  |             value = stringValue | ||||||
|  |         } else if let arrayValue = try? container.decode([AnyCodable].self) { | ||||||
|  |             value = arrayValue | ||||||
|  |         } else if let dictValue = try? container.decode([String: AnyCodable].self) { | ||||||
|  |             value = dictValue | ||||||
|  |         } else { | ||||||
|  |             value = NSNull() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func encode(to encoder: Encoder) throws { | ||||||
|  |         var container = encoder.singleValueContainer() | ||||||
|  |         switch value { | ||||||
|  |         case let intValue as Int: | ||||||
|  |             try container.encode(intValue) | ||||||
|  |         case let doubleValue as Double: | ||||||
|  |             try container.encode(doubleValue) | ||||||
|  |         case let boolValue as Bool: | ||||||
|  |             try container.encode(boolValue) | ||||||
|  |         case let stringValue as String: | ||||||
|  |             try container.encode(stringValue) | ||||||
|  |         case let arrayValue as [AnyCodable]: | ||||||
|  |             try container.encode(arrayValue) | ||||||
|  |         case let dictValue as [String: AnyCodable]: | ||||||
|  |             try container.encode(dictValue) | ||||||
|  |         default: | ||||||
|  |             try container.encodeNil() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct NotificationResponse { | ||||||
|  |     let notifications: [SnNotification] | ||||||
|  |     let total: Int | ||||||
|  |     let hasMore: Bool | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct ActivityResponse { | ||||||
|  |     let activities: [SnActivity] | ||||||
|  |     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" | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								ios/WatchRunner Watch App/Previews/CustomPreviews.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								ios/WatchRunner Watch App/Previews/CustomPreviews.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | // | ||||||
|  | //  CustomPreviews.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     NavigationStack { | ||||||
|  |         ActivityListView(filter: "Preview", mockActivities: SnActivity.mock) | ||||||
|  |             .environmentObject(AppState()) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										35
									
								
								ios/WatchRunner Watch App/Previews/MockData.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								ios/WatchRunner Watch App/Previews/MockData.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | // | ||||||
|  | //  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 | ||||||
							
								
								
									
										95
									
								
								ios/WatchRunner Watch App/Services/ImageLoader.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								ios/WatchRunner Watch App/Services/ImageLoader.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | |||||||
|  | // | ||||||
|  | //  ImageLoader.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  | import Kingfisher | ||||||
|  | import KingfisherWebP | ||||||
|  | import Combine | ||||||
|  |  | ||||||
|  | // MARK: - Image Loader | ||||||
|  |  | ||||||
|  | @MainActor | ||||||
|  | class ImageLoader: ObservableObject { | ||||||
|  |     @Published var image: Image? | ||||||
|  |     @Published var errorMessage: String? | ||||||
|  |     @Published var isLoading = false | ||||||
|  |  | ||||||
|  |     private var currentTask: DownloadTask? | ||||||
|  |  | ||||||
|  |     init() {} | ||||||
|  |  | ||||||
|  |     deinit { | ||||||
|  |         currentTask?.cancel() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func loadImage(from initialUrl: URL, token: String) async { | ||||||
|  |         isLoading = true | ||||||
|  |         errorMessage = nil | ||||||
|  |         image = nil | ||||||
|  |  | ||||||
|  |         // Create request modifier for authorization | ||||||
|  |         let modifier = AnyModifier { request in | ||||||
|  |             var r = request | ||||||
|  |             r.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") | ||||||
|  |             r.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") | ||||||
|  |             return r | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Use WebP processor as default since the app seems to handle WebP images | ||||||
|  |         let processor = WebPProcessor.default | ||||||
|  |  | ||||||
|  |         // Use KingfisherManager to retrieve image with caching | ||||||
|  |         currentTask = KingfisherManager.shared.retrieveImage( | ||||||
|  |             with: initialUrl, | ||||||
|  |             options: [ | ||||||
|  |                 .requestModifier(modifier), | ||||||
|  |                 .processor(processor), | ||||||
|  |                 .cacheOriginalImage, // Cache the original image data | ||||||
|  |                 .loadDiskFileSynchronously // Load from disk cache synchronously if available | ||||||
|  |             ] | ||||||
|  |         ) { [weak self] result in | ||||||
|  |             guard let self = self else { return } | ||||||
|  |  | ||||||
|  |             Task { @MainActor in | ||||||
|  |                 switch result { | ||||||
|  |                 case .success(let value): | ||||||
|  |                     self.image = Image(uiImage: value.image) | ||||||
|  |                     self.isLoading = false | ||||||
|  |                 case .failure(_): | ||||||
|  |                     // If WebP processor fails (likely due to format), try with default processor | ||||||
|  |                     let defaultProcessor = DefaultImageProcessor.default | ||||||
|  |                     self.currentTask = KingfisherManager.shared.retrieveImage( | ||||||
|  |                         with: initialUrl, | ||||||
|  |                         options: [ | ||||||
|  |                             .requestModifier(modifier), | ||||||
|  |                             .processor(defaultProcessor), | ||||||
|  |                             .cacheOriginalImage, | ||||||
|  |                             .loadDiskFileSynchronously | ||||||
|  |                         ] | ||||||
|  |                     ) { [weak self] fallbackResult in | ||||||
|  |                         guard let self = self else { return } | ||||||
|  |  | ||||||
|  |                         Task { @MainActor in | ||||||
|  |                             switch fallbackResult { | ||||||
|  |                             case .success(let value): | ||||||
|  |                                 self.image = Image(uiImage: value.image) | ||||||
|  |                             case .failure(let fallbackError): | ||||||
|  |                                 self.errorMessage = fallbackError.localizedDescription | ||||||
|  |                                 print("[watchOS] Image loading failed: \(fallbackError.localizedDescription)") | ||||||
|  |                             } | ||||||
|  |                             self.isLoading = false | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func cancel() { | ||||||
|  |         currentTask?.cancel() | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										637
									
								
								ios/WatchRunner Watch App/Services/NetworkService.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										637
									
								
								ios/WatchRunner Watch App/Services/NetworkService.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,637 @@ | |||||||
|  | // | ||||||
|  | //  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.shared | ||||||
|  |      | ||||||
|  |     // 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 | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										56
									
								
								ios/WatchRunner Watch App/State/AppState.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								ios/WatchRunner Watch App/State/AppState.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | |||||||
|  | // | ||||||
|  | //  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 | ||||||
|  |  | ||||||
|  |     let networkService = NetworkService() | ||||||
|  |     private var wcService = WatchConnectivityService() | ||||||
|  |     private var cancellables = Set<AnyCancellable>() | ||||||
|  |     private var hasAttemptedConnection = false | ||||||
|  |  | ||||||
|  |     init() { | ||||||
|  |         wcService.$token.combineLatest(wcService.$serverUrl, wcService.$isFetched) | ||||||
|  |             .receive(on: DispatchQueue.main) | ||||||
|  |             .sink { [weak self] (token: String?, serverUrl: String?, isFetched: Bool?) in | ||||||
|  |                 guard let self = self else { return } | ||||||
|  |                  | ||||||
|  |                 self.token = token | ||||||
|  |                 self.serverUrl = serverUrl | ||||||
|  |  | ||||||
|  |                 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() | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,93 @@ | |||||||
|  | // | ||||||
|  | //  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? | ||||||
|  |     @Published var isFetched: Bool? | ||||||
|  |  | ||||||
|  |     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)") | ||||||
|  |             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() { | ||||||
|  |         if self.isFetched == true { | ||||||
|  |             print("[watchOS] Skipped fetch from phone due to tried.") | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         guard session.isReachable else { | ||||||
|  |             self.isFetched = true | ||||||
|  |             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 { | ||||||
|  |                 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) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } errorHandler: { error in | ||||||
|  |             print("[watchOS] sendMessage failed with error: \(error.localizedDescription)") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										20
									
								
								ios/WatchRunner Watch App/Utils/AttachmentUtils.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								ios/WatchRunner Watch App/Utils/AttachmentUtils.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | // | ||||||
|  | //  AttachmentUtils.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import Foundation | ||||||
|  |  | ||||||
|  | // MARK: - Helper Functions | ||||||
|  |  | ||||||
|  | func getAttachmentUrl(for fileId: String, serverUrl: String) -> URL? { | ||||||
|  |     let urlString: String | ||||||
|  |     if fileId.starts(with: "http") { | ||||||
|  |         urlString = fileId | ||||||
|  |     } else { | ||||||
|  |         urlString = "\(serverUrl)/drive/files/\(fileId)" | ||||||
|  |     } | ||||||
|  |     return URL(string: urlString) | ||||||
|  | } | ||||||
							
								
								
									
										73
									
								
								ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | |||||||
|  | // | ||||||
|  | //  ActivityViewModel.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import Foundation | ||||||
|  | import Combine | ||||||
|  |  | ||||||
|  | // MARK: - View Models | ||||||
|  |  | ||||||
|  | @MainActor | ||||||
|  | class ActivityViewModel: ObservableObject { | ||||||
|  |     @Published var activities: [SnActivity] = [] | ||||||
|  |     @Published var isLoading = false | ||||||
|  |     @Published var isLoadingMore = false | ||||||
|  |     @Published var errorMessage: String? | ||||||
|  |     @Published var hasMore = false | ||||||
|  |  | ||||||
|  |     private let networkService = NetworkService() | ||||||
|  |     let filter: String | ||||||
|  |     private var isMock = false | ||||||
|  |     private var hasFetched = false | ||||||
|  |     private var nextCursor: String? | ||||||
|  |  | ||||||
|  |     init(filter: String, mockActivities: [SnActivity]? = nil) { | ||||||
|  |         self.filter = filter | ||||||
|  |         if let mockActivities = mockActivities { | ||||||
|  |             self.activities = mockActivities | ||||||
|  |             self.isMock = true | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func fetchActivities(token: String, serverUrl: String) async { | ||||||
|  |         if isMock || hasFetched { return } | ||||||
|  |         guard !isLoading else { return } | ||||||
|  |         isLoading = true | ||||||
|  |         errorMessage = nil | ||||||
|  |         hasFetched = true | ||||||
|  |         nextCursor = nil | ||||||
|  |  | ||||||
|  |         do { | ||||||
|  |             let response = try await networkService.fetchActivities(filter: filter, cursor: nil, token: token, serverUrl: serverUrl) | ||||||
|  |             self.activities = response.activities | ||||||
|  |             self.hasMore = response.hasMore | ||||||
|  |             self.nextCursor = response.nextCursor | ||||||
|  |         } catch { | ||||||
|  |             self.errorMessage = error.localizedDescription | ||||||
|  |             print("[watchOS] fetchActivities failed with error: \(error)") | ||||||
|  |             hasFetched = false | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         isLoading = false | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func loadMoreActivities(token: String, serverUrl: String) async { | ||||||
|  |         guard !isLoadingMore && hasMore && nextCursor != nil else { return } | ||||||
|  |         isLoadingMore = true | ||||||
|  |  | ||||||
|  |         do { | ||||||
|  |             let response = try await networkService.fetchActivities(filter: filter, cursor: nextCursor, token: token, serverUrl: serverUrl) | ||||||
|  |             self.activities.append(contentsOf: response.activities) | ||||||
|  |             self.hasMore = response.hasMore | ||||||
|  |             self.nextCursor = response.nextCursor | ||||||
|  |         } catch { | ||||||
|  |             self.errorMessage = error.localizedDescription | ||||||
|  |             print("[watchOS] loadMoreActivities failed with error: \(error)") | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         isLoadingMore = false | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,35 @@ | |||||||
|  | // | ||||||
|  | //  ComposePostViewModel.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import Foundation | ||||||
|  | import Combine | ||||||
|  |  | ||||||
|  | @MainActor | ||||||
|  | class ComposePostViewModel: ObservableObject { | ||||||
|  |     @Published var title = "" | ||||||
|  |     @Published var content = "" | ||||||
|  |     @Published var isPosting = false | ||||||
|  |     @Published var errorMessage: String? | ||||||
|  |     @Published var didPost = false | ||||||
|  |  | ||||||
|  |     private let networkService = NetworkService() | ||||||
|  |  | ||||||
|  |     func createPost(token: String, serverUrl: String) async { | ||||||
|  |         guard !isPosting else { return } | ||||||
|  |         isPosting = true | ||||||
|  |         errorMessage = nil | ||||||
|  |  | ||||||
|  |         do { | ||||||
|  |             try await networkService.createPost(title: title, content: content, token: token, serverUrl: serverUrl) | ||||||
|  |             didPost = true | ||||||
|  |         } catch { | ||||||
|  |             errorMessage = error.localizedDescription | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         isPosting = false | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										284
									
								
								ios/WatchRunner Watch App/Views/AccountView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										284
									
								
								ios/WatchRunner Watch App/Views/AccountView.swift
									
									
									
									
									
										Normal 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()) | ||||||
|  | } | ||||||
							
								
								
									
										86
									
								
								ios/WatchRunner Watch App/Views/ActivityListView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								ios/WatchRunner Watch App/Views/ActivityListView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,86 @@ | |||||||
|  | // | ||||||
|  | //  ActivityListView.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | // MARK: - Views | ||||||
|  |  | ||||||
|  | struct ActivityListView: View { | ||||||
|  |     @StateObject private var viewModel: ActivityViewModel | ||||||
|  |     @EnvironmentObject var appState: AppState | ||||||
|  |  | ||||||
|  |     init(filter: String, mockActivities: [SnActivity]? = nil) { | ||||||
|  |         _viewModel = StateObject(wrappedValue: ActivityViewModel(filter: filter, mockActivities: mockActivities)) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         Group { | ||||||
|  |             if viewModel.isLoading { | ||||||
|  |                 ProgressView() | ||||||
|  |             } else if let errorMessage = viewModel.errorMessage { | ||||||
|  |                 VStack { | ||||||
|  |                     Text("Error fetching data") | ||||||
|  |                         .font(.headline) | ||||||
|  |                     Text(errorMessage) | ||||||
|  |                         .font(.caption) | ||||||
|  |                         .lineLimit(nil) | ||||||
|  |                 } | ||||||
|  |                 .padding() | ||||||
|  |             } else if viewModel.activities.isEmpty { | ||||||
|  |                 Text("No activities found.") | ||||||
|  |             } else { | ||||||
|  |                 List { | ||||||
|  |                     ForEach(viewModel.activities) { activity in | ||||||
|  |                         switch activity.type { | ||||||
|  |                         case "posts.new", "posts.new.replies": | ||||||
|  |                             if case .post(let post) = activity.data { | ||||||
|  |                                 NavigationLink( | ||||||
|  |                                     destination: PostDetailView(post: post).environmentObject(appState) | ||||||
|  |                                 ) { | ||||||
|  |                                     PostRowView(post: post) | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         case "discovery": | ||||||
|  |                             if case .discovery(let discoveryData) = activity.data { | ||||||
|  |                                 DiscoveryView(discoveryData: discoveryData) | ||||||
|  |                             } | ||||||
|  |                         default: | ||||||
|  |                             Text("Unknown activity type: \(activity.type)") | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     if viewModel.hasMore { | ||||||
|  |                         if viewModel.isLoadingMore { | ||||||
|  |                             HStack { | ||||||
|  |                                 Spacer() | ||||||
|  |                                 ProgressView() | ||||||
|  |                                 Spacer() | ||||||
|  |                             } | ||||||
|  |                         } else { | ||||||
|  |                             Button("Load More") { | ||||||
|  |                                 Task { | ||||||
|  |                                     if let token = appState.token, let serverUrl = appState.serverUrl { | ||||||
|  |                                         await viewModel.loadMoreActivities(token: token, serverUrl: serverUrl) | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                             .frame(maxWidth: .infinity) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .onAppear { | ||||||
|  |             if appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl { | ||||||
|  |                 Task.detached { | ||||||
|  |                     await viewModel.fetchActivities(token: token, serverUrl: serverUrl) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .navigationTitle(viewModel.filter) | ||||||
|  |         .navigationBarTitleDisplayMode(.inline) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										62
									
								
								ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift
									
									
									
									
									
										Normal 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) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										109
									
								
								ios/WatchRunner Watch App/Views/AttachmentView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								ios/WatchRunner Watch App/Views/AttachmentView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | |||||||
|  | // | ||||||
|  | //  AttachmentImageView.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  | import AVKit | ||||||
|  | import AVFoundation | ||||||
|  |  | ||||||
|  | struct AttachmentView: View { | ||||||
|  |     let attachment: SnCloudFile | ||||||
|  |     @EnvironmentObject var appState: AppState | ||||||
|  |     @StateObject private var imageLoader = ImageLoader() | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         Group { | ||||||
|  |             if let mimeType = attachment.mimeType { | ||||||
|  |                 if mimeType.starts(with: "image") { | ||||||
|  |                     if let serverUrl = appState.serverUrl, let imageUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl) { | ||||||
|  |                         NavigationLink( | ||||||
|  |                             destination: ImageViewer(imageUrl: imageUrl).environmentObject(appState) | ||||||
|  |                         ) { | ||||||
|  |                             if imageLoader.isLoading { | ||||||
|  |                                 ProgressView() | ||||||
|  |                             } else if let image = imageLoader.image { | ||||||
|  |                                 image | ||||||
|  |                                     .resizable() | ||||||
|  |                                     .aspectRatio(contentMode: .fit) | ||||||
|  |                                     .frame(maxWidth: .infinity) | ||||||
|  |                                     .cornerRadius(8) | ||||||
|  |                             } else if let errorMessage = imageLoader.errorMessage { | ||||||
|  |                                 Text("Failed to load attachment: \(errorMessage)") | ||||||
|  |                                     .font(.caption) | ||||||
|  |                                     .foregroundColor(.red) | ||||||
|  |                                     .cornerRadius(8) | ||||||
|  |                             } else { | ||||||
|  |                                 Text("File: \(attachment.id)") | ||||||
|  |                                     .cornerRadius(8) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                         .buttonStyle(PlainButtonStyle()) | ||||||
|  |                     } else { | ||||||
|  |                         Text("Image URL not available.") | ||||||
|  |                     } | ||||||
|  |                 } else if mimeType.starts(with: "video") { | ||||||
|  |                     if let serverUrl = appState.serverUrl, let videoUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl) { | ||||||
|  |                         NavigationLink(destination: VideoPlayerView(videoUrl: videoUrl)) { | ||||||
|  |                             if imageLoader.isLoading { | ||||||
|  |                                 ProgressView() | ||||||
|  |                             } else if let image = imageLoader.image { | ||||||
|  |                                 ZStack { | ||||||
|  |                                     image | ||||||
|  |                                         .resizable() | ||||||
|  |                                         .aspectRatio(contentMode: .fit) | ||||||
|  |                                         .frame(maxWidth: .infinity) | ||||||
|  |                                         .cornerRadius(8) | ||||||
|  |  | ||||||
|  |                                     Image(systemName: "play.circle.fill") | ||||||
|  |                                         .resizable() | ||||||
|  |                                         .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()) | ||||||
|  |                     } else { | ||||||
|  |                         Text("Video URL not available.") | ||||||
|  |                     } | ||||||
|  |                 } else if mimeType.starts(with: "audio") { | ||||||
|  |                     if let serverUrl = appState.serverUrl, let audioUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl) { | ||||||
|  |                         AudioPlayerView(audioUrl: audioUrl) | ||||||
|  |                     } else { | ||||||
|  |                         Text("Cannot play audio: URL not available.") | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     Text("Unsupported media type: \(mimeType)") | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 Text("File: \(attachment.id) (No MIME type)") | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .task(id: attachment.id) { | ||||||
|  |             if let serverUrl = appState.serverUrl, let attachmentUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl), let token = appState.token { | ||||||
|  |                 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) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										47
									
								
								ios/WatchRunner Watch App/Views/AudioPlayerView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								ios/WatchRunner Watch App/Views/AudioPlayerView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  |  | ||||||
|  | // | ||||||
|  | //  AudioPlayerView.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  | import AVFoundation | ||||||
|  |  | ||||||
|  | struct AudioPlayerView: View { | ||||||
|  |     let audioUrl: URL | ||||||
|  |     @State private var player: AVPlayer? | ||||||
|  |     @State private var isPlaying: Bool = false | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         VStack { | ||||||
|  |             if player != nil { | ||||||
|  |                 Button(action: togglePlayPause) { | ||||||
|  |                     Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill") | ||||||
|  |                         .font(.largeTitle) | ||||||
|  |                 } | ||||||
|  |                 .buttonStyle(.plain) | ||||||
|  |             } else { | ||||||
|  |                 Text("Loading audio...") | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .onAppear { | ||||||
|  |             player = AVPlayer(url: audioUrl) | ||||||
|  |         } | ||||||
|  |         .onDisappear { | ||||||
|  |             player?.pause() | ||||||
|  |             player = nil | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private func togglePlayPause() { | ||||||
|  |         guard let player = player else { return } | ||||||
|  |         if isPlaying { | ||||||
|  |             player.pause() | ||||||
|  |         } else { | ||||||
|  |             player.play() | ||||||
|  |         } | ||||||
|  |         isPlaying.toggle() | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										785
									
								
								ios/WatchRunner Watch App/Views/ChatViews.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										785
									
								
								ios/WatchRunner Watch App/Views/ChatViews.swift
									
									
									
									
									
										Normal 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(.glass) | ||||||
|  |                     .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 | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										53
									
								
								ios/WatchRunner Watch App/Views/ComposePostView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								ios/WatchRunner Watch App/Views/ComposePostView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | // | ||||||
|  | //  ComposePostView.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | struct ComposePostView: View { | ||||||
|  |     @StateObject private var viewModel = ComposePostViewModel() | ||||||
|  |     @EnvironmentObject var appState: AppState | ||||||
|  |     @Environment(\.dismiss) private var dismiss | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         NavigationStack { | ||||||
|  |             Form { | ||||||
|  |                 TextField("Title", text: $viewModel.title) | ||||||
|  |                 TextField("Content", text: $viewModel.content) | ||||||
|  |             } | ||||||
|  |             .navigationTitle("New Post") | ||||||
|  |             .toolbar { | ||||||
|  |                 ToolbarItem(placement: .cancellationAction) { | ||||||
|  |                     Button("Cancel", systemImage: "xmark") { | ||||||
|  |                         dismiss() | ||||||
|  |                     } | ||||||
|  |                     .labelStyle(.iconOnly) | ||||||
|  |                 } | ||||||
|  |                 ToolbarItem(placement: .confirmationAction) { | ||||||
|  |                     Button("Post", systemImage: "square.and.arrow.up") { | ||||||
|  |                         Task { | ||||||
|  |                             if let token = appState.token, let serverUrl = appState.serverUrl { | ||||||
|  |                                 await viewModel.createPost(token: token, serverUrl: serverUrl) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     .labelStyle(.iconOnly) | ||||||
|  |                     .disabled(viewModel.isPosting) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .onChange(of: viewModel.didPost) { | ||||||
|  |                 if viewModel.didPost { | ||||||
|  |                     dismiss() | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .alert("Error", isPresented: .constant(viewModel.errorMessage != nil), actions: { | ||||||
|  |                 Button("OK") { viewModel.errorMessage = nil } | ||||||
|  |             }, message: { | ||||||
|  |                 Text(viewModel.errorMessage ?? "") | ||||||
|  |             }) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										110
									
								
								ios/WatchRunner Watch App/Views/DiscoveryViews.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								ios/WatchRunner Watch App/Views/DiscoveryViews.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | |||||||
|  | // | ||||||
|  | //  DiscoveryViews.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | struct DiscoveryView: View { | ||||||
|  |     let discoveryData: DiscoveryData | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         NavigationLink(destination: DiscoveryDetailView(discoveryData: discoveryData)) { | ||||||
|  |             VStack(alignment: .leading) { | ||||||
|  |                 Text("Discovery") | ||||||
|  |                     .font(.headline) | ||||||
|  |                 Text("\(discoveryData.items.count) new items to discover") | ||||||
|  |                     .font(.subheadline) | ||||||
|  |                     .foregroundColor(.secondary) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct DiscoveryDetailView: View { | ||||||
|  |     let discoveryData: DiscoveryData | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         List(discoveryData.items) { item in | ||||||
|  |             NavigationLink(destination: destinationView(for: item)) { | ||||||
|  |                 itemView(for: item) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Discovery") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @ViewBuilder | ||||||
|  |     private func itemView(for item: DiscoveryItem) -> some View { | ||||||
|  |         VStack(alignment: .leading) { | ||||||
|  |             switch item.data { | ||||||
|  |             case .realm(let realm): | ||||||
|  |                 Text("Realm").font(.headline) | ||||||
|  |                 Text(realm.name).foregroundColor(.secondary) | ||||||
|  |             case .publisher(let publisher): | ||||||
|  |                 Text("Publisher").font(.headline) | ||||||
|  |                 Text(publisher.name).foregroundColor(.secondary) | ||||||
|  |             case .article(let article): | ||||||
|  |                 Text("Article").font(.headline) | ||||||
|  |                 Text(article.title).foregroundColor(.secondary) | ||||||
|  |             case .unknown: | ||||||
|  |                 Text("Unknown item") | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     @ViewBuilder | ||||||
|  |     private func destinationView(for item: DiscoveryItem) -> some View { | ||||||
|  |         switch item.data { | ||||||
|  |         case .realm(let realm): | ||||||
|  |             RealmDetailView(realm: realm) | ||||||
|  |         case .publisher(let publisher): | ||||||
|  |             PublisherDetailView(publisher: publisher) | ||||||
|  |         case .article(let article): | ||||||
|  |             ArticleDetailView(article: article) | ||||||
|  |         case .unknown: | ||||||
|  |             Text("Detail view not available") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct RealmDetailView: View { | ||||||
|  |     let realm: SnRealm | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading, spacing: 8) { | ||||||
|  |             Text(realm.name).font(.headline) | ||||||
|  |             if let description = realm.description { | ||||||
|  |                 Text(description).font(.body) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Realm") | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct PublisherDetailView: View { | ||||||
|  |     let publisher: SnPublisher | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading, spacing: 8) { | ||||||
|  |             Text(publisher.name).font(.headline) | ||||||
|  |             if let description = publisher.description { | ||||||
|  |                 Text(description).font(.body) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Publisher") | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct ArticleDetailView: View { | ||||||
|  |     let article: SnWebArticle | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading, spacing: 8) { | ||||||
|  |             Text(article.title).font(.headline) | ||||||
|  |             Text(article.url).font(.caption).foregroundColor(.secondary) | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Article") | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										59
									
								
								ios/WatchRunner Watch App/Views/ExploreView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								ios/WatchRunner Watch App/Views/ExploreView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | |||||||
|  | // | ||||||
|  | //  ExploreView.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | // The main view with the TabView for filtering. | ||||||
|  | struct ExploreView: View { | ||||||
|  |     @StateObject private var appState = AppState() | ||||||
|  |     @State private var isComposing = false | ||||||
|  |     @State private var selectedTab: String = "Explore" | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         NavigationStack { | ||||||
|  |             if appState.isReady { | ||||||
|  |                 TabView(selection: $selectedTab) { | ||||||
|  |                     ActivityListView(filter: "Explore") | ||||||
|  |                         .tag("Explore") | ||||||
|  |                         .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 { | ||||||
|  |                     ToolbarItem(placement: .primaryAction) { | ||||||
|  |                         Button(action: { isComposing = true }) { | ||||||
|  |                             Label("Compose", systemImage: "plus") | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 .environmentObject(appState) | ||||||
|  |             } else { | ||||||
|  |                 ProgressView { Text("Connecting to phone...") } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .sheet(isPresented: $isComposing) { | ||||||
|  |             ComposePostView() | ||||||
|  |                 .environmentObject(appState) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										34
									
								
								ios/WatchRunner Watch App/Views/ImageViewer.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								ios/WatchRunner Watch App/Views/ImageViewer.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | struct ImageViewer: View { | ||||||
|  |     let imageUrl: URL | ||||||
|  |     @EnvironmentObject var appState: AppState | ||||||
|  |     @StateObject private var imageLoader = ImageLoader() | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         Group { | ||||||
|  |             if imageLoader.isLoading { | ||||||
|  |                 ProgressView() | ||||||
|  |             } else if let image = imageLoader.image { | ||||||
|  |                 image | ||||||
|  |                     .resizable() | ||||||
|  |                     .aspectRatio(contentMode: .fit) | ||||||
|  |                     .frame(maxWidth: .infinity, maxHeight: .infinity) | ||||||
|  |                     .scaledToFit() | ||||||
|  |             } else if let errorMessage = imageLoader.errorMessage { | ||||||
|  |                 Text("Failed to load image: \(errorMessage)") | ||||||
|  |                     .font(.caption) | ||||||
|  |                     .foregroundColor(.red) | ||||||
|  |             } else { | ||||||
|  |                 Text("Failed to load image.") | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .task(id: imageUrl) { | ||||||
|  |             if let token = appState.token { | ||||||
|  |                 await imageLoader.loadImage(from: imageUrl, token: token) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Image") | ||||||
|  |         .navigationBarTitleDisplayMode(.inline) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										198
									
								
								ios/WatchRunner Watch App/Views/NotificationView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										198
									
								
								ios/WatchRunner Watch App/Views/NotificationView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,198 @@ | |||||||
|  |  | ||||||
|  | // | ||||||
|  | //  NotificationView.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  | import Combine | ||||||
|  |  | ||||||
|  | @MainActor | ||||||
|  | class NotificationViewModel: ObservableObject { | ||||||
|  |     @Published var notifications = [SnNotification]() | ||||||
|  |     @Published var isLoading = false | ||||||
|  |     @Published var isLoadingMore = false | ||||||
|  |     @Published var errorMessage: String? | ||||||
|  |     @Published var hasMore = false | ||||||
|  |  | ||||||
|  |     private let networkService = NetworkService() | ||||||
|  |     private var hasFetched = false | ||||||
|  |     private var offset = 0 | ||||||
|  |     private let pageSize = 20 | ||||||
|  |  | ||||||
|  |     func fetchNotifications(token: String, serverUrl: String) async { | ||||||
|  |         if hasFetched { return } | ||||||
|  |         guard !isLoading else { return } | ||||||
|  |         isLoading = true | ||||||
|  |         errorMessage = nil | ||||||
|  |         hasFetched = true | ||||||
|  |         offset = 0 | ||||||
|  |  | ||||||
|  |         do { | ||||||
|  |             let response = try await networkService.fetchNotifications(offset: offset, take: pageSize, token: token, serverUrl: serverUrl) | ||||||
|  |             self.notifications = response.notifications | ||||||
|  |             self.hasMore = response.hasMore | ||||||
|  |             offset += response.notifications.count | ||||||
|  |         } catch { | ||||||
|  |             self.errorMessage = error.localizedDescription | ||||||
|  |             print("[watchOS] fetchNotifications failed with error: \(error)") | ||||||
|  |             hasFetched = false | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         isLoading = false | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func loadMoreNotifications(token: String, serverUrl: String) async { | ||||||
|  |         guard !isLoadingMore && hasMore else { return } | ||||||
|  |         isLoadingMore = true | ||||||
|  |  | ||||||
|  |         do { | ||||||
|  |             let response = try await networkService.fetchNotifications(offset: offset, take: pageSize, token: token, serverUrl: serverUrl) | ||||||
|  |             self.notifications.append(contentsOf: response.notifications) | ||||||
|  |             self.hasMore = response.hasMore | ||||||
|  |             offset += response.notifications.count | ||||||
|  |         } catch { | ||||||
|  |             self.errorMessage = error.localizedDescription | ||||||
|  |             print("[watchOS] loadMoreNotifications failed with error: \(error)") | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         isLoadingMore = false | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct NotificationView: View { | ||||||
|  |     @EnvironmentObject var appState: AppState | ||||||
|  |     @StateObject private var viewModel = NotificationViewModel() | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         Group { | ||||||
|  |             if viewModel.isLoading { | ||||||
|  |                 ProgressView() | ||||||
|  |             } else if let errorMessage = viewModel.errorMessage { | ||||||
|  |                 VStack { | ||||||
|  |                     Text("Error") | ||||||
|  |                         .font(.headline) | ||||||
|  |                     Text(errorMessage) | ||||||
|  |                         .font(.caption) | ||||||
|  |                     Button("Retry") { | ||||||
|  |                         Task { | ||||||
|  |                             if let token = appState.token, let serverUrl = appState.serverUrl { | ||||||
|  |                                 await viewModel.fetchNotifications(token: token, serverUrl: serverUrl) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 .padding() | ||||||
|  |             } else if viewModel.notifications.isEmpty { | ||||||
|  |                 Text("No notifications") | ||||||
|  |             } else { | ||||||
|  |                 List { | ||||||
|  |                     ForEach(viewModel.notifications) { notification in | ||||||
|  |                         NavigationLink(destination: NotificationDetailView(notification: notification)) { | ||||||
|  |                             VStack(alignment: .leading, spacing: 4) { | ||||||
|  |                                 HStack { | ||||||
|  |                                     Text(notification.title) | ||||||
|  |                                         .font(.headline) | ||||||
|  |                                     Spacer() | ||||||
|  |                                     if notification.viewedAt == nil { | ||||||
|  |                                         Circle() | ||||||
|  |                                             .fill(Color.blue) | ||||||
|  |                                             .frame(width: 8, height: 8) | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                                 if !notification.subtitle.isEmpty { | ||||||
|  |                                     Text(notification.subtitle) | ||||||
|  |                                         .font(.subheadline) | ||||||
|  |                                         .foregroundColor(.secondary) | ||||||
|  |                                 } | ||||||
|  |                                 if notification.content.count > 100 { | ||||||
|  |                                     Text(notification.content.prefix(100) + "...") | ||||||
|  |                                         .font(.caption) | ||||||
|  |                                         .foregroundColor(.gray) | ||||||
|  |                                         .lineLimit(2) | ||||||
|  |                                 } else { | ||||||
|  |                                     Text(notification.content) | ||||||
|  |                                         .font(.caption) | ||||||
|  |                                         .foregroundColor(.gray) | ||||||
|  |                                         .lineLimit(2) | ||||||
|  |                                 } | ||||||
|  |                                 Text(notification.createdAt, style: .relative) | ||||||
|  |                                     .font(.caption2) | ||||||
|  |                                     .foregroundColor(.gray) | ||||||
|  |                             } | ||||||
|  |                             .padding(.vertical, 8) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     if viewModel.hasMore { | ||||||
|  |                         if viewModel.isLoadingMore { | ||||||
|  |                             HStack { | ||||||
|  |                                 Spacer() | ||||||
|  |                                 ProgressView() | ||||||
|  |                                 Spacer() | ||||||
|  |                             } | ||||||
|  |                         } else { | ||||||
|  |                             Button("Load More") { | ||||||
|  |                                 Task { | ||||||
|  |                                     if let token = appState.token, let serverUrl = appState.serverUrl { | ||||||
|  |                                         await viewModel.loadMoreNotifications(token: token, serverUrl: serverUrl) | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                             .frame(maxWidth: .infinity) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .onAppear { | ||||||
|  |             if appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl { | ||||||
|  |                 Task.detached { | ||||||
|  |                     await viewModel.fetchNotifications(token: token, serverUrl: serverUrl) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Notifications") | ||||||
|  |         .navigationBarTitleDisplayMode(.inline) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct NotificationDetailView: View { | ||||||
|  |     let notification: SnNotification | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         ScrollView { | ||||||
|  |             VStack(alignment: .leading, spacing: 16) { | ||||||
|  |                 Text(notification.title) | ||||||
|  |                     .font(.headline) | ||||||
|  |                  | ||||||
|  |                 if !notification.subtitle.isEmpty { | ||||||
|  |                     Text(notification.subtitle) | ||||||
|  |                         .font(.subheadline) | ||||||
|  |                         .foregroundColor(.secondary) | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 Text(notification.content) | ||||||
|  |                     .font(.body) | ||||||
|  |                  | ||||||
|  |                 HStack { | ||||||
|  |                     Text(notification.createdAt, style: .date) | ||||||
|  |                     Text("·") | ||||||
|  |                     Text(notification.createdAt, style: .time) | ||||||
|  |                 } | ||||||
|  |                 .font(.caption) | ||||||
|  |                 .foregroundColor(.gray) | ||||||
|  |                  | ||||||
|  |                 if notification.viewedAt == nil { | ||||||
|  |                     Text("Unread") | ||||||
|  |                         .font(.caption) | ||||||
|  |                         .foregroundColor(.blue) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .padding() | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Notification") | ||||||
|  |         .navigationBarTitleDisplayMode(.inline) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										151
									
								
								ios/WatchRunner Watch App/Views/PostViews.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								ios/WatchRunner Watch App/Views/PostViews.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,151 @@ | |||||||
|  | // | ||||||
|  | //  PostViews.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | struct PostRowView: View { | ||||||
|  |     let post: SnPost | ||||||
|  |     @EnvironmentObject var appState: AppState | ||||||
|  |     @StateObject private var imageLoader = ImageLoader() // Instantiate ImageLoader | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading, spacing: 4) { | ||||||
|  |             HStack { | ||||||
|  |                 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) | ||||||
|  |                     .bold() | ||||||
|  |             } | ||||||
|  |             .task(id: post.publisher.picture?.id) { // Use task(id:) to reload image when pictureId changes | ||||||
|  |                 if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { | ||||||
|  |                     await imageLoader.loadImage(from: imageUrl, token: token) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             if let title = post.title, !title.isEmpty { | ||||||
|  |                 Text(title) | ||||||
|  |                     .font(.headline) | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             if let content = post.content, !content.isEmpty { | ||||||
|  |                 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) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct PostDetailView: View { | ||||||
|  |     let post: SnPost | ||||||
|  |     @EnvironmentObject var appState: AppState | ||||||
|  |     @StateObject private var publisherImageLoader = ImageLoader() // Instantiate ImageLoader for publisher avatar | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         ScrollView { | ||||||
|  |             VStack(alignment: .leading, spacing: 8) { | ||||||
|  |                 HStack { | ||||||
|  |                     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) | ||||||
|  |                 } | ||||||
|  |                 // 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) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 if let title = post.title, !title.isEmpty { | ||||||
|  |                     Text(title) | ||||||
|  |                         .font(.title2) | ||||||
|  |                         .bold() | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 if let content = post.content, !content.isEmpty { | ||||||
|  |                     Text(content) | ||||||
|  |                         .font(.body) | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 if !post.attachments.isEmpty { | ||||||
|  |                     Text("Attachments").font(.headline) | ||||||
|  |                     ForEach(post.attachments) { attachment in | ||||||
|  |                         AttachmentView(attachment: attachment) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 if !post.tags.isEmpty { | ||||||
|  |                     Text("Tags").font(.headline) | ||||||
|  |                     FlowLayout(alignment: .leading, spacing: 4) { | ||||||
|  |                         ForEach(post.tags) { tag in | ||||||
|  |                             Text("#\(tag.name ?? tag.slug)") | ||||||
|  |                                 .font(.caption) | ||||||
|  |                                 .padding(.horizontal, 8) | ||||||
|  |                                 .padding(.vertical, 3) | ||||||
|  |                                 .background(Capsule().fill(Color.accentColor.opacity(0.2))) | ||||||
|  |                                 .cornerRadius(5) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .padding() | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Post") | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										132
									
								
								ios/WatchRunner Watch App/Views/StatusCreationView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								ios/WatchRunner Watch App/Views/StatusCreationView.swift
									
									
									
									
									
										Normal 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(.glass) | ||||||
|  |                      | ||||||
|  |                     Button(isSubmitting ? "Saving..." : "Save") { | ||||||
|  |                         Task { | ||||||
|  |                             await submitStatus() | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     .buttonStyle(.glassProminent) | ||||||
|  |                     .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()) | ||||||
|  | } | ||||||
							
								
								
									
										12
									
								
								ios/WatchRunner Watch App/Views/VideoPlayerView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								ios/WatchRunner Watch App/Views/VideoPlayerView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | import SwiftUI | ||||||
|  | import AVKit | ||||||
|  | import AVFoundation | ||||||
|  |  | ||||||
|  | struct VideoPlayerView: View { | ||||||
|  |     let videoUrl: URL | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         VideoPlayer(player: AVPlayer(url: videoUrl)) | ||||||
|  |             .edgesIgnoringSafeArea(.all) // Make it full screen | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										17
									
								
								ios/WatchRunner Watch App/WatchRunnerApp.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								ios/WatchRunner Watch App/WatchRunnerApp.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | // | ||||||
|  | //  WatchRunnerApp.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/28. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | @main | ||||||
|  | struct WatchRunner_Watch_AppApp: App { | ||||||
|  |     var body: some Scene { | ||||||
|  |         WindowGroup { | ||||||
|  |             ContentView() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										10
									
								
								ios/WatchRunner-Watch-App-Info.plist
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								ios/WatchRunner-Watch-App-Info.plist
									
									
									
									
									
										Normal 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> | ||||||
		Reference in New Issue
	
	Block a user