diff --git a/ios/Podfile b/ios/Podfile index 7231925b..988ff4a6 100644 --- a/ios/Podfile +++ b/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. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -28,6 +25,8 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup target 'Runner' do + platform :ios, '15.0' + use_frameworks! use_modular_headers! @@ -50,6 +49,16 @@ target 'Runner' do 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| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1a510d09..e5af04f9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -219,6 +219,21 @@ PODS: - irondash_engine_context (0.0.1): - Flutter - 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): - Flutter - flutter_webrtc @@ -333,6 +348,7 @@ DEPENDENCIES: - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) - Kingfisher (~> 8.0) + - KingfisherWebP - livekit_client (from `.symlinks/plugins/livekit_client/ios`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) @@ -375,6 +391,8 @@ SPEC REPOS: - GoogleDataTransport - GoogleUtilities - Kingfisher + - KingfisherWebP + - libwebp - nanopb - OrderedSet - PromisesObjC @@ -520,6 +538,8 @@ SPEC CHECKSUMS: image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 Kingfisher: 64278f126a815d0e2d391cdf71311b85882c4de0 + KingfisherWebP: 38b9721821947f547afb78f933f75f4f9e0ae402 + libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 livekit_client: 86c8af579274e4b7a215185a8080db2d4e176f40 local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 @@ -551,6 +571,6 @@ SPEC CHECKSUMS: wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e -PODFILE CHECKSUM: c818292390b02fa379036ea099713a332bd7193f +PODFILE CHECKSUM: 3096dc559be56aca856e757e1dc65ca039801e2e COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 15098550..bf906178 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,13 +3,14 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 7310A7DF2EB10963002C0FD3 /* WatchRunner Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; }; 73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -20,6 +21,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A1D34487886D362AC8B99B2E /* Pods_WatchRunner_Watch_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 802C1CFCA7F1E069AAEFB454 /* Pods_WatchRunner_Watch_App.framework */; }; B87C0E607033790E71B54D73 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F6D834CA86410B09796B312B /* Pods_Runner.framework */; }; D174D53FF3E8EA943491A5CC /* Pods_SolianShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */; }; D1772CE196985AE8E8C9F2E5 /* Pods_SolianNotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 39FE4CC6223F0D3C0E1FFD04 /* Pods_SolianNotificationService.framework */; }; @@ -58,6 +60,17 @@ /* End PBXContainerItemProxy 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 */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -84,6 +97,7 @@ /* End PBXCopyFilesBuildPhase 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 = ""; }; 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 = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; @@ -100,6 +114,7 @@ 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 = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 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 = ""; }; 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; }; @@ -111,6 +126,8 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -120,6 +137,7 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9AE244813FCDFAA941430393 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; 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 = ""; }; 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 */ /* 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 */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -162,6 +187,14 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet 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 = ""; + }; 73268D272DEB012A0076E970 /* Services */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -205,6 +238,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7310A7D12EB10962002C0FD3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A1D34487886D362AC8B99B2E /* Pods_WatchRunner_Watch_App.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 73ACDFA82E3D0E6100B63535 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -258,6 +299,7 @@ 7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */, 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */, 73ACDFB82E3D0E6100B63535 /* UIKit.framework */, + 802C1CFCA7F1E069AAEFB454 /* Pods_WatchRunner_Watch_App.framework */, ); name = Frameworks; sourceTree = ""; @@ -280,6 +322,9 @@ 17FAB080A9C53193ABD9C15B /* Pods-SolianShareExtension.debug.xcconfig */, 27C66EFB5A705F1A822C3EB0 /* Pods-SolianShareExtension.release.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; sourceTree = ""; @@ -303,6 +348,7 @@ 73CDD67B2DEC00480059D95D /* SolianNotificationService */, 73C305CF2E0BE878009035B9 /* SolianShareExtension */, 73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */, + 7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 91E124CE95BCB4DCD890160D /* Pods */, @@ -319,6 +365,7 @@ 73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */, 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */, 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */, + 7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */, ); name = Products; sourceTree = ""; @@ -363,6 +410,28 @@ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; 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 */ = { isa = PBXNativeTarget; buildConfigurationList = 73ACDFCB2E3D0E6100B63535 /* Build configuration list for PBXNativeTarget "SolianBroadcastExtension" */; @@ -434,6 +503,7 @@ 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */, + 7310A7DE2EB10963002C0FD3 /* Embed Watch Content */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, @@ -463,7 +533,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1640; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -471,6 +541,9 @@ CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; }; + 7310A7D32EB10962002C0FD3 = { + CreatedOnToolsVersion = 26.0.1; + }; 73ACDFAA2E3D0E6100B63535 = { CreatedOnToolsVersion = 16.4; }; @@ -504,6 +577,7 @@ 73CDD6792DEC00480059D95D /* SolianNotificationService */, 73C305CD2E0BE878009035B9 /* SolianShareExtension */, 73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */, + 7310A7D32EB10962002C0FD3 /* WatchRunner Watch App */, ); }; /* End PBXProject section */ @@ -516,6 +590,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7310A7D22EB10962002C0FD3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 73ACDFA92E3D0E6100B63535 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -683,6 +764,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + C74B07D6587D29C67A198025 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + DDEDA1BA6278B94F0F7B9B61 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + 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" */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -734,6 +854,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7310A7D02EB10962002C0FD3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 73ACDFA72E3D0E6100B63535 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -943,6 +1070,147 @@ }; 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 */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1487,6 +1755,16 @@ defaultConfigurationIsVisible = 0; 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" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6b8ce61f..7dcf80a7 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,9 +1,11 @@ import Flutter import UIKit +import WatchConnectivity @main @objc class AppDelegate: FlutterAppDelegate { let notifyDelegate = NotifyDelegate() + private var watchConnectivityService: WatchConnectivityService? override func application( _ application: UIApplication, @@ -28,6 +30,55 @@ import UIKit GeneratedPluginRegistrant.register(with: self) + if WCSession.isSupported() { + watchConnectivityService = WatchConnectivityService() + } + 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) + } + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index cb3ed702..85c1dee9 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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"}} \ No newline at end of file +{ + "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 + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 7353c41e..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cd7b009..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 797d452e..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 84ac32ae..00000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/ios/WatchRunner Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/WatchRunner Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..4bf40ac6 --- /dev/null +++ b/ios/WatchRunner Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "universal", + "reference" : "systemIndigoColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..44472cbc --- /dev/null +++ b/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "Icon-App-1024x1024@1x.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..964fbf00 Binary files /dev/null and b/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/WatchRunner Watch App/Assets.xcassets/Contents.json b/ios/WatchRunner Watch App/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/WatchRunner Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/Contents.json b/ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/Contents.json new file mode 100644 index 00000000..2945b36b --- /dev/null +++ b/ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/Contents.json @@ -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 + } +} diff --git a/ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/icon.png b/ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/icon.png new file mode 100644 index 00000000..0eeb8c11 Binary files /dev/null and b/ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/icon.png differ diff --git a/ios/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift new file mode 100644 index 00000000..5d5b0f79 --- /dev/null +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -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") + } + } + } +} diff --git a/ios/WatchRunner Watch App/Layouts/FlowLayout.swift b/ios/WatchRunner Watch App/Layouts/FlowLayout.swift new file mode 100644 index 00000000..564e769c --- /dev/null +++ b/ios/WatchRunner Watch App/Layouts/FlowLayout.swift @@ -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 + } +} diff --git a/ios/WatchRunner Watch App/Models/Models.swift b/ios/WatchRunner Watch App/Models/Models.swift new file mode 100644 index 00000000..09356735 --- /dev/null +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -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" + } +} diff --git a/ios/WatchRunner Watch App/Previews/CustomPreviews.swift b/ios/WatchRunner Watch App/Previews/CustomPreviews.swift new file mode 100644 index 00000000..73afa65d --- /dev/null +++ b/ios/WatchRunner Watch App/Previews/CustomPreviews.swift @@ -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()) + } +} diff --git a/ios/WatchRunner Watch App/Previews/MockData.swift b/ios/WatchRunner Watch App/Previews/MockData.swift new file mode 100644 index 00000000..4e68232f --- /dev/null +++ b/ios/WatchRunner Watch App/Previews/MockData.swift @@ -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 diff --git a/ios/WatchRunner Watch App/Services/ImageLoader.swift b/ios/WatchRunner Watch App/Services/ImageLoader.swift new file mode 100644 index 00000000..5297bdb5 --- /dev/null +++ b/ios/WatchRunner Watch App/Services/ImageLoader.swift @@ -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() + } +} diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift new file mode 100644 index 00000000..8c881134 --- /dev/null +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -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() + private let stateSubject = CurrentValueSubject(.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 { + packetSubject.eraseToAnyPublisher() + } + + var stateStream: AnyPublisher { + 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 + } +} diff --git a/ios/WatchRunner Watch App/State/AppState.swift b/ios/WatchRunner Watch App/State/AppState.swift new file mode 100644 index 00000000..df67e909 --- /dev/null +++ b/ios/WatchRunner Watch App/State/AppState.swift @@ -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() + 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() + } +} diff --git a/ios/WatchRunner Watch App/State/WatchConnectivityService.swift b/ios/WatchRunner Watch App/State/WatchConnectivityService.swift new file mode 100644 index 00000000..7db1f9a8 --- /dev/null +++ b/ios/WatchRunner Watch App/State/WatchConnectivityService.swift @@ -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)") + } + } +} diff --git a/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift b/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift new file mode 100644 index 00000000..cbe894ca --- /dev/null +++ b/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift @@ -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) +} diff --git a/ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift b/ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift new file mode 100644 index 00000000..783a708d --- /dev/null +++ b/ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift @@ -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 + } +} diff --git a/ios/WatchRunner Watch App/ViewModels/ComposePostViewModel.swift b/ios/WatchRunner Watch App/ViewModels/ComposePostViewModel.swift new file mode 100644 index 00000000..7a41fe13 --- /dev/null +++ b/ios/WatchRunner Watch App/ViewModels/ComposePostViewModel.swift @@ -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 + } +} diff --git a/ios/WatchRunner Watch App/Views/AccountView.swift b/ios/WatchRunner Watch App/Views/AccountView.swift new file mode 100644 index 00000000..9d5b0ca7 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/AccountView.swift @@ -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()) +} diff --git a/ios/WatchRunner Watch App/Views/ActivityListView.swift b/ios/WatchRunner Watch App/Views/ActivityListView.swift new file mode 100644 index 00000000..50299009 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ActivityListView.swift @@ -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) + } +} diff --git a/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift b/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift new file mode 100644 index 00000000..94b94fc9 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift @@ -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() // 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) + } +} diff --git a/ios/WatchRunner Watch App/Views/AttachmentView.swift b/ios/WatchRunner Watch App/Views/AttachmentView.swift new file mode 100644 index 00000000..cbd50396 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/AttachmentView.swift @@ -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) + } + } + } + } +} diff --git a/ios/WatchRunner Watch App/Views/AudioPlayerView.swift b/ios/WatchRunner Watch App/Views/AudioPlayerView.swift new file mode 100644 index 00000000..0fbb3ab9 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/AudioPlayerView.swift @@ -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() + } +} diff --git a/ios/WatchRunner Watch App/Views/ChatViews.swift b/ios/WatchRunner Watch App/Views/ChatViews.swift new file mode 100644 index 00000000..997c9dc9 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ChatViews.swift @@ -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.. [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() // 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 + } +} diff --git a/ios/WatchRunner Watch App/Views/ComposePostView.swift b/ios/WatchRunner Watch App/Views/ComposePostView.swift new file mode 100644 index 00000000..a78c679a --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ComposePostView.swift @@ -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 ?? "") + }) + } + } +} diff --git a/ios/WatchRunner Watch App/Views/DiscoveryViews.swift b/ios/WatchRunner Watch App/Views/DiscoveryViews.swift new file mode 100644 index 00000000..fb66e62f --- /dev/null +++ b/ios/WatchRunner Watch App/Views/DiscoveryViews.swift @@ -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") + } +} diff --git a/ios/WatchRunner Watch App/Views/ExploreView.swift b/ios/WatchRunner Watch App/Views/ExploreView.swift new file mode 100644 index 00000000..692905c0 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ExploreView.swift @@ -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) + } + } +} diff --git a/ios/WatchRunner Watch App/Views/ImageViewer.swift b/ios/WatchRunner Watch App/Views/ImageViewer.swift new file mode 100644 index 00000000..717e8fe7 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ImageViewer.swift @@ -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) + } +} diff --git a/ios/WatchRunner Watch App/Views/NotificationView.swift b/ios/WatchRunner Watch App/Views/NotificationView.swift new file mode 100644 index 00000000..53444436 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/NotificationView.swift @@ -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) + } +} diff --git a/ios/WatchRunner Watch App/Views/PostViews.swift b/ios/WatchRunner Watch App/Views/PostViews.swift new file mode 100644 index 00000000..248677f5 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/PostViews.swift @@ -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") + } +} diff --git a/ios/WatchRunner Watch App/Views/StatusCreationView.swift b/ios/WatchRunner Watch App/Views/StatusCreationView.swift new file mode 100644 index 00000000..38db1397 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/StatusCreationView.swift @@ -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()) +} diff --git a/ios/WatchRunner Watch App/Views/VideoPlayerView.swift b/ios/WatchRunner Watch App/Views/VideoPlayerView.swift new file mode 100644 index 00000000..f21eec33 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/VideoPlayerView.swift @@ -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 + } +} diff --git a/ios/WatchRunner Watch App/WatchRunnerApp.swift b/ios/WatchRunner Watch App/WatchRunnerApp.swift new file mode 100644 index 00000000..a6d40306 --- /dev/null +++ b/ios/WatchRunner Watch App/WatchRunnerApp.swift @@ -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() + } + } +} diff --git a/ios/WatchRunner-Watch-App-Info.plist b/ios/WatchRunner-Watch-App-Info.plist new file mode 100644 index 00000000..ca9a074a --- /dev/null +++ b/ios/WatchRunner-Watch-App-Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + remote-notification + + +