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()) + } +}