From 6572875229b3d51ff403133cd136ddb94e2e433a Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 28 Oct 2025 22:29:05 +0800 Subject: [PATCH 01/29] :tada: Created a watchOS app that compiles --- ios/Runner.xcodeproj/project.pbxproj | 223 +++++++++++++++++- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + ios/WatchRunner Watch App/ContentView.swift | 24 ++ .../WatchRunnerApp.swift | 17 ++ 6 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 ios/WatchRunner Watch App/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/WatchRunner Watch App/Assets.xcassets/Contents.json create mode 100644 ios/WatchRunner Watch App/ContentView.swift create mode 100644 ios/WatchRunner Watch App/WatchRunnerApp.swift diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 15098550..f9b5ca52 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, ); }; }; @@ -58,6 +59,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; @@ -100,6 +112,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; }; @@ -162,6 +175,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "WatchRunner Watch App"; + sourceTree = ""; + }; 73268D272DEB012A0076E970 /* Services */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -205,6 +223,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7310A7D12EB10962002C0FD3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 73ACDFA82E3D0E6100B63535 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -303,6 +328,7 @@ 73CDD67B2DEC00480059D95D /* SolianNotificationService */, 73C305CF2E0BE878009035B9 /* SolianShareExtension */, 73ACDFAE2E3D0E6100B63535 /* SolianBroadcastExtension */, + 7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 91E124CE95BCB4DCD890160D /* Pods */, @@ -319,6 +345,7 @@ 73CDD67A2DEC00480059D95D /* SolianNotificationService.appex */, 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */, 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */, + 7310A7D42EB10962002C0FD3 /* WatchRunner Watch App.app */, ); name = Products; sourceTree = ""; @@ -363,6 +390,26 @@ 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 = ( + 7310A7D02EB10962002C0FD3 /* Sources */, + 7310A7D12EB10962002C0FD3 /* Frameworks */, + 7310A7D22EB10962002C0FD3 /* Resources */, + ); + 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 +481,7 @@ 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 73268D1D2DEAFD670076E970 /* Embed Foundation Extensions */, + 7310A7DE2EB10963002C0FD3 /* Embed Watch Content */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, @@ -463,7 +511,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1640; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -471,6 +519,9 @@ CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; }; + 7310A7D32EB10962002C0FD3 = { + CreatedOnToolsVersion = 26.0.1; + }; 73ACDFAA2E3D0E6100B63535 = { CreatedOnToolsVersion = 16.4; }; @@ -504,6 +555,7 @@ 73CDD6792DEC00480059D95D /* SolianNotificationService */, 73C305CD2E0BE878009035B9 /* SolianShareExtension */, 73ACDFAA2E3D0E6100B63535 /* SolianBroadcastExtension */, + 7310A7D32EB10962002C0FD3 /* WatchRunner Watch App */, ); }; /* End PBXProject section */ @@ -516,6 +568,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7310A7D22EB10962002C0FD3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 73ACDFA92E3D0E6100B63535 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -598,10 +657,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -659,10 +722,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -734,6 +801,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7310A7D02EB10962002C0FD3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 73ACDFA72E3D0E6100B63535 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -943,6 +1017,141 @@ }; name = Profile; }; + 7310A7E02EB10963002C0FD3 /* Debug */ = { + isa = XCBuildConfiguration; + 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 = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + 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; + 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 = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + 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; + 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 = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + 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 +1696,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/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..eb878970 --- /dev/null +++ b/ios/WatchRunner Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "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..49c81cd8 --- /dev/null +++ b/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift new file mode 100644 index 00000000..89d953d3 --- /dev/null +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -0,0 +1,24 @@ +// +// ContentView.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/28. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} 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() + } + } +} -- 2.49.1 From 9697def808b27e707591e7f04405da90f0ad94f1 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 28 Oct 2025 23:16:44 +0800 Subject: [PATCH 02/29] :sparkles: Watch connectivity on iOS --- ios/Runner/AppDelegate.swift | 51 +++ ios/WatchRunner Watch App/ContentView.swift | 403 +++++++++++++++++++- 2 files changed, 447 insertions(+), 7 deletions(-) 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/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift index 89d953d3..b6ddc74e 100644 --- a/ios/WatchRunner Watch App/ContentView.swift +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -1,3 +1,4 @@ + // // ContentView.swift // WatchRunner Watch App @@ -6,16 +7,404 @@ // import SwiftUI +import Combine +import WatchConnectivity +// MARK: - App State + +@MainActor +class AppState: ObservableObject { + @Published var token: String? = nil + @Published var serverUrl: String? = nil + @Published var isReady = false + + private var wcService = WatchConnectivityService() + private var cancellables = Set() + + init() { + wcService.$token.combineLatest(wcService.$serverUrl) + .receive(on: DispatchQueue.main) + .sink { [weak self] token, serverUrl in + self?.token = token + self?.serverUrl = serverUrl + if token != nil && serverUrl != nil { + self?.isReady = true + } + } + .store(in: &cancellables) + } + + func requestData() { + wcService.requestDataFromPhone() + } +} + +// MARK: - Watch Connectivity + +class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { + @Published var token: String? + @Published var serverUrl: String? + + private let session: WCSession + + override init() { + self.session = .default + super.init() + print("[watchOS] Activating WCSession") + self.session.delegate = self + self.session.activate() + } + + 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 + } + if let serverUrl = message["serverUrl"] as? String { + self.serverUrl = serverUrl + } + } + } + + func requestDataFromPhone() { + guard session.isReachable else { + print("[watchOS] Phone is not reachable") + return + } + + print("[watchOS] Requesting data from phone") + session.sendMessage(["request": "data"]) { [weak self] response in + print("[watchOS] Received reply: \(response)") + DispatchQueue.main.async { + if let token = response["token"] as? String { + self?.token = token + } + if let serverUrl = response["serverUrl"] as? String { + self?.serverUrl = serverUrl + } + } + } errorHandler: { error in + print("[watchOS] sendMessage failed with error: \(error.localizedDescription)") + } + } +} + + +// 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 CodingKeys: String, CodingKey { + case id, type, data, createdAt + } +} + +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 content: String? + let title: String? +} + +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 description: String? +} + +struct SnWebArticle: Codable, Identifiable { + let id: String + let title: String + let url: String +} + + +// MARK: - Network Service + +class NetworkService { + private let session = URLSession.shared + + func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> [SnActivity] { + 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 + + return try decoder.decode([SnActivity].self, from: data) + } +} + +// MARK: - View Models + +@MainActor +class ActivityViewModel: ObservableObject { + @Published var activities: [SnActivity] = [] + @Published var isLoading = false + @Published var errorMessage: String? + + private let networkService = NetworkService() + let filter: String + + init(filter: String) { + self.filter = filter + } + + func fetchActivities(appState: AppState) async { + guard !isLoading, appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl else { return } + isLoading = true + errorMessage = nil + + do { + let fetchedActivities = try await networkService.fetchActivities(filter: filter, token: token, serverUrl: serverUrl) + self.activities = fetchedActivities + } catch { + self.errorMessage = error.localizedDescription + } + + isLoading = false + } +} + +// MARK: - Views + +struct ActivityListView: View { + @StateObject private var viewModel: ActivityViewModel + @EnvironmentObject var appState: AppState + + init(filter: String) { + _viewModel = StateObject(wrappedValue: ActivityViewModel(filter: filter)) + } + + var body: some View { + Group { + if viewModel.isLoading { + ProgressView() + } else if let errorMessage = viewModel.errorMessage { + VStack { + Text("Error") + .font(.headline) + Text(errorMessage) + .font(.caption) + } + } else { + List(viewModel.activities) { activity in + switch activity.type { + case "posts.new", "posts.new.replies": + if case .post(let post) = activity.data { + PostRowView(post: post) + } + case "discovery": + if case .discovery(let discoveryData) = activity.data { + DiscoveryView(discoveryData: discoveryData) + } + default: + Text("Unknown activity type: \(activity.type)") + } + } + } + } + .task { + await viewModel.fetchActivities(appState: appState) + } + .navigationTitle(viewModel.filter) + .navigationBarTitleDisplayMode(.inline) + } +} + +struct PostRowView: View { + let post: SnPost + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(post.title ?? "Post") + .font(.headline) + if let content = post.content { + Text(content) + .font(.body) + } + } + } +} + +struct DiscoveryView: View { + let discoveryData: DiscoveryData + + var body: some View { + VStack(alignment: .leading) { + Text("Discovery") + .font(.headline) + .padding(.bottom, 2) + ForEach(discoveryData.items) { item in + switch item.data { + case .realm(let realm): + Text("Realm: \(realm.name)") + case .publisher(let publisher): + Text("Publisher: \(publisher.name)") + case .article(let article): + Text("Article: \(article.title)") + case .unknown: + Text("Unknown discovery item") + } + } + } + } +} + + +// The main view with the TabView for filtering. +struct ExploreView: View { + @StateObject private var appState = AppState() + + var body: some View { + Group { + if appState.isReady { + TabView { + NavigationStack { + ActivityListView(filter: "Explore") + } + .tabItem { + Label("Explore", systemImage: "safari") + } + + NavigationStack { + ActivityListView(filter: "Subscriptions") + } + .tabItem { + Label("Subscriptions", systemImage: "star") + } + + NavigationStack { + ActivityListView(filter: "Friends") + } + .tabItem { + Label("Friends", systemImage: "person.2") + } + } + .environmentObject(appState) + } else { + ProgressView { Text("Connecting to phone...") } + .onAppear { + appState.requestData() + } + } + } + } +} + +// The root view of the app. struct ContentView: View { var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() + ExploreView() } } -- 2.49.1 From 0106c088916c6271f828841992f82f585d1f5244 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 28 Oct 2025 23:20:52 +0800 Subject: [PATCH 03/29] :bug: Fix API requesting on watchOS app --- ios/WatchRunner Watch App/ContentView.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ios/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift index b6ddc74e..b3faaab8 100644 --- a/ios/WatchRunner Watch App/ContentView.swift +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -1,4 +1,3 @@ - // // ContentView.swift // WatchRunner Watch App @@ -113,10 +112,6 @@ struct SnActivity: Codable, Identifiable { let type: String let data: ActivityData? let createdAt: Date - - enum CodingKeys: String, CodingKey { - case id, type, data, createdAt - } } enum ActivityData: Codable { @@ -239,6 +234,7 @@ class NetworkService { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .convertFromSnakeCase return try decoder.decode([SnActivity].self, from: data) } @@ -269,6 +265,7 @@ class ActivityViewModel: ObservableObject { self.activities = fetchedActivities } catch { self.errorMessage = error.localizedDescription + print("[watchOS] fetchActivities failed with error: \(error)") } isLoading = false @@ -291,11 +288,15 @@ struct ActivityListView: View { ProgressView() } else if let errorMessage = viewModel.errorMessage { VStack { - Text("Error") + Text("Error fetching data") .font(.headline) Text(errorMessage) .font(.caption) + .lineLimit(nil) } + .padding() + } else if viewModel.activities.isEmpty { + Text("No activities found.") } else { List(viewModel.activities) { activity in switch activity.type { @@ -410,4 +411,4 @@ struct ContentView: View { #Preview { ContentView() -} +} \ No newline at end of file -- 2.49.1 From d4cf598f69de32d254c8759de5240151caf7fe2e Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 29 Oct 2025 00:47:23 +0800 Subject: [PATCH 04/29] :sparkles: Image rendering on watchOS --- ios/Podfile | 15 +- ios/Podfile.lock | 22 +- ios/Runner.xcodeproj/project.pbxproj | 64 ++- ios/WatchRunner Watch App/ContentView.swift | 523 ++++++++++++++++++-- 4 files changed, 587 insertions(+), 37 deletions(-) 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 f9b5ca52..44828bb4 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -21,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 */; }; @@ -96,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 = ""; }; @@ -124,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 = ""; }; @@ -133,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; }; @@ -227,6 +232,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A1D34487886D362AC8B99B2E /* Pods_WatchRunner_Watch_App.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -283,6 +289,7 @@ 7B40764A2C4CC0E7DC70A0D3 /* Pods_SolianShareExtension.framework */, 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */, 73ACDFB82E3D0E6100B63535 /* UIKit.framework */, + 802C1CFCA7F1E069AAEFB454 /* Pods_WatchRunner_Watch_App.framework */, ); name = Frameworks; sourceTree = ""; @@ -305,6 +312,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 = ""; @@ -394,9 +404,11 @@ 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 = ( ); @@ -750,6 +762,49 @@ 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", + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + 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; @@ -1019,6 +1074,7 @@ }; 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; @@ -1033,7 +1089,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = W7HPZ53V6B; ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; @@ -1066,6 +1122,7 @@ }; 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; @@ -1080,7 +1137,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = W7HPZ53V6B; ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; @@ -1110,6 +1167,7 @@ }; 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; @@ -1124,7 +1182,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = W7HPZ53V6B; ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = WatchRunner; diff --git a/ios/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift index b3faaab8..d4af4eda 100644 --- a/ios/WatchRunner Watch App/ContentView.swift +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -1,3 +1,4 @@ + // // ContentView.swift // WatchRunner Watch App @@ -8,6 +9,8 @@ import SwiftUI import Combine import WatchConnectivity +import Kingfisher // Import Kingfisher +import KingfisherWebP // Import KingfisherWebP // MARK: - App State @@ -139,8 +142,11 @@ enum ActivityData: Codable { struct SnPost: Codable, Identifiable { let id: String - let content: String? let title: String? + let content: String? + let publisher: SnPublisher + let attachments: [SnCloudFile] + let tags: [SnPostTag] } struct DiscoveryData: Codable { @@ -194,7 +200,20 @@ struct SnRealm: Codable, Identifiable { 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 { @@ -203,6 +222,18 @@ struct SnWebArticle: Codable, Identifiable { let url: String } +// 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)" + } + print("[watchOS] Generated image URL: \(urlString)") + return URL(string: urlString) +} // MARK: - Network Service @@ -210,7 +241,7 @@ class NetworkService { private let session = URLSession.shared func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> [SnActivity] { - guard let baseURL = URL(string: serverUrl) else { + guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } var components = URLComponents(url: baseURL.appendingPathComponent("/sphere/activities"), resolvingAgainstBaseURL: false)! @@ -250,13 +281,19 @@ class ActivityViewModel: ObservableObject { private let networkService = NetworkService() let filter: String - - init(filter: String) { + private var isMock = false + + init(filter: String, mockActivities: [SnActivity]? = nil) { self.filter = filter + if let mockActivities = mockActivities { + self.activities = mockActivities + self.isMock = true + } } - func fetchActivities(appState: AppState) async { - guard !isLoading, appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl else { return } + func fetchActivities(token: String, serverUrl: String) async { + if isMock { return } + guard !isLoading else { return } isLoading = true errorMessage = nil @@ -272,14 +309,178 @@ class ActivityViewModel: ObservableObject { } } +// 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 + } +} + +// MARK: - Image Loader + +@MainActor +class ImageLoader: ObservableObject { + @Published var image: Image? + @Published var errorMessage: String? + @Published var isLoading = false + + private var dataTask: URLSessionDataTask? + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func loadImage(from initialUrl: URL, token: String) async { + isLoading = true + errorMessage = nil + image = nil + + do { + // First request with Authorization header + var request = URLRequest(url: initialUrl) + 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 { + if httpResponse.statusCode == 302, let redirectLocation = httpResponse.allHeaderFields["Location"] as? String, let redirectUrl = URL(string: redirectLocation) { + print("[watchOS] Redirecting to: \(redirectUrl)") + // Second request to the redirected URL (S3 signed URL) without Authorization header + let (redirectData, _) = try await session.data(from: redirectUrl) + if let uiImage = UIImage(data: redirectData) { + self.image = Image(uiImage: uiImage) + } else { + // Try KingfisherWebP for WebP + let processor = WebPProcessor.default // Correct usage + if let kfImage = processor.process(item: .data(redirectData), options: KingfisherParsedOptionsInfo( + [ + .processor(processor), + .loadDiskFileSynchronously, + .cacheOriginalImage + ] + )) { + self.image = Image(uiImage: kfImage) + } else { + self.errorMessage = "Invalid image data from redirect (could not decode with KingfisherWebP)." + } + } + } else if httpResponse.statusCode == 200 { + if let uiImage = UIImage(data: data) { + self.image = Image(uiImage: uiImage) + } else { + // Try KingfisherWebP for WebP + let processor = WebPProcessor.default // Correct usage + if let kfImage = processor.process(item: .data(data), options: KingfisherParsedOptionsInfo( + [ + .processor(processor), + .loadDiskFileSynchronously, + .cacheOriginalImage + ] + )) { + self.image = Image(uiImage: kfImage) + } else { + self.errorMessage = "Invalid image data (could not decode with KingfisherWebP)." + } + } + } else { + self.errorMessage = "HTTP Status Code: \(httpResponse.statusCode)" + } + } + } catch { + self.errorMessage = error.localizedDescription + print("[watchOS] Image loading failed: \(error.localizedDescription)") + } + isLoading = false + } + + func cancel() { + dataTask?.cancel() + } +} + // MARK: - Views struct ActivityListView: View { @StateObject private var viewModel: ActivityViewModel @EnvironmentObject var appState: AppState - init(filter: String) { - _viewModel = StateObject(wrappedValue: ActivityViewModel(filter: filter)) + init(filter: String, mockActivities: [SnActivity]? = nil) { + _viewModel = StateObject(wrappedValue: ActivityViewModel(filter: filter, mockActivities: mockActivities)) } var body: some View { @@ -302,7 +503,9 @@ struct ActivityListView: View { switch activity.type { case "posts.new", "posts.new.replies": if case .post(let post) = activity.data { - PostRowView(post: post) + NavigationLink(destination: PostDetailView(post: post)) { + PostRowView(post: post) + } } case "discovery": if case .discovery(let discoveryData) = activity.data { @@ -315,7 +518,10 @@ struct ActivityListView: View { } } .task { - await viewModel.fetchActivities(appState: appState) + // Only fetch if appState is ready and token/serverUrl are available + if appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl { + await viewModel.fetchActivities(token: token, serverUrl: serverUrl) + } } .navigationTitle(viewModel.filter) .navigationBarTitleDisplayMode(.inline) @@ -324,12 +530,51 @@ struct ActivityListView: View { 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) { - Text(post.title ?? "Post") - .font(.headline) - if let content = post.content { + HStack { + if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { + if imageLoader.isLoading { + ProgressView() + .frame(width: 24, height: 24) + } else if let image = imageLoader.image { + image + .resizable() + .frame(width: 24, height: 24) + .clipShape(Circle()) + } else if let errorMessage = imageLoader.errorMessage { + Text("Failed: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + .frame(width: 24, height: 24) + } else { + // Placeholder if no image and not loading + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 24, height: 24) + .clipShape(Circle()) + .foregroundColor(.gray) + } + } + 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) } @@ -337,30 +582,218 @@ struct PostRowView: View { } } +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 let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { + if publisherImageLoader.isLoading { + ProgressView() + .frame(width: 32, height: 32) + } else if let image = publisherImageLoader.image { + image + .resizable() + .frame(width: 32, height: 32) + .clipShape(Circle()) + } else if let errorMessage = publisherImageLoader.errorMessage { + Text("Failed: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + .frame(width: 32, height: 32) + } else { + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 32, height: 32) + .clipShape(Circle()) + .foregroundColor(.gray) + } + } + Text("@\(post.publisher.name)") + .font(.headline) + } + .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 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 { + Divider() + Text("Attachments").font(.headline) + ForEach(post.attachments) { attachment in + AttachmentImageView(attachment: attachment) + } + } + + if !post.tags.isEmpty { + Divider() + Text("Tags").font(.headline) + FlowLayout(alignment: .leading, spacing: 4) { + ForEach(post.tags) { tag in + Text("#\(tag.name ?? tag.slug)") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Color.accentColor.opacity(0.2))) + .cornerRadius(5) + } + } + } + } + .padding() + } + .navigationTitle("Post") + } +} + +struct AttachmentImageView: View { + let attachment: SnCloudFile + @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) + } else if let errorMessage = imageLoader.errorMessage { + Text("Failed to load attachment: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + } else { + Text("File: \(attachment.id)") + } + } + .task(id: attachment.id) { + if let serverUrl = appState.serverUrl, let imageUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl), let token = appState.token, attachment.mimeType?.starts(with: "image") == true { + await imageLoader.loadImage(from: imageUrl, token: token) + } + } + } +} + struct DiscoveryView: View { let discoveryData: DiscoveryData var body: some View { - VStack(alignment: .leading) { - Text("Discovery") - .font(.headline) - .padding(.bottom, 2) - ForEach(discoveryData.items) { item in - switch item.data { - case .realm(let realm): - Text("Realm: \(realm.name)") - case .publisher(let publisher): - Text("Publisher: \(publisher.name)") - case .article(let article): - Text("Article: \(article.title)") - case .unknown: - Text("Unknown discovery item") - } + 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") + } +} + // The main view with the TabView for filtering. struct ExploreView: View { @@ -409,6 +842,36 @@ struct ContentView: View { } } +#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 + #Preview { - ContentView() -} \ No newline at end of file + NavigationStack { + ActivityListView(filter: "Preview", mockActivities: SnActivity.mock) + .environmentObject(AppState()) + } +} -- 2.49.1 From 1a37d384e6b28e149928c49e45ace58bb332b9f0 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 29 Oct 2025 01:26:27 +0800 Subject: [PATCH 05/29] :recycle: Refactor watchOS content view --- ios/WatchRunner Watch App/ContentView.swift | 864 +----------------- .../Layouts/FlowLayout.swift | 88 ++ ios/WatchRunner Watch App/Models/Models.swift | 126 +++ .../Previews/CustomPreviews.swift | 15 + .../Previews/MockData.swift | 35 + .../Services/ImageLoader.swift | 103 +++ .../Services/NetworkService.swift | 69 ++ .../State/AppState.swift | 38 + .../State/WatchConnectivityService.swift | 72 ++ .../Utils/AttachmentUtils.swift | 21 + .../ViewModels/ActivityViewModel.swift | 47 + .../ViewModels/ComposePostViewModel.swift | 35 + .../Views/ActivityListView.swift | 63 ++ .../Views/AttachmentImageView.swift | 38 + .../Views/ComposePostView.swift | 52 ++ .../Views/DiscoveryViews.swift | 110 +++ .../Views/ExploreView.swift | 63 ++ .../Views/PostViews.swift | 142 +++ 18 files changed, 1118 insertions(+), 863 deletions(-) create mode 100644 ios/WatchRunner Watch App/Layouts/FlowLayout.swift create mode 100644 ios/WatchRunner Watch App/Models/Models.swift create mode 100644 ios/WatchRunner Watch App/Previews/CustomPreviews.swift create mode 100644 ios/WatchRunner Watch App/Previews/MockData.swift create mode 100644 ios/WatchRunner Watch App/Services/ImageLoader.swift create mode 100644 ios/WatchRunner Watch App/Services/NetworkService.swift create mode 100644 ios/WatchRunner Watch App/State/AppState.swift create mode 100644 ios/WatchRunner Watch App/State/WatchConnectivityService.swift create mode 100644 ios/WatchRunner Watch App/Utils/AttachmentUtils.swift create mode 100644 ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift create mode 100644 ios/WatchRunner Watch App/ViewModels/ComposePostViewModel.swift create mode 100644 ios/WatchRunner Watch App/Views/ActivityListView.swift create mode 100644 ios/WatchRunner Watch App/Views/AttachmentImageView.swift create mode 100644 ios/WatchRunner Watch App/Views/ComposePostView.swift create mode 100644 ios/WatchRunner Watch App/Views/DiscoveryViews.swift create mode 100644 ios/WatchRunner Watch App/Views/ExploreView.swift create mode 100644 ios/WatchRunner Watch App/Views/PostViews.swift diff --git a/ios/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift index d4af4eda..47e444c9 100644 --- a/ios/WatchRunner Watch App/ContentView.swift +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -1,4 +1,3 @@ - // // ContentView.swift // WatchRunner Watch App @@ -7,871 +6,10 @@ // import SwiftUI -import Combine -import WatchConnectivity -import Kingfisher // Import Kingfisher -import KingfisherWebP // Import KingfisherWebP - -// MARK: - App State - -@MainActor -class AppState: ObservableObject { - @Published var token: String? = nil - @Published var serverUrl: String? = nil - @Published var isReady = false - - private var wcService = WatchConnectivityService() - private var cancellables = Set() - - init() { - wcService.$token.combineLatest(wcService.$serverUrl) - .receive(on: DispatchQueue.main) - .sink { [weak self] token, serverUrl in - self?.token = token - self?.serverUrl = serverUrl - if token != nil && serverUrl != nil { - self?.isReady = true - } - } - .store(in: &cancellables) - } - - func requestData() { - wcService.requestDataFromPhone() - } -} - -// MARK: - Watch Connectivity - -class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { - @Published var token: String? - @Published var serverUrl: String? - - private let session: WCSession - - override init() { - self.session = .default - super.init() - print("[watchOS] Activating WCSession") - self.session.delegate = self - self.session.activate() - } - - 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 - } - if let serverUrl = message["serverUrl"] as? String { - self.serverUrl = serverUrl - } - } - } - - func requestDataFromPhone() { - guard session.isReachable else { - print("[watchOS] Phone is not reachable") - return - } - - print("[watchOS] Requesting data from phone") - session.sendMessage(["request": "data"]) { [weak self] response in - print("[watchOS] Received reply: \(response)") - DispatchQueue.main.async { - if let token = response["token"] as? String { - self?.token = token - } - if let serverUrl = response["serverUrl"] as? String { - self?.serverUrl = serverUrl - } - } - } errorHandler: { error in - print("[watchOS] sendMessage failed with error: \(error.localizedDescription)") - } - } -} - - -// 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 -} - -// 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)" - } - print("[watchOS] Generated image URL: \(urlString)") - return URL(string: urlString) -} - -// MARK: - Network Service - -class NetworkService { - private let session = URLSession.shared - - func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> [SnActivity] { - 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 - - return try decoder.decode([SnActivity].self, from: data) - } -} - -// MARK: - View Models - -@MainActor -class ActivityViewModel: ObservableObject { - @Published var activities: [SnActivity] = [] - @Published var isLoading = false - @Published var errorMessage: String? - - private let networkService = NetworkService() - let filter: String - private var isMock = false - - 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 { return } - guard !isLoading else { return } - isLoading = true - errorMessage = nil - - do { - let fetchedActivities = try await networkService.fetchActivities(filter: filter, token: token, serverUrl: serverUrl) - self.activities = fetchedActivities - } catch { - self.errorMessage = error.localizedDescription - print("[watchOS] fetchActivities failed with error: \(error)") - } - - isLoading = false - } -} - -// 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 - } -} - -// MARK: - Image Loader - -@MainActor -class ImageLoader: ObservableObject { - @Published var image: Image? - @Published var errorMessage: String? - @Published var isLoading = false - - private var dataTask: URLSessionDataTask? - private let session: URLSession - - init(session: URLSession = .shared) { - self.session = session - } - - func loadImage(from initialUrl: URL, token: String) async { - isLoading = true - errorMessage = nil - image = nil - - do { - // First request with Authorization header - var request = URLRequest(url: initialUrl) - 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 { - if httpResponse.statusCode == 302, let redirectLocation = httpResponse.allHeaderFields["Location"] as? String, let redirectUrl = URL(string: redirectLocation) { - print("[watchOS] Redirecting to: \(redirectUrl)") - // Second request to the redirected URL (S3 signed URL) without Authorization header - let (redirectData, _) = try await session.data(from: redirectUrl) - if let uiImage = UIImage(data: redirectData) { - self.image = Image(uiImage: uiImage) - } else { - // Try KingfisherWebP for WebP - let processor = WebPProcessor.default // Correct usage - if let kfImage = processor.process(item: .data(redirectData), options: KingfisherParsedOptionsInfo( - [ - .processor(processor), - .loadDiskFileSynchronously, - .cacheOriginalImage - ] - )) { - self.image = Image(uiImage: kfImage) - } else { - self.errorMessage = "Invalid image data from redirect (could not decode with KingfisherWebP)." - } - } - } else if httpResponse.statusCode == 200 { - if let uiImage = UIImage(data: data) { - self.image = Image(uiImage: uiImage) - } else { - // Try KingfisherWebP for WebP - let processor = WebPProcessor.default // Correct usage - if let kfImage = processor.process(item: .data(data), options: KingfisherParsedOptionsInfo( - [ - .processor(processor), - .loadDiskFileSynchronously, - .cacheOriginalImage - ] - )) { - self.image = Image(uiImage: kfImage) - } else { - self.errorMessage = "Invalid image data (could not decode with KingfisherWebP)." - } - } - } else { - self.errorMessage = "HTTP Status Code: \(httpResponse.statusCode)" - } - } - } catch { - self.errorMessage = error.localizedDescription - print("[watchOS] Image loading failed: \(error.localizedDescription)") - } - isLoading = false - } - - func cancel() { - dataTask?.cancel() - } -} - -// 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(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)) { - PostRowView(post: post) - } - } - case "discovery": - if case .discovery(let discoveryData) = activity.data { - DiscoveryView(discoveryData: discoveryData) - } - default: - Text("Unknown activity type: \(activity.type)") - } - } - } - } - .task { - // Only fetch if appState is ready and token/serverUrl are available - if appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl { - await viewModel.fetchActivities(token: token, serverUrl: serverUrl) - } - } - .navigationTitle(viewModel.filter) - .navigationBarTitleDisplayMode(.inline) - } -} - -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 let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { - if imageLoader.isLoading { - ProgressView() - .frame(width: 24, height: 24) - } else if let image = imageLoader.image { - image - .resizable() - .frame(width: 24, height: 24) - .clipShape(Circle()) - } else if let errorMessage = imageLoader.errorMessage { - Text("Failed: \(errorMessage)") - .font(.caption) - .foregroundColor(.red) - .frame(width: 24, height: 24) - } else { - // Placeholder if no image and not loading - Image(systemName: "person.circle.fill") - .resizable() - .frame(width: 24, height: 24) - .clipShape(Circle()) - .foregroundColor(.gray) - } - } - 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) - } - } - } -} - -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 let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { - if publisherImageLoader.isLoading { - ProgressView() - .frame(width: 32, height: 32) - } else if let image = publisherImageLoader.image { - image - .resizable() - .frame(width: 32, height: 32) - .clipShape(Circle()) - } else if let errorMessage = publisherImageLoader.errorMessage { - Text("Failed: \(errorMessage)") - .font(.caption) - .foregroundColor(.red) - .frame(width: 32, height: 32) - } else { - Image(systemName: "person.circle.fill") - .resizable() - .frame(width: 32, height: 32) - .clipShape(Circle()) - .foregroundColor(.gray) - } - } - Text("@\(post.publisher.name)") - .font(.headline) - } - .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 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 { - Divider() - Text("Attachments").font(.headline) - ForEach(post.attachments) { attachment in - AttachmentImageView(attachment: attachment) - } - } - - if !post.tags.isEmpty { - Divider() - Text("Tags").font(.headline) - FlowLayout(alignment: .leading, spacing: 4) { - ForEach(post.tags) { tag in - Text("#\(tag.name ?? tag.slug)") - .font(.caption) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(Capsule().fill(Color.accentColor.opacity(0.2))) - .cornerRadius(5) - } - } - } - } - .padding() - } - .navigationTitle("Post") - } -} - -struct AttachmentImageView: View { - let attachment: SnCloudFile - @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) - } else if let errorMessage = imageLoader.errorMessage { - Text("Failed to load attachment: \(errorMessage)") - .font(.caption) - .foregroundColor(.red) - } else { - Text("File: \(attachment.id)") - } - } - .task(id: attachment.id) { - if let serverUrl = appState.serverUrl, let imageUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl), let token = appState.token, attachment.mimeType?.starts(with: "image") == true { - await imageLoader.loadImage(from: imageUrl, token: token) - } - } - } -} - -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") - } -} - - -// The main view with the TabView for filtering. -struct ExploreView: View { - @StateObject private var appState = AppState() - - var body: some View { - Group { - if appState.isReady { - TabView { - NavigationStack { - ActivityListView(filter: "Explore") - } - .tabItem { - Label("Explore", systemImage: "safari") - } - - NavigationStack { - ActivityListView(filter: "Subscriptions") - } - .tabItem { - Label("Subscriptions", systemImage: "star") - } - - NavigationStack { - ActivityListView(filter: "Friends") - } - .tabItem { - Label("Friends", systemImage: "person.2") - } - } - .environmentObject(appState) - } else { - ProgressView { Text("Connecting to phone...") } - .onAppear { - appState.requestData() - } - } - } - } -} // The root view of the app. struct ContentView: View { var body: some View { ExploreView() } -} - -#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 - -#Preview { - NavigationStack { - ActivityListView(filter: "Preview", mockActivities: SnActivity.mock) - .environmentObject(AppState()) - } -} +} \ No newline at end of file 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..ff6d83bd --- /dev/null +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -0,0 +1,126 @@ +// +// 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 +} 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..3f4e1b86 --- /dev/null +++ b/ios/WatchRunner Watch App/Services/ImageLoader.swift @@ -0,0 +1,103 @@ +// +// 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 dataTask: URLSessionDataTask? + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + deinit { + dataTask?.cancel() + } + + func loadImage(from initialUrl: URL, token: String) async { + isLoading = true + errorMessage = nil + image = nil + + do { + // First request with Authorization header + var request = URLRequest(url: initialUrl) + 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 { + if httpResponse.statusCode == 302, let redirectLocation = httpResponse.allHeaderFields["Location"] as? String, let redirectUrl = URL(string: redirectLocation) { + print("[watchOS] Redirecting to: \(redirectUrl)") + // Second request to the redirected URL (S3 signed URL) without Authorization header + let (redirectData, _) = try await session.data(from: redirectUrl) + if let uiImage = UIImage(data: redirectData) { + self.image = Image(uiImage: uiImage) + print("[watchOS] Image loaded successfully from redirect URL.") + } else { + // Try KingfisherWebP for WebP + let processor = WebPProcessor.default // Correct usage + if let kfImage = processor.process(item: .data(redirectData), options: KingfisherParsedOptionsInfo( + [ + .processor(processor), + .loadDiskFileSynchronously, + .cacheOriginalImage + ] + )) { + self.image = Image(uiImage: kfImage) + print("[watchOS] Image loaded successfully from redirect URL using KingfisherWebP.") + } else { + self.errorMessage = "Invalid image data from redirect (could not decode with KingfisherWebP)." + } + } + } else if httpResponse.statusCode == 200 { + if let uiImage = UIImage(data: data) { + self.image = Image(uiImage: uiImage) + print("[watchOS] Image loaded successfully from initial URL.") + } else { + // Try KingfisherWebP for WebP + let processor = WebPProcessor.default // Correct usage + if let kfImage = processor.process(item: .data(data), options: KingfisherParsedOptionsInfo( + [ + .processor(processor), + .loadDiskFileSynchronously, + .cacheOriginalImage + ] + )) { + self.image = Image(uiImage: kfImage) + print("[watchOS] Image loaded successfully from initial URL using KingfisherWebP.") + } else { + self.errorMessage = "Invalid image data (could not decode with KingfisherWebP)." + } + } + } else { + self.errorMessage = "HTTP Status Code: \(httpResponse.statusCode)" + } + } + } catch { + self.errorMessage = error.localizedDescription + print("[watchOS] Image loading failed: \(error.localizedDescription)") + } + isLoading = false + } + + func cancel() { + dataTask?.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..ed9bc0b4 --- /dev/null +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -0,0 +1,69 @@ +// +// NetworkService.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import Foundation + +// MARK: - Network Service + +class NetworkService { + private let session = URLSession.shared + + func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> [SnActivity] { + 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 + + return try decoder.decode([SnActivity].self, from: data) + } + + 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)) + } + } +} diff --git a/ios/WatchRunner Watch App/State/AppState.swift b/ios/WatchRunner Watch App/State/AppState.swift new file mode 100644 index 00000000..3e75a0a6 --- /dev/null +++ b/ios/WatchRunner Watch App/State/AppState.swift @@ -0,0 +1,38 @@ +// +// AppState.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI +import Combine + +// MARK: - App State + +@MainActor +class AppState: ObservableObject { + @Published var token: String? = nil + @Published var serverUrl: String? = nil + @Published var isReady = false + + private var wcService = WatchConnectivityService() + private var cancellables = Set() + + init() { + wcService.$token.combineLatest(wcService.$serverUrl) + .receive(on: DispatchQueue.main) + .sink { [weak self] token, serverUrl in + self?.token = token + self?.serverUrl = serverUrl + if token != nil && serverUrl != nil { + self?.isReady = true + } + } + .store(in: &cancellables) + } + + func requestData() { + wcService.requestDataFromPhone() + } +} diff --git a/ios/WatchRunner Watch App/State/WatchConnectivityService.swift b/ios/WatchRunner Watch App/State/WatchConnectivityService.swift new file mode 100644 index 00000000..c246edd2 --- /dev/null +++ b/ios/WatchRunner Watch App/State/WatchConnectivityService.swift @@ -0,0 +1,72 @@ +// +// WatchConnectivityService.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import Foundation +import WatchConnectivity +import Combine + +// MARK: - Watch Connectivity + +class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { + @Published var token: String? + @Published var serverUrl: String? + + private let session: WCSession + + override init() { + self.session = .default + super.init() + print("[watchOS] Activating WCSession") + self.session.delegate = self + self.session.activate() + } + + 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 + } + if let serverUrl = message["serverUrl"] as? String { + self.serverUrl = serverUrl + } + } + } + + func requestDataFromPhone() { + guard session.isReachable else { + print("[watchOS] Phone is not reachable") + return + } + + print("[watchOS] Requesting data from phone") + session.sendMessage(["request": "data"]) { [weak self] response in + print("[watchOS] Received reply: \(response)") + DispatchQueue.main.async { + if let token = response["token"] as? String { + self?.token = token + } + if let serverUrl = response["serverUrl"] as? String { + self?.serverUrl = serverUrl + } + } + } 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..510340f2 --- /dev/null +++ b/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift @@ -0,0 +1,21 @@ +// +// 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)" + } + print("[watchOS] Generated image URL: \(urlString)") + 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..c5a3fde2 --- /dev/null +++ b/ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift @@ -0,0 +1,47 @@ +// +// 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 errorMessage: String? + + private let networkService = NetworkService() + let filter: String + private var isMock = false + + 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 { return } + guard !isLoading else { return } + isLoading = true + errorMessage = nil + + do { + let fetchedActivities = try await networkService.fetchActivities(filter: filter, token: token, serverUrl: serverUrl) + self.activities = fetchedActivities + } catch { + self.errorMessage = error.localizedDescription + print("[watchOS] fetchActivities failed with error: \(error)") + } + + isLoading = 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/ActivityListView.swift b/ios/WatchRunner Watch App/Views/ActivityListView.swift new file mode 100644 index 00000000..8ac17ba3 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ActivityListView.swift @@ -0,0 +1,63 @@ +// +// 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(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)) { + PostRowView(post: post) + } + } + case "discovery": + if case .discovery(let discoveryData) = activity.data { + DiscoveryView(discoveryData: discoveryData) + } + default: + Text("Unknown activity type: \(activity.type)") + } + } + } + } + .task { + // Only fetch if appState is ready and token/serverUrl are available + if appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl { + await viewModel.fetchActivities(token: token, serverUrl: serverUrl) + } + } + .navigationTitle(viewModel.filter) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/ios/WatchRunner Watch App/Views/AttachmentImageView.swift b/ios/WatchRunner Watch App/Views/AttachmentImageView.swift new file mode 100644 index 00000000..a6b35ef5 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/AttachmentImageView.swift @@ -0,0 +1,38 @@ +// +// AttachmentImageView.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI + +struct AttachmentImageView: View { + let attachment: SnCloudFile + @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) + } else if let errorMessage = imageLoader.errorMessage { + Text("Failed to load attachment: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + } else { + Text("File: \(attachment.id)") + } + } + .task(id: attachment.id) { + if let serverUrl = appState.serverUrl, let imageUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl), let token = appState.token, attachment.mimeType?.starts(with: "image") == true { + await imageLoader.loadImage(from: imageUrl, token: token) + } + } + } +} diff --git a/ios/WatchRunner Watch App/Views/ComposePostView.swift b/ios/WatchRunner Watch App/Views/ComposePostView.swift new file mode 100644 index 00000000..7d1b0324 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ComposePostView.swift @@ -0,0 +1,52 @@ +// +// 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) + .frame(height: 100) + } + .navigationTitle("New Post") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Post") { + Task { + if let token = appState.token, let serverUrl = appState.serverUrl { + await viewModel.createPost(token: token, serverUrl: serverUrl) + } + } + } + .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..d6b043de --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ExploreView.swift @@ -0,0 +1,63 @@ +// +// 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 + + var body: some View { + NavigationStack { + Group { + if appState.isReady { + TabView { + NavigationStack { + ActivityListView(filter: "Explore") + } + .tabItem { + Label("Explore", systemImage: "safari") + } + + NavigationStack { + ActivityListView(filter: "Subscriptions") + } + .tabItem { + Label("Subscriptions", systemImage: "star") + } + + NavigationStack { + ActivityListView(filter: "Friends") + } + .tabItem { + Label("Friends", systemImage: "person.2") + } + } + .environmentObject(appState) + } else { + ProgressView { Text("Connecting to phone...") } + .onAppear { + appState.requestData() + } + } + } + .navigationTitle("Explore") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { isComposing = true }) { + Label("Compose", systemImage: "plus") + } + } + } + .sheet(isPresented: $isComposing) { + ComposePostView() + .environmentObject(appState) + } + } + } +} \ No newline at end of file diff --git a/ios/WatchRunner Watch App/Views/PostViews.swift b/ios/WatchRunner Watch App/Views/PostViews.swift new file mode 100644 index 00000000..8007eccc --- /dev/null +++ b/ios/WatchRunner Watch App/Views/PostViews.swift @@ -0,0 +1,142 @@ +// +// 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 let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { + if imageLoader.isLoading { + ProgressView() + .frame(width: 24, height: 24) + } else if let image = imageLoader.image { + image + .resizable() + .frame(width: 24, height: 24) + .clipShape(Circle()) + } else if let errorMessage = imageLoader.errorMessage { + Text("Failed: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + .frame(width: 24, height: 24) + } else { + // Placeholder if no image and not loading + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 24, height: 24) + .clipShape(Circle()) + .foregroundColor(.gray) + } + } + 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) + } + } + } +} + +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 let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { + if publisherImageLoader.isLoading { + ProgressView() + .frame(width: 32, height: 32) + } else if let image = publisherImageLoader.image { + image + .resizable() + .frame(width: 32, height: 32) + .clipShape(Circle()) + } else if let errorMessage = publisherImageLoader.errorMessage { + Text("Failed: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + .frame(width: 32, height: 32) + } else { + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 32, height: 32) + .clipShape(Circle()) + .foregroundColor(.gray) + } + } + Text("@\(post.publisher.name)") + .font(.headline) + } + .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 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 { + Divider() + Text("Attachments").font(.headline) + ForEach(post.attachments) { attachment in + AttachmentImageView(attachment: attachment) + } + } + + if !post.tags.isEmpty { + Divider() + Text("Tags").font(.headline) + FlowLayout(alignment: .leading, spacing: 4) { + ForEach(post.tags) { tag in + Text("#\(tag.name ?? tag.slug)") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Color.accentColor.opacity(0.2))) + .cornerRadius(5) + } + } + } + } + .padding() + } + .navigationTitle("Post") + } +} -- 2.49.1 From 926ae5402fed1e1a63770e263843fb03ee8e7431 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 29 Oct 2025 01:50:27 +0800 Subject: [PATCH 06/29] :bug: Fix bugs --- .../AppIcon.appiconset/Contents.json | 1 + .../AppIcon.appiconset/icon.png | Bin 0 -> 71375 bytes .../ViewModels/ActivityViewModel.swift | 7 ++- .../Views/ActivityListView.swift | 21 ++++--- .../Views/ExploreView.swift | 54 ++++++++---------- 5 files changed, 43 insertions(+), 40 deletions(-) create mode 100644 ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/icon.png diff --git a/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json index 49c81cd8..110a3e31 100644 --- a/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "icon.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" diff --git a/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/icon.png b/ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0eeb8c11185064aeed6a0a0696e691d3ea92530a GIT binary patch literal 71375 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 Date: Wed, 29 Oct 2025 13:09:15 +0800 Subject: [PATCH 07/29] :bento: Add accent color to watchOS --- .../Assets.xcassets/AccentColor.colorset/Contents.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ios/WatchRunner Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/WatchRunner Watch App/Assets.xcassets/AccentColor.colorset/Contents.json index eb878970..4bf40ac6 100644 --- a/ios/WatchRunner Watch App/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/ios/WatchRunner Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,10 @@ { "colors" : [ { + "color" : { + "platform" : "universal", + "reference" : "systemIndigoColor" + }, "idiom" : "universal" } ], -- 2.49.1 From ad91b17af7eee2bf6cfbc09a711076462bed05e7 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 29 Oct 2025 13:13:13 +0800 Subject: [PATCH 08/29] :zap: Cache the data fetched from phone in watch --- .../State/WatchConnectivityService.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ios/WatchRunner Watch App/State/WatchConnectivityService.swift b/ios/WatchRunner Watch App/State/WatchConnectivityService.swift index c246edd2..720665c4 100644 --- a/ios/WatchRunner Watch App/State/WatchConnectivityService.swift +++ b/ios/WatchRunner Watch App/State/WatchConnectivityService.swift @@ -16,6 +16,9 @@ class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { @Published var serverUrl: String? private let session: WCSession + private let userDefaults = UserDefaults.standard + private let tokenKey = "token" + private let serverUrlKey = "serverUrl" override init() { self.session = .default @@ -23,6 +26,10 @@ class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { print("[watchOS] Activating WCSession") self.session.delegate = self self.session.activate() + + // Load cached data + self.token = userDefaults.string(forKey: tokenKey) + self.serverUrl = userDefaults.string(forKey: serverUrlKey) } func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { @@ -41,9 +48,11 @@ class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { 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) } } } @@ -56,13 +65,16 @@ class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { print("[watchOS] Requesting data from phone") session.sendMessage(["request": "data"]) { [weak self] response in + guard let self = self else { return } print("[watchOS] Received reply: \(response)") DispatchQueue.main.async { if let token = response["token"] as? String { - self?.token = token + self.token = token + self.userDefaults.set(token, forKey: self.tokenKey) } if let serverUrl = response["serverUrl"] as? String { - self?.serverUrl = serverUrl + self.serverUrl = serverUrl + self.userDefaults.set(serverUrl, forKey: self.serverUrlKey) } } } errorHandler: { error in -- 2.49.1 From fcbd5fe6804754690fac126d695ca8a7797dd6c2 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 29 Oct 2025 21:44:33 +0800 Subject: [PATCH 09/29] :sparkles: watchOS showing video --- .../Views/AttachmentView.swift | 96 +++++++++++++++++++ .../Views/AudioPlayerView.swift | 47 +++++++++ ...hmentImageView.swift => ImageViewer.swift} | 24 ++--- .../Views/PostViews.swift | 2 +- .../Views/VideoPlayerView.swift | 12 +++ 5 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 ios/WatchRunner Watch App/Views/AttachmentView.swift create mode 100644 ios/WatchRunner Watch App/Views/AudioPlayerView.swift rename ios/WatchRunner Watch App/Views/{AttachmentImageView.swift => ImageViewer.swift} (54%) create mode 100644 ios/WatchRunner Watch App/Views/VideoPlayerView.swift diff --git a/ios/WatchRunner Watch App/Views/AttachmentView.swift b/ios/WatchRunner Watch App/Views/AttachmentView.swift new file mode 100644 index 00000000..2f7f49c3 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/AttachmentView.swift @@ -0,0 +1,96 @@ +// +// 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) { + let thumbnailUrl = videoUrl.appendingPathComponent("thumbnail") // Construct thumbnail URL + NavigationLink(destination: VideoPlayerView(videoUrl: videoUrl)) { + AsyncImage(url: thumbnailUrl) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity) + .cornerRadius(8) + } else if phase.error != nil { + Image(systemName: "play.rectangle.fill") // Placeholder for video thumbnail + .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) + } + } + } + } +} 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/AttachmentImageView.swift b/ios/WatchRunner Watch App/Views/ImageViewer.swift similarity index 54% rename from ios/WatchRunner Watch App/Views/AttachmentImageView.swift rename to ios/WatchRunner Watch App/Views/ImageViewer.swift index a6b35ef5..717e8fe7 100644 --- a/ios/WatchRunner Watch App/Views/AttachmentImageView.swift +++ b/ios/WatchRunner Watch App/Views/ImageViewer.swift @@ -1,14 +1,7 @@ -// -// AttachmentImageView.swift -// WatchRunner Watch App -// -// Created by LittleSheep on 2025/10/29. -// - import SwiftUI -struct AttachmentImageView: View { - let attachment: SnCloudFile +struct ImageViewer: View { + let imageUrl: URL @EnvironmentObject var appState: AppState @StateObject private var imageLoader = ImageLoader() @@ -20,19 +13,22 @@ struct AttachmentImageView: View { image .resizable() .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .scaledToFit() } else if let errorMessage = imageLoader.errorMessage { - Text("Failed to load attachment: \(errorMessage)") + Text("Failed to load image: \(errorMessage)") .font(.caption) .foregroundColor(.red) } else { - Text("File: \(attachment.id)") + Text("Failed to load image.") } } - .task(id: attachment.id) { - if let serverUrl = appState.serverUrl, let imageUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl), let token = appState.token, attachment.mimeType?.starts(with: "image") == true { + .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/PostViews.swift b/ios/WatchRunner Watch App/Views/PostViews.swift index 8007eccc..275296ab 100644 --- a/ios/WatchRunner Watch App/Views/PostViews.swift +++ b/ios/WatchRunner Watch App/Views/PostViews.swift @@ -116,7 +116,7 @@ struct PostDetailView: View { Divider() Text("Attachments").font(.headline) ForEach(post.attachments) { attachment in - AttachmentImageView(attachment: attachment) + AttachmentView(attachment: attachment) } } 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 + } +} -- 2.49.1 From 82682cae9a409ac70ba37f81b9b4659dcbd27b5a Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 29 Oct 2025 22:13:29 +0800 Subject: [PATCH 10/29] :sparkles: watchOS notification screen --- ios/WatchRunner Watch App/ContentView.swift | 31 ++- ios/WatchRunner Watch App/Models/Models.swift | 83 ++++++++ .../Services/NetworkService.swift | 29 +++ .../Utils/AttachmentUtils.swift | 1 - .../Views/ComposePostView.swift | 7 +- .../Views/NotificationView.swift | 198 ++++++++++++++++++ 6 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 ios/WatchRunner Watch App/Views/NotificationView.swift diff --git a/ios/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift index 47e444c9..00c99583 100644 --- a/ios/WatchRunner Watch App/ContentView.swift +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -9,7 +9,32 @@ import SwiftUI // The root view of the app. struct ContentView: View { - var body: some View { - ExploreView() + @StateObject private var appState = AppState() + @State private var selection: Panel? = .explore + + enum Panel: Hashable { + case explore + case notifications } -} \ No newline at end of file + + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Label("Explore", systemImage: "globe").tag(Panel.explore) + Label("Notifications", systemImage: "bell").tag(Panel.notifications) + } + .listStyle(.automatic) + } detail: { + switch selection { + case .explore: + ExploreView() + .environmentObject(appState) + case .notifications: + NotificationView() + .environmentObject(appState) + case .none: + Text("Select a panel") + } + } + } +} diff --git a/ios/WatchRunner Watch App/Models/Models.swift b/ios/WatchRunner Watch App/Models/Models.swift index ff6d83bd..c091eaef 100644 --- a/ios/WatchRunner Watch App/Models/Models.swift +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -124,3 +124,86 @@ struct SnWebArticle: Codable, Identifiable { 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 +} diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index ed9bc0b4..286959d4 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -66,4 +66,33 @@ class NetworkService { throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } } + + func fetchNotifications(offset: Int = 0, take: Int = 20, token: String, serverUrl: String) async throws -> NotificationResponse { + guard let baseURL = URL(string: serverUrl) else { + throw URLError(.badURL) + } + var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)! + var queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))] + components.queryItems = queryItems + + var request = URLRequest(url: components.url!) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") + request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") + + let (data, response) = try await session.data(for: request) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let notifications = try decoder.decode([SnNotification].self, from: data) + + let httpResponse = response as? HTTPURLResponse + let total = Int(httpResponse?.value(forHTTPHeaderField: "X-Total") ?? "0") ?? 0 + let hasMore = offset + notifications.count < total + + return NotificationResponse(notifications: notifications, total: total, hasMore: hasMore) + } } diff --git a/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift b/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift index 510340f2..cbe894ca 100644 --- a/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift +++ b/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift @@ -16,6 +16,5 @@ func getAttachmentUrl(for fileId: String, serverUrl: String) -> URL? { } else { urlString = "\(serverUrl)/drive/files/\(fileId)" } - print("[watchOS] Generated image URL: \(urlString)") return URL(string: urlString) } diff --git a/ios/WatchRunner Watch App/Views/ComposePostView.swift b/ios/WatchRunner Watch App/Views/ComposePostView.swift index 7d1b0324..a78c679a 100644 --- a/ios/WatchRunner Watch App/Views/ComposePostView.swift +++ b/ios/WatchRunner Watch App/Views/ComposePostView.swift @@ -17,23 +17,24 @@ struct ComposePostView: View { Form { TextField("Title", text: $viewModel.title) TextField("Content", text: $viewModel.content) - .frame(height: 100) } .navigationTitle("New Post") .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { + Button("Cancel", systemImage: "xmark") { dismiss() } + .labelStyle(.iconOnly) } ToolbarItem(placement: .confirmationAction) { - Button("Post") { + 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) } } 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) + } +} -- 2.49.1 From 9c3b228d02e02aed822aab0e6f7e276c8e8c38d1 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 29 Oct 2025 22:21:11 +0800 Subject: [PATCH 11/29] :sparkles: Pagination real impl on watchOS --- ios/WatchRunner Watch App/Models/Models.swift | 6 +++ .../Services/NetworkService.swift | 15 ++++-- .../ViewModels/ActivityViewModel.swift | 35 ++++++++++--- .../Views/ActivityListView.swift | 50 +++++++++++++------ 4 files changed, 80 insertions(+), 26 deletions(-) diff --git a/ios/WatchRunner Watch App/Models/Models.swift b/ios/WatchRunner Watch App/Models/Models.swift index c091eaef..5d89c5b3 100644 --- a/ios/WatchRunner Watch App/Models/Models.swift +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -207,3 +207,9 @@ struct NotificationResponse { let total: Int let hasMore: Bool } + +struct ActivityResponse { + let activities: [SnActivity] + let hasMore: Bool + let nextCursor: String? +} diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index 286959d4..5c6a171a 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -12,7 +12,7 @@ import Foundation class NetworkService { private let session = URLSession.shared - func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> [SnActivity] { + func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> ActivityResponse { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) } @@ -29,17 +29,22 @@ class NetworkService { 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 - - return try decoder.decode([SnActivity].self, from: data) + + 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 { diff --git a/ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift b/ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift index 0505004f..783a708d 100644 --- a/ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift +++ b/ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift @@ -14,12 +14,15 @@ import Combine 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 // Add this + private var hasFetched = false + private var nextCursor: String? init(filter: String, mockActivities: [SnActivity]? = nil) { self.filter = filter @@ -30,21 +33,41 @@ class ActivityViewModel: ObservableObject { } func fetchActivities(token: String, serverUrl: String) async { - if isMock || hasFetched { return } // Check hasFetched + if isMock || hasFetched { return } guard !isLoading else { return } isLoading = true errorMessage = nil - hasFetched = true // Set hasFetched + hasFetched = true + nextCursor = nil do { - let fetchedActivities = try await networkService.fetchActivities(filter: filter, token: token, serverUrl: serverUrl) - self.activities = fetchedActivities + 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 // Reset on 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/Views/ActivityListView.swift b/ios/WatchRunner Watch App/Views/ActivityListView.swift index 8a126e13..50299009 100644 --- a/ios/WatchRunner Watch App/Views/ActivityListView.swift +++ b/ios/WatchRunner Watch App/Views/ActivityListView.swift @@ -12,11 +12,11 @@ import SwiftUI 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 { @@ -33,22 +33,42 @@ struct ActivityListView: View { } else if viewModel.activities.isEmpty { Text("No activities found.") } else { - List(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) + 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)") } - case "discovery": - if case .discovery(let discoveryData) = activity.data { - DiscoveryView(discoveryData: discoveryData) + } + 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) } - default: - Text("Unknown activity type: \(activity.type)") } } } -- 2.49.1 From 234434f10295bd9905ff1f0e7ad60f006de5934b Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 29 Oct 2025 23:41:43 +0800 Subject: [PATCH 12/29] :zap: watchOS cache image --- ios/Runner.xcodeproj/project.pbxproj | 14 +-- .../Services/ImageLoader.swift | 116 +++++++++--------- 2 files changed, 57 insertions(+), 73 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 44828bb4..3cf9600a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -182,6 +182,8 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = "WatchRunner Watch App"; sourceTree = ""; }; @@ -669,14 +671,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -734,14 +732,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -770,14 +764,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks.sh\"\n"; diff --git a/ios/WatchRunner Watch App/Services/ImageLoader.swift b/ios/WatchRunner Watch App/Services/ImageLoader.swift index 3f4e1b86..5edd7be0 100644 --- a/ios/WatchRunner Watch App/Services/ImageLoader.swift +++ b/ios/WatchRunner Watch App/Services/ImageLoader.swift @@ -18,15 +18,12 @@ class ImageLoader: ObservableObject { @Published var errorMessage: String? @Published var isLoading = false - private var dataTask: URLSessionDataTask? - private let session: URLSession + private var currentTask: DownloadTask? - init(session: URLSession = .shared) { - self.session = session - } + init() {} deinit { - dataTask?.cancel() + currentTask?.cancel() } func loadImage(from initialUrl: URL, token: String) async { @@ -34,70 +31,67 @@ class ImageLoader: ObservableObject { errorMessage = nil image = nil - do { - // First request with Authorization header - var request = URLRequest(url: initialUrl) - request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") - request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") + // 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 + } - let (data, response) = try await session.data(for: request) + // Use WebP processor as default since the app seems to handle WebP images + let processor = WebPProcessor.default - if let httpResponse = response as? HTTPURLResponse { - if httpResponse.statusCode == 302, let redirectLocation = httpResponse.allHeaderFields["Location"] as? String, let redirectUrl = URL(string: redirectLocation) { - print("[watchOS] Redirecting to: \(redirectUrl)") - // Second request to the redirected URL (S3 signed URL) without Authorization header - let (redirectData, _) = try await session.data(from: redirectUrl) - if let uiImage = UIImage(data: redirectData) { - self.image = Image(uiImage: uiImage) - print("[watchOS] Image loaded successfully from redirect URL.") - } else { - // Try KingfisherWebP for WebP - let processor = WebPProcessor.default // Correct usage - if let kfImage = processor.process(item: .data(redirectData), options: KingfisherParsedOptionsInfo( - [ - .processor(processor), - .loadDiskFileSynchronously, - .cacheOriginalImage - ] - )) { - self.image = Image(uiImage: kfImage) - print("[watchOS] Image loaded successfully from redirect URL using KingfisherWebP.") - } else { - self.errorMessage = "Invalid image data from redirect (could not decode with KingfisherWebP)." + // 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) + print("[watchOS] Image loaded successfully from \(value.cacheType == .none ? "network" : "cache (\(value.cacheType))").") + self.isLoading = false + case .failure(let error): + // 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) + print("[watchOS] Image loaded successfully from \(value.cacheType == .none ? "network" : "cache (\(value.cacheType))") using fallback processor.") + case .failure(let fallbackError): + self.errorMessage = fallbackError.localizedDescription + print("[watchOS] Image loading failed: \(fallbackError.localizedDescription)") + } + self.isLoading = false } } - } else if httpResponse.statusCode == 200 { - if let uiImage = UIImage(data: data) { - self.image = Image(uiImage: uiImage) - print("[watchOS] Image loaded successfully from initial URL.") - } else { - // Try KingfisherWebP for WebP - let processor = WebPProcessor.default // Correct usage - if let kfImage = processor.process(item: .data(data), options: KingfisherParsedOptionsInfo( - [ - .processor(processor), - .loadDiskFileSynchronously, - .cacheOriginalImage - ] - )) { - self.image = Image(uiImage: kfImage) - print("[watchOS] Image loaded successfully from initial URL using KingfisherWebP.") - } else { - self.errorMessage = "Invalid image data (could not decode with KingfisherWebP)." - } - } - } else { - self.errorMessage = "HTTP Status Code: \(httpResponse.statusCode)" } } - } catch { - self.errorMessage = error.localizedDescription - print("[watchOS] Image loading failed: \(error.localizedDescription)") } - isLoading = false } func cancel() { - dataTask?.cancel() + currentTask?.cancel() } } -- 2.49.1 From 7a5a2407b7eb3adea3e46497035499787bdf2e12 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 29 Oct 2025 23:52:18 +0800 Subject: [PATCH 13/29] :lipstick: Optimized post item row --- .../Views/PostViews.swift | 65 +++++++++++-------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/ios/WatchRunner Watch App/Views/PostViews.swift b/ios/WatchRunner Watch App/Views/PostViews.swift index 275296ab..a4971ef7 100644 --- a/ios/WatchRunner Watch App/Views/PostViews.swift +++ b/ios/WatchRunner Watch App/Views/PostViews.swift @@ -15,28 +15,26 @@ struct PostRowView: View { var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { - if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { - if imageLoader.isLoading { - ProgressView() - .frame(width: 24, height: 24) - } else if let image = imageLoader.image { - image - .resizable() - .frame(width: 24, height: 24) - .clipShape(Circle()) - } else if let errorMessage = imageLoader.errorMessage { - Text("Failed: \(errorMessage)") - .font(.caption) - .foregroundColor(.red) - .frame(width: 24, height: 24) - } else { - // Placeholder if no image and not loading - Image(systemName: "person.circle.fill") - .resizable() - .frame(width: 24, height: 24) - .clipShape(Circle()) - .foregroundColor(.gray) - } + if imageLoader.isLoading { + ProgressView() + .frame(width: 24, height: 24) + } else if let image = imageLoader.image { + image + .resizable() + .frame(width: 24, height: 24) + .clipShape(Circle()) + } else if let errorMessage = imageLoader.errorMessage { + Text("Failed: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + .frame(width: 24, height: 24) + } else { + // Placeholder if no image and not loading + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 24, height: 24) + .clipShape(Circle()) + .foregroundColor(.gray) } Text(post.publisher.nick ?? post.publisher.name) .font(.subheadline) @@ -57,7 +55,21 @@ struct PostRowView: View { Text(content) .font(.body) } - } + + if !post.attachments.isEmpty { + AttachmentView(attachment: post.attachments[0]) + if post.attachments.count > 1 { + HStack(spacing: 8) { + Image(systemName: "paperclip.circle.fill") + .frame(width: 12, height: 12) + .foregroundStyle(.gray) + Text("\(post.attachments.count - 1)+ attachments") + .font(.footnote) + .foregroundStyle(.gray) + } + } + } + }.padding(.vertical) } } @@ -70,7 +82,7 @@ struct PostDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 8) { HStack { - if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { + if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id { if publisherImageLoader.isLoading { ProgressView() .frame(width: 32, height: 32) @@ -95,7 +107,8 @@ struct PostDetailView: View { Text("@\(post.publisher.name)") .font(.headline) } - .task(id: post.publisher.picture?.id) { // Use task(id:) to reload image when pictureId changes + // Use task(id:) to reload image when pictureId changes + .task(id: post.publisher.picture?.id) { if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { await publisherImageLoader.loadImage(from: imageUrl, token: token) } @@ -113,7 +126,6 @@ struct PostDetailView: View { } if !post.attachments.isEmpty { - Divider() Text("Attachments").font(.headline) ForEach(post.attachments) { attachment in AttachmentView(attachment: attachment) @@ -121,7 +133,6 @@ struct PostDetailView: View { } if !post.tags.isEmpty { - Divider() Text("Tags").font(.headline) FlowLayout(alignment: .leading, spacing: 4) { ForEach(post.tags) { tag in -- 2.49.1 From 44c5d916204022b2a4b09246a0fea1db6308ecca Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 00:03:24 +0800 Subject: [PATCH 14/29] :lipstick: Better watchOS video attachment display --- .../Views/AttachmentView.swift | 37 ++++++++++------ .../Views/PostViews.swift | 43 +++++++++---------- 2 files changed, 46 insertions(+), 34 deletions(-) diff --git a/ios/WatchRunner Watch App/Views/AttachmentView.swift b/ios/WatchRunner Watch App/Views/AttachmentView.swift index 2f7f49c3..cbd50396 100644 --- a/ios/WatchRunner Watch App/Views/AttachmentView.swift +++ b/ios/WatchRunner Watch App/Views/AttachmentView.swift @@ -46,26 +46,34 @@ struct AttachmentView: View { } } else if mimeType.starts(with: "video") { if let serverUrl = appState.serverUrl, let videoUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl) { - let thumbnailUrl = videoUrl.appendingPathComponent("thumbnail") // Construct thumbnail URL NavigationLink(destination: VideoPlayerView(videoUrl: videoUrl)) { - AsyncImage(url: thumbnailUrl) { phase in - if let image = phase.image { + if imageLoader.isLoading { + ProgressView() + } else if let image = imageLoader.image { + ZStack { image .resizable() .aspectRatio(contentMode: .fit) .frame(maxWidth: .infinity) .cornerRadius(8) - } else if phase.error != nil { - Image(systemName: "play.rectangle.fill") // Placeholder for video thumbnail + + Image(systemName: "play.circle.fill") .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) - .foregroundColor(.gray) - .cornerRadius(8) - } else { - ProgressView() - .cornerRadius(8) + .scaledToFit() + .frame(width: 36, height: 36) + .foregroundColor(.white) + .shadow(color: .black.opacity(0.6), radius: 4, x: 0, y: 2) } + } else if imageLoader.errorMessage != nil { + Image(systemName: "play.rectangle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity) + .foregroundColor(.gray) + .cornerRadius(8) + } else { + ProgressView() + .cornerRadius(8) } } .buttonStyle(PlainButtonStyle()) @@ -90,6 +98,11 @@ struct AttachmentView: View { if attachment.mimeType?.starts(with: "image") == true { await imageLoader.loadImage(from: attachmentUrl, token: token) } + if attachment.mimeType?.starts(with: "video") == true { + let thumbnailUrl = attachmentUrl + .appending(queryItems: [URLQueryItem(name: "thumbnail", value: "true")]) // Construct thumbnail URL + await imageLoader.loadImage(from: thumbnailUrl, token: token) + } } } } diff --git a/ios/WatchRunner Watch App/Views/PostViews.swift b/ios/WatchRunner Watch App/Views/PostViews.swift index a4971ef7..726b40e3 100644 --- a/ios/WatchRunner Watch App/Views/PostViews.swift +++ b/ios/WatchRunner Watch App/Views/PostViews.swift @@ -82,27 +82,25 @@ struct PostDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 8) { HStack { - if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id { - if publisherImageLoader.isLoading { - ProgressView() - .frame(width: 32, height: 32) - } else if let image = publisherImageLoader.image { - image - .resizable() - .frame(width: 32, height: 32) - .clipShape(Circle()) - } else if let errorMessage = publisherImageLoader.errorMessage { - Text("Failed: \(errorMessage)") - .font(.caption) - .foregroundColor(.red) - .frame(width: 32, height: 32) - } else { - Image(systemName: "person.circle.fill") - .resizable() - .frame(width: 32, height: 32) - .clipShape(Circle()) - .foregroundColor(.gray) - } + if publisherImageLoader.isLoading { + ProgressView() + .frame(width: 32, height: 32) + } else if let image = publisherImageLoader.image { + image + .resizable() + .frame(width: 32, height: 32) + .clipShape(Circle()) + } else if let errorMessage = publisherImageLoader.errorMessage { + Text("Failed: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + .frame(width: 32, height: 32) + } else { + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 32, height: 32) + .clipShape(Circle()) + .foregroundColor(.gray) } Text("@\(post.publisher.name)") .font(.headline) @@ -138,7 +136,7 @@ struct PostDetailView: View { ForEach(post.tags) { tag in Text("#\(tag.name ?? tag.slug)") .font(.caption) - .padding(.horizontal, 6) + .padding(.horizontal, 8) .padding(.vertical, 3) .background(Capsule().fill(Color.accentColor.opacity(0.2))) .cornerRadius(5) @@ -147,6 +145,7 @@ struct PostDetailView: View { } } .padding() + .frame(width: .infinity) } .navigationTitle("Post") } -- 2.49.1 From e2369c40dbb3bcaaa410babdd69a5ecbed4bac3a Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 00:26:32 +0800 Subject: [PATCH 15/29] :sparkles: watchOS Account profile page --- ios/Runner.xcodeproj/project.pbxproj | 14 +- .../AppIcon.appiconset/Contents.json | 335 +++++++++++++++++- .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 295 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 282 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 406 -> 0 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 762 -> 0 bytes .../AppIcon.appiconset/Contents.json | 2 +- .../Icon-App-1024x1024@1x.png | Bin 0 -> 49094 bytes .../AppIcon.appiconset/icon.png | Bin 71375 -> 0 bytes ios/WatchRunner Watch App/ContentView.swift | 5 + ios/WatchRunner Watch App/Models/Models.swift | 17 + .../Services/NetworkService.swift | 21 ++ .../Views/AccountView.swift | 182 ++++++++++ 13 files changed, 572 insertions(+), 4 deletions(-) delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png delete mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png delete mode 100644 ios/WatchRunner Watch App/Assets.xcassets/AppIcon.appiconset/icon.png create mode 100644 ios/WatchRunner Watch App/Views/AccountView.swift diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3cf9600a..44828bb4 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -182,8 +182,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 7310A7D52EB10962002C0FD3 /* WatchRunner Watch App */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = "WatchRunner Watch App"; sourceTree = ""; }; @@ -671,10 +669,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -732,10 +734,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -764,10 +770,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks.sh\"\n"; 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 7353c41ecf9ca08017312dc233d9830079b50717..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 295 zcmV+?0oeYDP)xN#0001NP)t-s|Ns9~ z#rXRE|M&d=0au&!`~QyF`q}dRnBDt}*!qXo`c{v z{Djr|@Adh0(D_%#_&mM$D6{kE_x{oE{l@J5@%H*?%=t~i_`ufYOPkAEn!pfkr2$fs z652Tz0001XNklqeeKN4RM4i{jKqmiC$?+xN>3Apn^ z0QfuZLym_5b<*QdmkHjHlj811{If)dl(Z2K0A+ekGtrFJb?g|wt#k#pV-#A~bK=OT ts8>{%cPtyC${m|1#B1A6#u!Q;umknL1chzTM$P~L002ovPDHLkV1lTfnu!1a 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 4cd7b0099ca80c806f8fe495613e8d6c69460d76..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX 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 797d452e458972bab9d994556c8305db4c827017..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c 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 84ac32ae7d989f82d5e46a60405adcc8279e8001..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 762 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ⓈVM{h$^^D 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 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) + } } diff --git a/ios/WatchRunner Watch App/Views/AccountView.swift b/ios/WatchRunner Watch App/Views/AccountView.swift new file mode 100644 index 00000000..f078b688 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/AccountView.swift @@ -0,0 +1,182 @@ +// +// 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 isLoading = false + @State private var error: Error? + + @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, content: <#T##() -> View#>, spacing: 4) { + Text(user.nick) + .font(.headline) + Text("@\(user.name)") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + // Bio + if let bio = user.profile.bio, !bio.isEmpty { + Text(bio) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + } else { + Text("No bio available") + .font(.body) + .foregroundColor(.secondary) + } + + // Level and Progress + VStack(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) + } + + // Member since + Text("Member since: \(user.createdAt.formatted(.dateTime.month(.abbreviated).year()))") + .font(.caption) + .foregroundColor(.secondary) + } + .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") + .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) + } catch { + self.error = error + } + + isLoading = false + } +} + +#Preview { + AccountView() + .environmentObject(AppState()) +} -- 2.49.1 From 49b15e767443cdf234fe7bab6adba9717b8a5b3a Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 00:26:46 +0800 Subject: [PATCH 16/29] :bug: Fix compile issue on watchOS --- ios/WatchRunner Watch App/Views/AccountView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/WatchRunner Watch App/Views/AccountView.swift b/ios/WatchRunner Watch App/Views/AccountView.swift index f078b688..3c104eea 100644 --- a/ios/WatchRunner Watch App/Views/AccountView.swift +++ b/ios/WatchRunner Watch App/Views/AccountView.swift @@ -93,7 +93,7 @@ struct AccountView: View { } // Username and Handle - VStack(alignment: .leading, content: <#T##() -> View#>, spacing: 4) { + VStack(alignment: .leading, spacing: 4) { Text(user.nick) .font(.headline) Text("@\(user.name)") -- 2.49.1 From a8055de910339e61c6e07bc73bf1ba961b828164 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 00:28:56 +0800 Subject: [PATCH 17/29] :lipstick: Optimize account view on watchOS --- ios/WatchRunner Watch App/Views/AccountView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/WatchRunner Watch App/Views/AccountView.swift b/ios/WatchRunner Watch App/Views/AccountView.swift index 3c104eea..030159fc 100644 --- a/ios/WatchRunner Watch App/Views/AccountView.swift +++ b/ios/WatchRunner Watch App/Views/AccountView.swift @@ -93,11 +93,11 @@ struct AccountView: View { } // Username and Handle - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading) { Text(user.nick) .font(.headline) Text("@\(user.name)") - .font(.subheadline) + .font(.caption) .foregroundColor(.secondary) } } -- 2.49.1 From dbcd1b6d36d87148987e9a85e86dde475d9093cd Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 01:03:19 +0800 Subject: [PATCH 18/29] :sparkles: watchOS able to set status --- ios/WatchRunner Watch App/Models/Models.swift | 16 +++ .../Services/NetworkService.swift | 90 ++++++++++++ .../Views/AccountView.swift | 85 +++++++++-- .../Views/PostViews.swift | 1 - .../Views/StatusCreationView.swift | 132 ++++++++++++++++++ 5 files changed, 311 insertions(+), 13 deletions(-) create mode 100644 ios/WatchRunner Watch App/Views/StatusCreationView.swift diff --git a/ios/WatchRunner Watch App/Models/Models.swift b/ios/WatchRunner Watch App/Models/Models.swift index 2ca62509..f30d00bc 100644 --- a/ios/WatchRunner Watch App/Models/Models.swift +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -230,3 +230,19 @@ struct SnUserProfile: Codable { 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? +} diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index 7a87ea01..45de32bd 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -121,4 +121,94 @@ class NetworkService { 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 { + // First check if status exists + let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl) + let method = existingStatus == nil ? "POST" : "PATCH" + + 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)) + } + } } diff --git a/ios/WatchRunner Watch App/Views/AccountView.swift b/ios/WatchRunner Watch App/Views/AccountView.swift index 030159fc..1fec3fd3 100644 --- a/ios/WatchRunner Watch App/Views/AccountView.swift +++ b/ios/WatchRunner Watch App/Views/AccountView.swift @@ -10,6 +10,7 @@ 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? @@ -102,20 +103,64 @@ struct AccountView: View { } } - // Bio - if let bio = user.profile.bio, !bio.isEmpty { - Text(bio) - .font(.body) - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - } else { - Text("No bio available") - .font(.body) - .foregroundColor(.secondary) + // Status + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Status") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + NavigationLink( + destination: StatusCreationView(initialStatus: status) + .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(spacing: 8) { + VStack(alignment: .leading, spacing: 8) { Text("Level \(user.profile.level)") .font(.title3) .bold() @@ -127,10 +172,25 @@ struct AccountView: View { .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("Member since: \(user.createdAt.formatted(.dateTime.month(.abbreviated).year()))") + 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 @@ -168,6 +228,7 @@ struct AccountView: View { do { user = try await networkService.fetchUserProfile(token: token, serverUrl: serverUrl) + status = try await networkService.fetchAccountStatus(token: token, serverUrl: serverUrl) } catch { self.error = error } diff --git a/ios/WatchRunner Watch App/Views/PostViews.swift b/ios/WatchRunner Watch App/Views/PostViews.swift index 726b40e3..248677f5 100644 --- a/ios/WatchRunner Watch App/Views/PostViews.swift +++ b/ios/WatchRunner Watch App/Views/PostViews.swift @@ -145,7 +145,6 @@ struct PostDetailView: View { } } .padding() - .frame(width: .infinity) } .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()) +} -- 2.49.1 From b57caf56dbe1be6fc6c3f512fc2b5874b9fb161b Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 01:15:42 +0800 Subject: [PATCH 19/29] :sparkles: Able to clear status on watchOS :bug: Fix some bugs in status on watchOS --- .../Services/NetworkService.swift | 4 +- .../Views/AccountView.swift | 53 ++++++++++++++++--- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index 45de32bd..4de64ba0 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -148,9 +148,9 @@ class NetworkService { } func createOrUpdateStatus(attitude: Int, isInvisible: Bool, isNotDisturb: Bool, label: String?, token: String, serverUrl: String) async throws -> SnAccountStatus { - // First check if status exists + // Check if there's already a customized status let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl) - let method = existingStatus == nil ? "POST" : "PATCH" + let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST" guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) diff --git a/ios/WatchRunner Watch App/Views/AccountView.swift b/ios/WatchRunner Watch App/Views/AccountView.swift index 1fec3fd3..9d5b0ca7 100644 --- a/ios/WatchRunner Watch App/Views/AccountView.swift +++ b/ios/WatchRunner Watch App/Views/AccountView.swift @@ -13,10 +13,11 @@ struct AccountView: View { @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 { @@ -110,8 +111,23 @@ struct AccountView: View { .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) + destination: StatusCreationView(initialStatus: status?.isCustomized == true ? status : nil) .environmentObject(appState) ) { ZStack { @@ -210,6 +226,16 @@ struct AccountView: View { } } .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() @@ -222,19 +248,34 @@ struct AccountView: View { 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 { -- 2.49.1 From 44dbcfdc942aebe827eb7fb3fadf0149547c6554 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 01:28:36 +0800 Subject: [PATCH 20/29] :sparkles: Chat room listing --- ios/WatchRunner Watch App/ContentView.swift | 5 + ios/WatchRunner Watch App/Models/Models.swift | 86 +++- .../Services/NetworkService.swift | 113 +++++ .../State/AppState.swift | 3 +- .../Views/ChatView.swift | 472 ++++++++++++++++++ .../Views/ExploreView.swift | 5 +- 6 files changed, 680 insertions(+), 4 deletions(-) create mode 100644 ios/WatchRunner Watch App/Views/ChatView.swift diff --git a/ios/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift index 057a9e2c..472da0a2 100644 --- a/ios/WatchRunner Watch App/ContentView.swift +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -14,6 +14,7 @@ struct ContentView: View { enum Panel: Hashable { case explore + case chat case notifications case account } @@ -22,6 +23,7 @@ struct ContentView: View { NavigationSplitView { List(selection: $selection) { Label("Explore", systemImage: "globe").tag(Panel.explore) + Label("Chat", systemImage: "message").tag(Panel.chat) Label("Notifications", systemImage: "bell").tag(Panel.notifications) Label("Account", systemImage: "person.circle").tag(Panel.account) } @@ -31,6 +33,9 @@ struct ContentView: View { case .explore: ExploreView() .environmentObject(appState) + case .chat: + ChatView() + .environmentObject(appState) case .notifications: NotificationView() .environmentObject(appState) diff --git a/ios/WatchRunner Watch App/Models/Models.swift b/ios/WatchRunner Watch App/Models/Models.swift index f30d00bc..665e325e 100644 --- a/ios/WatchRunner Watch App/Models/Models.swift +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -1,4 +1,3 @@ -// // Models.swift // WatchRunner Watch App // @@ -88,7 +87,7 @@ enum DiscoveryItemData: Codable { } self = .unknown } - + func encode(to encoder: Encoder) throws { // Not needed for decoding } @@ -246,3 +245,86 @@ struct SnAccountStatus: Codable { 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? +} + +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] +} diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index 4de64ba0..80d551dd 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -211,4 +211,117 @@ class NetworkService { 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)) + } + } } diff --git a/ios/WatchRunner Watch App/State/AppState.swift b/ios/WatchRunner Watch App/State/AppState.swift index 3e75a0a6..e8c69ae2 100644 --- a/ios/WatchRunner Watch App/State/AppState.swift +++ b/ios/WatchRunner Watch App/State/AppState.swift @@ -15,7 +15,8 @@ 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() diff --git a/ios/WatchRunner Watch App/Views/ChatView.swift b/ios/WatchRunner Watch App/Views/ChatView.swift new file mode 100644 index 00000000..e30952a7 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ChatView.swift @@ -0,0 +1,472 @@ +// +// 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)) { + 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 let errorMessage = avatarLoader.errorMessage { + // 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) + } + } +} + +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? + + var body: some View { + VStack { + 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 { + List(messages) { message in + ChatMessageItem(message: message) + } + .listStyle(.plain) + } + } + .navigationTitle(room.name ?? "Chat") + .task { + await loadMessages() + } + } + + private func loadMessages() async { + // Placeholder for message loading + // In a full implementation, this would fetch messages from the API + // For now, just show empty state + isLoading = false + } +} + +struct ChatMessageItem: View { + let message: SnChatMessage + + var body: some View { + 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 { + Text(content) + .font(.system(size: 14)) + } + } + .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/ExploreView.swift b/ios/WatchRunner Watch App/Views/ExploreView.swift index 1aca9bab..c8c48829 100644 --- a/ios/WatchRunner Watch App/Views/ExploreView.swift +++ b/ios/WatchRunner Watch App/Views/ExploreView.swift @@ -22,18 +22,21 @@ struct ExploreView: View { .tabItem { Label("Explore", systemImage: "safari") } + .labelStyle(.titleOnly) ActivityListView(filter: "Subscriptions") .tag("Subscriptions") .tabItem { Label("Subscriptions", systemImage: "star") } + .labelStyle(.titleOnly) ActivityListView(filter: "Friends") .tag("Friends") .tabItem { Label("Friends", systemImage: "person.2") } + .labelStyle(.titleOnly) } .navigationTitle(selectedTab) .toolbar { @@ -56,4 +59,4 @@ struct ExploreView: View { .environmentObject(appState) } } -} \ No newline at end of file +} -- 2.49.1 From 6fc94001b335e59e7921f65223b02ed8fc2555e5 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 02:04:10 +0800 Subject: [PATCH 21/29] :sparkles: Message loading on watchOS --- ios/WatchRunner Watch App/Models/Models.swift | 37 ++- .../Services/NetworkService.swift | 212 ++++++++++++------ .../Views/ChatView.swift | 25 ++- 3 files changed, 199 insertions(+), 75 deletions(-) diff --git a/ios/WatchRunner Watch App/Models/Models.swift b/ios/WatchRunner Watch App/Models/Models.swift index 665e325e..09356735 100644 --- a/ios/WatchRunner Watch App/Models/Models.swift +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -271,7 +271,7 @@ struct SnChatMessage: Codable, Identifiable { let content: String? let nonce: String? let meta: [String: AnyCodable] - let membersMentioned: [String] + let membersMentioned: [String]? let editedAt: Date? let attachments: [SnCloudFile] let reactions: [SnChatReaction] @@ -283,6 +283,31 @@ struct SnChatMessage: Codable, Identifiable { 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 { @@ -328,3 +353,13 @@ struct ChatRoomsResponse { 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/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index 80d551dd..188deb6a 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -11,7 +11,7 @@ import Foundation class NetworkService { private let session = URLSession.shared - + func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> ActivityResponse { guard let baseURL = URL(string: serverUrl) else { throw URLError(.badURL) @@ -25,53 +25,53 @@ class NetworkService { 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) @@ -79,249 +79,321 @@ class NetworkService { var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)! var queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))] components.queryItems = queryItems - + var request = URLRequest(url: components.url!) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - + let (data, response) = try await session.data(for: request) - + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase - + let notifications = try decoder.decode([SnNotification].self, from: data) - + let httpResponse = response as? HTTPURLResponse let total = Int(httpResponse?.value(forHTTPHeaderField: "X-Total") ?? "0") ?? 0 let hasMore = offset + notifications.count < total - + return NotificationResponse(notifications: notifications, total: total, hasMore: hasMore) } - + 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 DecodingError.dataCorrupted(let context) { + print(context) + return [] + } catch DecodingError.keyNotFound(let key, let context) { + print("Key '\(key)' not found:", context.debugDescription) + print("codingPath:", context.codingPath) + return [] + } catch DecodingError.valueNotFound(let value, let context) { + print("Value '\(value)' not found:", context.debugDescription) + print("codingPath:", context.codingPath) + return [] + } catch DecodingError.typeMismatch(let type, let context) { + print("Type '\(type)' mismatch:", context.debugDescription) + print("codingPath:", context.codingPath) + return [] + } catch { + print("error: ", error) + throw error + } + } } diff --git a/ios/WatchRunner Watch App/Views/ChatView.swift b/ios/WatchRunner Watch App/Views/ChatView.swift index e30952a7..89cd7468 100644 --- a/ios/WatchRunner Watch App/Views/ChatView.swift +++ b/ios/WatchRunner Watch App/Views/ChatView.swift @@ -181,7 +181,10 @@ struct ChatRoomListItem: View { } var body: some View { - NavigationLink(destination: ChatRoomView(room: room)) { + NavigationLink( + destination: ChatRoomView(room: room) + .environmentObject(appState) + ) { HStack { // Avatar using ImageLoader pattern Group { @@ -292,9 +295,23 @@ struct ChatRoomView: View { } private func loadMessages() async { - // Placeholder for message loading - // In a full implementation, this would fetch messages from the API - // For now, just show empty state + guard let token = appState.token, let serverUrl = appState.serverUrl else { + isLoading = false + return + } + + do { + let messages = try await appState.networkService.fetchChatMessages( + chatRoomId: room.id, + token: token, + serverUrl: serverUrl + ) + self.messages = messages.sorted { $0.createdAt < $1.createdAt } + } catch { + print("[watchOS] Error loading messages: \(error.localizedDescription)") + self.error = error + } + isLoading = false } } -- 2.49.1 From 983ae2a1fc3dc2a819ac00d3079fa505dbeb3efd Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 02:15:51 +0800 Subject: [PATCH 22/29] :sparkles: Render messages on watchOS --- .../Views/ChatView.swift | 90 ++++++++++++++++--- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/ios/WatchRunner Watch App/Views/ChatView.swift b/ios/WatchRunner Watch App/Views/ChatView.swift index 89cd7468..c7102b67 100644 --- a/ios/WatchRunner Watch App/Views/ChatView.swift +++ b/ios/WatchRunner Watch App/Views/ChatView.swift @@ -282,10 +282,31 @@ struct ChatRoomView: View { .foregroundColor(.secondary) } } else { - List(messages) { message in - ChatMessageItem(message: message) + ScrollViewReader { scrollView in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(messages) { message in + ChatMessageItem(message: message) + } + } + .padding(.horizontal) + .padding(.vertical, 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) + } + } + } } - .listStyle(.plain) } } .navigationTitle(room.name ?? "Chat") @@ -306,6 +327,7 @@ struct ChatRoomView: View { 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 } } catch { print("[watchOS] Error loading messages: \(error.localizedDescription)") @@ -318,21 +340,61 @@ struct ChatRoomView: View { 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 { - 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) + 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) + } } - if let content = message.content { - Text(content) - .font(.system(size: 14)) + 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 { + Text(content) + .font(.system(size: 14)) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } } } .padding(.vertical, 4) -- 2.49.1 From 8ba55eb1be1f7b0a5707e0927b366b0846e45ea5 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 21:20:41 +0800 Subject: [PATCH 23/29] :sparkles: App info header on watchOS --- .../Logo.imageset/Contents.json | 21 ++++++++++++++ .../Assets.xcassets/Logo.imageset/icon.png | Bin 0 -> 71375 bytes ios/WatchRunner Watch App/ContentView.swift | 26 ++++++++---------- .../Services/ImageLoader.swift | 4 +-- .../Services/NetworkService.swift | 12 ++++---- .../Views/AppInfoHeaderView.swift | 25 +++++++++++++++++ 6 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/Contents.json create mode 100644 ios/WatchRunner Watch App/Assets.xcassets/Logo.imageset/icon.png create mode 100644 ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift 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 0000000000000000000000000000000000000000..0eeb8c11185064aeed6a0a0696e691d3ea92530a GIT binary patch literal 71375 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 Date: Thu, 30 Oct 2025 22:37:41 +0800 Subject: [PATCH 24/29] :sparkles: Make a broke websocket on watchOS (w.i.p) --- ios/Runner.xcodeproj/project.pbxproj | 13 + ios/WatchRunner Watch App/ContentView.swift | 4 +- .../Services/NetworkService.swift | 289 ++++++++++++++++-- .../State/AppState.swift | 13 +- .../Views/AppInfoHeaderView.swift | 37 +++ .../Views/ChatView.swift | 74 ++++- ios/WatchRunner-Watch-App-Info.plist | 10 + 7 files changed, 408 insertions(+), 32 deletions(-) create mode 100644 ios/WatchRunner-Watch-App-Info.plist diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 44828bb4..3e0e6fad 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -148,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 = ( @@ -182,6 +189,9 @@ /* 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 = ""; }; @@ -1092,6 +1102,7 @@ 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; @@ -1140,6 +1151,7 @@ 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; @@ -1185,6 +1197,7 @@ 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; diff --git a/ios/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift index d09b4428..5d5b0f79 100644 --- a/ios/WatchRunner Watch App/ContentView.swift +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -22,7 +22,9 @@ struct ContentView: View { var body: some View { NavigationSplitView { List(selection: $selection) { - AppInfoHeaderView().listRowBackground(Color.clear) + AppInfoHeaderView() + .listRowBackground(Color.clear) + .environmentObject(appState) Label("Explore", systemImage: "globe.fill").tag(Panel.explore) Label("Chat", systemImage: "message.fill").tag(Panel.chat) diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index 04a9a3df..efae0a8f 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -2,16 +2,53 @@ // NetworkService.swift // WatchRunner Watch App // -// Created by LittleSheep on 2025/10/29. -// +// 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) @@ -77,7 +114,7 @@ class NetworkService { throw URLError(.badURL) } var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)! - var queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))] + let queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))] components.queryItems = queryItems var request = URLRequest(url: components.url!) @@ -148,7 +185,7 @@ class NetworkService { } 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 + // Check if there\'s already a customized status let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl) let method = (existingStatus?.isCustomized == true) ? "PATCH" : "POST" @@ -167,7 +204,7 @@ class NetworkService { var body: [String: Any] = [ "attitude": attitude, "is_invisible": isInvisible, - "is_not_disturb": isNotDisturb + "is_not_disturb": isNotDisturb, ] if let label = label, !label.isEmpty { @@ -338,7 +375,7 @@ class NetworkService { resolvingAgainstBaseURL: false )! var queryItems = [ - URLQueryItem(name: "take", value: String(take)) + URLQueryItem(name: "take", value: String(take)), ] if let before = before { queryItems.append(URLQueryItem(name: "before", value: ISO8601DateFormatter().string(from: before))) @@ -352,48 +389,248 @@ class NetworkService { 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 DecodingError.dataCorrupted(let context) { - print(context) - return [] - } catch DecodingError.keyNotFound(let key, let context) { - print("[watchOS] Message decode failed: Key '\(key)' not found:", context.debugDescription) - print("[watchOS] Message decode failed: codingPath:", context.codingPath) - return [] - } catch DecodingError.valueNotFound(let value, let context) { - print("[watchOS] Message decode failed: Value '\(value)' not found:", context.debugDescription) - print("[watchOS] Message decode failed: codingPath:", context.codingPath) - return [] - } catch DecodingError.typeMismatch(let type, let context) { - print("[watchOS] Message decode failed: Type '\(type)' mismatch:", context.debugDescription) - print("[watchOS] Message decode failed: codingPath:", context.codingPath) - return [] } 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) { + connectLock.lock() + defer { connectLock.unlock() } + + webSocketQueue.async { [weak self] in + guard let self = self else { return } + + // Prevent redundant connection attempts + if self.currentConnectionState == .connecting || self.currentConnectionState == .connected { + print("[WebSocket] Already connecting or connected, ignoring new connect request.") + return + } + + // 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 + } + + print("[WebSocket] Trying connecting to \(url)") + self.currentConnectionState = .connecting + + var request = URLRequest(url: url) + request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + 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 index e8c69ae2..2c6392ec 100644 --- a/ios/WatchRunner Watch App/State/AppState.swift +++ b/ios/WatchRunner Watch App/State/AppState.swift @@ -26,10 +26,15 @@ class AppState: ObservableObject { .sink { [weak self] token, serverUrl in self?.token = token self?.serverUrl = serverUrl - if token != nil && serverUrl != nil { - self?.isReady = true - } - } + if let token = token, let serverUrl = serverUrl { + self?.isReady = true + // Auto-connect WebSocket here + self?.networkService.connectWebSocket(token: token, serverUrl: serverUrl) + } else { + self?.isReady = false + // Disconnect WebSocket if token or serverUrl become nil + self?.networkService.disconnectWebSocket() + } } .store(in: &cancellables) } diff --git a/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift b/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift index 93719238..9384a5dd 100644 --- a/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift +++ b/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift @@ -5,9 +5,14 @@ // 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) { @@ -18,8 +23,40 @@ struct AppInfoHeaderView : View { VStack(alignment: .leading) { Text("Solian").font(.headline) Text("for Apple Watch").font(.system(size: 11)) + + // Display WebSocket connection status + Text(webSocketStatusMessage) + .font(.caption2) + .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/ChatView.swift b/ios/WatchRunner Watch App/Views/ChatView.swift index c7102b67..d5fc71d7 100644 --- a/ios/WatchRunner Watch App/Views/ChatView.swift +++ b/ios/WatchRunner Watch App/Views/ChatView.swift @@ -196,7 +196,7 @@ struct ChatRoomListItem: View { .resizable() .frame(width: 32, height: 32) .clipShape(Circle()) - } else if let errorMessage = avatarLoader.errorMessage { + } else if avatarLoader.errorMessage != nil { // Error state - show fallback Circle() .fill(Color.gray.opacity(0.3)) @@ -250,15 +250,28 @@ struct ChatRoomListItem: View { } } +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 webSocketConnectionState: WebSocketState = .disconnected // New state for WebSocket status + + @State private var cancellables = Set() // For managing subscriptions var body: some View { VStack { + // Display WebSocket connection status + Text(webSocketStatusMessage) + .font(.caption2) + .foregroundColor(.secondary) + .padding(.vertical, 2) + .animation(.easeInOut, value: webSocketConnectionState) // Animate status changes + if isLoading { ProgressView() } else if error != nil { @@ -313,6 +326,24 @@ struct ChatRoomView: View { .task { await loadMessages() } + .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 loadMessages() async { @@ -336,6 +367,47 @@ struct ChatRoomView: View { isLoading = false } + + 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 + // Assuming 'message.created' is the type for new messages + if packet.type == "message.created", + let messageData = packet.data { + do { + let jsonData = try JSONSerialization.data(withJSONObject: messageData, options: []) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .convertFromSnakeCase + let newMessage = try decoder.decode(SnChatMessage.self, from: jsonData) + + if newMessage.chatRoomId == room.id { + // Avoid adding duplicates + if !messages.contains(where: { $0.id == newMessage.id }) { + messages.append(newMessage) + } + } + } 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 + webSocketConnectionState = state + } + .store(in: &cancellables) + } } struct ChatMessageItem: View { 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 + + + -- 2.49.1 From 3edcdd72afe6ff5163aa9b762b9815cbaffb5c7d Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 30 Oct 2025 23:58:05 +0800 Subject: [PATCH 25/29] :bug: Fixed stupid app state updated twice --- .../Services/NetworkService.swift | 15 ++++---- .../State/AppState.swift | 38 ++++++++++++------- .../State/WatchConnectivityService.swift | 9 +++++ .../Views/ExploreView.swift | 3 -- 4 files changed, 42 insertions(+), 23 deletions(-) diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index efae0a8f..8c881134 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -455,17 +455,19 @@ class NetworkService { } func connectWebSocket(token: String, serverUrl: String) { - connectLock.lock() - defer { connectLock.unlock() } - 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) @@ -490,13 +492,12 @@ class NetworkService { return } - print("[WebSocket] Trying connecting to \(url)") - self.currentConnectionState = .connecting - 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() diff --git a/ios/WatchRunner Watch App/State/AppState.swift b/ios/WatchRunner Watch App/State/AppState.swift index 2c6392ec..df67e909 100644 --- a/ios/WatchRunner Watch App/State/AppState.swift +++ b/ios/WatchRunner Watch App/State/AppState.swift @@ -19,22 +19,34 @@ class AppState: ObservableObject { let networkService = NetworkService() private var wcService = WatchConnectivityService() private var cancellables = Set() + private var hasAttemptedConnection = false init() { - wcService.$token.combineLatest(wcService.$serverUrl) + wcService.$token.combineLatest(wcService.$serverUrl, wcService.$isFetched) .receive(on: DispatchQueue.main) - .sink { [weak self] token, serverUrl in - self?.token = token - self?.serverUrl = serverUrl - if let token = token, let serverUrl = serverUrl { - self?.isReady = true - // Auto-connect WebSocket here - self?.networkService.connectWebSocket(token: token, serverUrl: serverUrl) - } else { - self?.isReady = false - // Disconnect WebSocket if token or serverUrl become nil - self?.networkService.disconnectWebSocket() - } } + .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) } diff --git a/ios/WatchRunner Watch App/State/WatchConnectivityService.swift b/ios/WatchRunner Watch App/State/WatchConnectivityService.swift index 720665c4..7db1f9a8 100644 --- a/ios/WatchRunner Watch App/State/WatchConnectivityService.swift +++ b/ios/WatchRunner Watch App/State/WatchConnectivityService.swift @@ -14,6 +14,7 @@ import Combine 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 @@ -30,6 +31,7 @@ class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { // 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?) { @@ -58,7 +60,13 @@ class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { } 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 } @@ -68,6 +76,7 @@ class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { 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) diff --git a/ios/WatchRunner Watch App/Views/ExploreView.swift b/ios/WatchRunner Watch App/Views/ExploreView.swift index c8c48829..692905c0 100644 --- a/ios/WatchRunner Watch App/Views/ExploreView.swift +++ b/ios/WatchRunner Watch App/Views/ExploreView.swift @@ -49,9 +49,6 @@ struct ExploreView: View { .environmentObject(appState) } else { ProgressView { Text("Connecting to phone...") } - .onAppear { - appState.requestData() - } } } .sheet(isPresented: $isComposing) { -- 2.49.1 From 0ca801d96314ffbdb6309b6c4dbe049e01793c30 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 31 Oct 2025 00:11:24 +0800 Subject: [PATCH 26/29] :sparkles: Live updates of chat messages with websocket on watchOS --- .../Views/AppInfoHeaderView.swift | 2 +- .../Views/{ChatView.swift => ChatViews.swift} | 56 ++++++++++++++----- 2 files changed, 42 insertions(+), 16 deletions(-) rename ios/WatchRunner Watch App/Views/{ChatView.swift => ChatViews.swift} (90%) diff --git a/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift b/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift index 9384a5dd..94b94fc9 100644 --- a/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift +++ b/ios/WatchRunner Watch App/Views/AppInfoHeaderView.swift @@ -26,7 +26,7 @@ struct AppInfoHeaderView : View { // Display WebSocket connection status Text(webSocketStatusMessage) - .font(.caption2) + .font(.system(size: 10)) .foregroundColor(.secondary) } } diff --git a/ios/WatchRunner Watch App/Views/ChatView.swift b/ios/WatchRunner Watch App/Views/ChatViews.swift similarity index 90% rename from ios/WatchRunner Watch App/Views/ChatView.swift rename to ios/WatchRunner Watch App/Views/ChatViews.swift index d5fc71d7..040a420c 100644 --- a/ios/WatchRunner Watch App/Views/ChatView.swift +++ b/ios/WatchRunner Watch App/Views/ChatViews.swift @@ -259,18 +259,22 @@ struct ChatRoomView: View { @State private var messages: [SnChatMessage] = [] @State private var isLoading = false @State private var error: Error? - @State private var webSocketConnectionState: WebSocketState = .disconnected // New state for WebSocket status + @State private var wsState: WebSocketState = .disconnected // New state for WebSocket status @State private var cancellables = Set() // For managing subscriptions var body: some View { VStack { // Display WebSocket connection status - Text(webSocketStatusMessage) - .font(.caption2) - .foregroundColor(.secondary) - .padding(.vertical, 2) - .animation(.easeInOut, value: webSocketConnectionState) // Animate status changes + if (wsState != .connected) + { + Text(webSocketStatusMessage) + .font(.caption2) + .foregroundColor(.secondary) + .padding(.vertical, 2) + .animation(.easeInOut, value: wsState) // Animate status changes + .transition(.opacity) + } if isLoading { ProgressView() @@ -336,7 +340,7 @@ struct ChatRoomView: View { } private var webSocketStatusMessage: String { - switch webSocketConnectionState { + switch wsState { case .connected: return "Connected" case .connecting: return "Connecting..." case .disconnected: return "Disconnected" @@ -368,6 +372,15 @@ struct ChatRoomView: View { isLoading = 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 @@ -377,20 +390,33 @@ struct ChatRoomView: View { print("[ChatRoomView] WebSocket packet stream error: \(err.localizedDescription)") } }, receiveValue: { packet in - // Assuming 'message.created' is the type for new messages - if packet.type == "message.created", + 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 newMessage = try decoder.decode(SnChatMessage.self, from: jsonData) + let message = try decoder.decode(SnChatMessage.self, from: jsonData) - if newMessage.chatRoomId == room.id { - // Avoid adding duplicates - if !messages.contains(where: { $0.id == newMessage.id }) { - messages.append(newMessage) + 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 { @@ -404,7 +430,7 @@ struct ChatRoomView: View { appState.networkService.stateStream .receive(on: DispatchQueue.main) // Ensure UI updates on main thread .sink { state in - webSocketConnectionState = state + wsState = state } .store(in: &cancellables) } -- 2.49.1 From dc6af6d9e5d1875137093b2790f9da1985782157 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 31 Oct 2025 00:20:35 +0800 Subject: [PATCH 27/29] :sparkles: Render attachments of message on watchOS --- ios/Runner.xcodeproj/project.pbxproj | 12 ---------- .../Views/ChatViews.swift | 24 ++++++++++++++++++- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3e0e6fad..bf906178 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -679,14 +679,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -744,14 +740,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -780,14 +772,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WatchRunner Watch App/Pods-WatchRunner Watch App-frameworks.sh\"\n"; diff --git a/ios/WatchRunner Watch App/Views/ChatViews.swift b/ios/WatchRunner Watch App/Views/ChatViews.swift index 040a420c..fd04d826 100644 --- a/ios/WatchRunner Watch App/Views/ChatViews.swift +++ b/ios/WatchRunner Watch App/Views/ChatViews.swift @@ -260,6 +260,7 @@ struct ChatRoomView: View { @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 cancellables = Set() // For managing subscriptions @@ -351,11 +352,17 @@ struct ChatRoomView: View { } 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, @@ -364,6 +371,7 @@ struct ChatRoomView: View { ) // 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 @@ -487,12 +495,26 @@ struct ChatMessageItem: View { .foregroundColor(.secondary) } - if let content = message.content { + 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) -- 2.49.1 From ab90d244b58ef835b00e8aa461797d1eea059707 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 31 Oct 2025 00:39:06 +0800 Subject: [PATCH 28/29] :sparkles: Able to send message on watchOS --- .../Views/ChatViews.swift | 93 ++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/ios/WatchRunner Watch App/Views/ChatViews.swift b/ios/WatchRunner Watch App/Views/ChatViews.swift index fd04d826..0f398e2f 100644 --- a/ios/WatchRunner Watch App/Views/ChatViews.swift +++ b/ios/WatchRunner Watch App/Views/ChatViews.swift @@ -261,6 +261,8 @@ struct ChatRoomView: View { @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 cancellables = Set() // For managing subscriptions @@ -316,7 +318,7 @@ struct ChatRoomView: View { scrollView.scrollTo(lastMessage.id, anchor: .bottom) } } - .onChange(of: messages.count) { _ in + .onChange(of: messages.count) { _, _ in // Scroll to bottom when new messages arrive if let lastMessage = messages.last { withAnimation { @@ -326,6 +328,35 @@ struct ChatRoomView: View { } } } + + // Message input area + 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) } .navigationTitle(room.name ?? "Chat") .task { @@ -380,6 +411,66 @@ struct ChatRoomView: View { 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"] -- 2.49.1 From 6273b2d917c66441c45b3017f42a524fcaeb7f36 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 31 Oct 2025 00:56:51 +0800 Subject: [PATCH 29/29] :lipstick: Auto hide input on watchOS --- .../Views/ChatViews.swift | 69 ++++++++++++------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/ios/WatchRunner Watch App/Views/ChatViews.swift b/ios/WatchRunner Watch App/Views/ChatViews.swift index 0f398e2f..997c9dc9 100644 --- a/ios/WatchRunner Watch App/Views/ChatViews.swift +++ b/ios/WatchRunner Watch App/Views/ChatViews.swift @@ -263,6 +263,8 @@ struct ChatRoomView: View { @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 @@ -311,6 +313,7 @@ struct ChatRoomView: View { } .padding(.horizontal) .padding(.vertical, 8) + .padding(.bottom, 8) } .onAppear { // Scroll to bottom when messages load @@ -326,37 +329,55 @@ struct ChatRoomView: View { } } } + .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 - HStack(spacing: 8) { - TextField("Send message...", text: $messageText) - .font(.system(size: 14)) - .disabled(isSending) - .frame(height: 40) + 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) + 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) } - .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)) } - .padding(.horizontal) - .padding(.top, 8) } .navigationTitle(room.name ?? "Chat") .task { @@ -368,6 +389,8 @@ struct ChatRoomView: View { .onDisappear { cancellables.forEach { $0.cancel() } cancellables.removeAll() + scrollTimer?.invalidate() + scrollTimer = nil } } -- 2.49.1