♻️ Refactor watchOS content view
This commit is contained in:
		| @@ -1,4 +1,3 @@ | |||||||
|  |  | ||||||
| // | // | ||||||
| //  ContentView.swift | //  ContentView.swift | ||||||
| //  WatchRunner Watch App | //  WatchRunner Watch App | ||||||
| @@ -7,833 +6,6 @@ | |||||||
| // | // | ||||||
|  |  | ||||||
| import SwiftUI | 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<AnyCancellable>() |  | ||||||
|  |  | ||||||
|     init() { |  | ||||||
|         wcService.$token.combineLatest(wcService.$serverUrl) |  | ||||||
|             .receive(on: DispatchQueue.main) |  | ||||||
|             .sink { [weak self] token, serverUrl in |  | ||||||
|                 self?.token = token |  | ||||||
|                 self?.serverUrl = serverUrl |  | ||||||
|                 if token != nil && serverUrl != nil { |  | ||||||
|                     self?.isReady = true |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             .store(in: &cancellables) |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     func requestData() { |  | ||||||
|         wcService.requestDataFromPhone() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // 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. | // The root view of the app. | ||||||
| struct ContentView: View { | struct ContentView: View { | ||||||
| @@ -841,37 +13,3 @@ struct ContentView: View { | |||||||
|         ExploreView() |         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()) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|   | |||||||
							
								
								
									
										88
									
								
								ios/WatchRunner Watch App/Layouts/FlowLayout.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								ios/WatchRunner Watch App/Layouts/FlowLayout.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | |||||||
|  | // | ||||||
|  | //  FlowLayout.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | // MARK: - Custom Layouts | ||||||
|  |  | ||||||
|  | struct FlowLayout: Layout { | ||||||
|  |     var alignment: HorizontalAlignment = .leading | ||||||
|  |     var spacing: CGFloat = 10 | ||||||
|  |  | ||||||
|  |     func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { | ||||||
|  |         let containerWidth = proposal.width ?? 0 | ||||||
|  |         let sizes = subviews.map { $0.sizeThatFits(.unspecified) } | ||||||
|  |  | ||||||
|  |         var currentX: CGFloat = 0 | ||||||
|  |         var currentY: CGFloat = 0 | ||||||
|  |         var lineHeight: CGFloat = 0 | ||||||
|  |         var totalHeight: CGFloat = 0 | ||||||
|  |  | ||||||
|  |         for size in sizes { | ||||||
|  |             if currentX + size.width > containerWidth { | ||||||
|  |                 // New line | ||||||
|  |                 currentX = 0 | ||||||
|  |                 currentY += lineHeight + spacing | ||||||
|  |                 totalHeight = currentY + size.height | ||||||
|  |                 lineHeight = 0 | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             currentX += size.width + spacing | ||||||
|  |             lineHeight = max(lineHeight, size.height) | ||||||
|  |         } | ||||||
|  |         totalHeight = currentY + lineHeight | ||||||
|  |  | ||||||
|  |         return CGSize(width: containerWidth, height: totalHeight) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { | ||||||
|  |         let containerWidth = bounds.width | ||||||
|  |         let sizes = subviews.map { $0.sizeThatFits(.unspecified) } | ||||||
|  |  | ||||||
|  |         var currentX: CGFloat = 0 | ||||||
|  |         var currentY: CGFloat = 0 | ||||||
|  |         var lineHeight: CGFloat = 0 | ||||||
|  |         var lineElements: [(offset: Int, size: CGSize)] = [] | ||||||
|  |  | ||||||
|  |         func placeLine() { | ||||||
|  |             let lineWidth = lineElements.map { $0.size.width }.reduce(0, +) + CGFloat(lineElements.count - 1) * spacing | ||||||
|  |             var startX: CGFloat = 0 | ||||||
|  |             switch alignment { | ||||||
|  |             case .leading: | ||||||
|  |                 startX = bounds.minX | ||||||
|  |             case .center: | ||||||
|  |                 startX = bounds.minX + (containerWidth - lineWidth) / 2 | ||||||
|  |             case .trailing: | ||||||
|  |                 startX = bounds.maxX - lineWidth | ||||||
|  |             default: | ||||||
|  |                 startX = bounds.minX | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var xOffset = startX | ||||||
|  |             for (offset, size) in lineElements { | ||||||
|  |                 subviews[offset].place(at: CGPoint(x: xOffset, y: bounds.minY + currentY), proposal: ProposedViewSize(size)) // Use bounds.minY + currentY | ||||||
|  |                 xOffset += size.width + spacing | ||||||
|  |             } | ||||||
|  |             lineElements.removeAll() // Clear elements for the next line | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         for (offset, size) in sizes.enumerated() { | ||||||
|  |             if currentX + size.width > containerWidth && !lineElements.isEmpty { | ||||||
|  |                 // New line | ||||||
|  |                 placeLine() | ||||||
|  |                 currentX = 0 | ||||||
|  |                 currentY += lineHeight + spacing | ||||||
|  |                 lineHeight = 0 | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             lineElements.append((offset, size)) | ||||||
|  |             currentX += size.width + spacing | ||||||
|  |             lineHeight = max(lineHeight, size.height) | ||||||
|  |         } | ||||||
|  |         placeLine() // Place the last line | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										126
									
								
								ios/WatchRunner Watch App/Models/Models.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								ios/WatchRunner Watch App/Models/Models.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								ios/WatchRunner Watch App/Previews/CustomPreviews.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								ios/WatchRunner Watch App/Previews/CustomPreviews.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | // | ||||||
|  | //  CustomPreviews.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     NavigationStack { | ||||||
|  |         ActivityListView(filter: "Preview", mockActivities: SnActivity.mock) | ||||||
|  |             .environmentObject(AppState()) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										35
									
								
								ios/WatchRunner Watch App/Previews/MockData.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								ios/WatchRunner Watch App/Previews/MockData.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | // | ||||||
|  | //  MockData.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import Foundation | ||||||
|  |  | ||||||
|  | #if DEBUG | ||||||
|  | extension SnActivity { | ||||||
|  |     static var mock: [SnActivity] { | ||||||
|  |         let mockPublisher = SnPublisher(id: "pub1", name: "Mock Publisher", nick: "mock_nick", description: "A publisher for testing", picture: SnCloudFile(id: "mock_avatar_id", mimeType: "image/png")) | ||||||
|  |         let mockTag1 = SnPostTag(id: "tag1", slug: "swiftui", name: "SwiftUI") | ||||||
|  |         let mockTag2 = SnPostTag(id: "tag2", slug: "watchos", name: "watchOS") | ||||||
|  |         let mockAttachment1 = SnCloudFile(id: "mock_image_id_1", mimeType: "image/jpeg") | ||||||
|  |         let mockAttachment2 = SnCloudFile(id: "mock_image_id_2", mimeType: "image/png") | ||||||
|  |  | ||||||
|  |         let post1 = SnPost(id: "1", title: "Hello from a Mock Post!", content: "This is a mock post content. It can be a bit longer to see how it wraps.", publisher: mockPublisher, attachments: [mockAttachment1, mockAttachment2], tags: [mockTag1, mockTag2]) | ||||||
|  |         let activity1 = SnActivity(id: "1", type: "posts.new", data: .post(post1), createdAt: Date()) | ||||||
|  |          | ||||||
|  |         let realm1 = SnRealm(id: "r1", name: "SwiftUI Previews", description: "A place for designing in previews.") | ||||||
|  |         let publisher1 = SnPublisher(id: "p1", name: "The Mock Times", nick: "mock_times", description: "All the news that's fit to mock.", picture: nil) | ||||||
|  |         let article1 = SnWebArticle(id: "a1", title: "The Art of Mocking Data", url: "https://example.com") | ||||||
|  |  | ||||||
|  |         let discoveryItem1 = DiscoveryItem(type: "realm", data: .realm(realm1)) | ||||||
|  |         let discoveryItem2 = DiscoveryItem(type: "publisher", data: .publisher(publisher1)) | ||||||
|  |         let discoveryItem3 = DiscoveryItem(type: "article", data: .article(article1)) | ||||||
|  |         let discoveryData = DiscoveryData(items: [discoveryItem1, discoveryItem2, discoveryItem3]) | ||||||
|  |         let activity2 = SnActivity(id: "2", type: "discovery", data: .discovery(discoveryData), createdAt: Date()) | ||||||
|  |          | ||||||
|  |         return [activity1, activity2] | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | #endif | ||||||
							
								
								
									
										103
									
								
								ios/WatchRunner Watch App/Services/ImageLoader.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								ios/WatchRunner Watch App/Services/ImageLoader.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										69
									
								
								ios/WatchRunner Watch App/Services/NetworkService.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								ios/WatchRunner Watch App/Services/NetworkService.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										38
									
								
								ios/WatchRunner Watch App/State/AppState.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								ios/WatchRunner Watch App/State/AppState.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AnyCancellable>() | ||||||
|  |  | ||||||
|  |     init() { | ||||||
|  |         wcService.$token.combineLatest(wcService.$serverUrl) | ||||||
|  |             .receive(on: DispatchQueue.main) | ||||||
|  |             .sink { [weak self] token, serverUrl in | ||||||
|  |                 self?.token = token | ||||||
|  |                 self?.serverUrl = serverUrl | ||||||
|  |                 if token != nil && serverUrl != nil { | ||||||
|  |                     self?.isReady = true | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .store(in: &cancellables) | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     func requestData() { | ||||||
|  |         wcService.requestDataFromPhone() | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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)") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										21
									
								
								ios/WatchRunner Watch App/Utils/AttachmentUtils.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								ios/WatchRunner Watch App/Utils/AttachmentUtils.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  | } | ||||||
							
								
								
									
										47
									
								
								ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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 | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										63
									
								
								ios/WatchRunner Watch App/Views/ActivityListView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								ios/WatchRunner Watch App/Views/ActivityListView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										38
									
								
								ios/WatchRunner Watch App/Views/AttachmentImageView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								ios/WatchRunner Watch App/Views/AttachmentImageView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										52
									
								
								ios/WatchRunner Watch App/Views/ComposePostView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								ios/WatchRunner Watch App/Views/ComposePostView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ?? "") | ||||||
|  |             }) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										110
									
								
								ios/WatchRunner Watch App/Views/DiscoveryViews.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								ios/WatchRunner Watch App/Views/DiscoveryViews.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | |||||||
|  | // | ||||||
|  | //  DiscoveryViews.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/29. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | struct DiscoveryView: View { | ||||||
|  |     let discoveryData: DiscoveryData | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         NavigationLink(destination: DiscoveryDetailView(discoveryData: discoveryData)) { | ||||||
|  |             VStack(alignment: .leading) { | ||||||
|  |                 Text("Discovery") | ||||||
|  |                     .font(.headline) | ||||||
|  |                 Text("\(discoveryData.items.count) new items to discover") | ||||||
|  |                     .font(.subheadline) | ||||||
|  |                     .foregroundColor(.secondary) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct DiscoveryDetailView: View { | ||||||
|  |     let discoveryData: DiscoveryData | ||||||
|  |  | ||||||
|  |     var body: some View { | ||||||
|  |         List(discoveryData.items) { item in | ||||||
|  |             NavigationLink(destination: destinationView(for: item)) { | ||||||
|  |                 itemView(for: item) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Discovery") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @ViewBuilder | ||||||
|  |     private func itemView(for item: DiscoveryItem) -> some View { | ||||||
|  |         VStack(alignment: .leading) { | ||||||
|  |             switch item.data { | ||||||
|  |             case .realm(let realm): | ||||||
|  |                 Text("Realm").font(.headline) | ||||||
|  |                 Text(realm.name).foregroundColor(.secondary) | ||||||
|  |             case .publisher(let publisher): | ||||||
|  |                 Text("Publisher").font(.headline) | ||||||
|  |                 Text(publisher.name).foregroundColor(.secondary) | ||||||
|  |             case .article(let article): | ||||||
|  |                 Text("Article").font(.headline) | ||||||
|  |                 Text(article.title).foregroundColor(.secondary) | ||||||
|  |             case .unknown: | ||||||
|  |                 Text("Unknown item") | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     @ViewBuilder | ||||||
|  |     private func destinationView(for item: DiscoveryItem) -> some View { | ||||||
|  |         switch item.data { | ||||||
|  |         case .realm(let realm): | ||||||
|  |             RealmDetailView(realm: realm) | ||||||
|  |         case .publisher(let publisher): | ||||||
|  |             PublisherDetailView(publisher: publisher) | ||||||
|  |         case .article(let article): | ||||||
|  |             ArticleDetailView(article: article) | ||||||
|  |         case .unknown: | ||||||
|  |             Text("Detail view not available") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct RealmDetailView: View { | ||||||
|  |     let realm: SnRealm | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading, spacing: 8) { | ||||||
|  |             Text(realm.name).font(.headline) | ||||||
|  |             if let description = realm.description { | ||||||
|  |                 Text(description).font(.body) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Realm") | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct PublisherDetailView: View { | ||||||
|  |     let publisher: SnPublisher | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading, spacing: 8) { | ||||||
|  |             Text(publisher.name).font(.headline) | ||||||
|  |             if let description = publisher.description { | ||||||
|  |                 Text(description).font(.body) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Publisher") | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct ArticleDetailView: View { | ||||||
|  |     let article: SnWebArticle | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading, spacing: 8) { | ||||||
|  |             Text(article.title).font(.headline) | ||||||
|  |             Text(article.url).font(.caption).foregroundColor(.secondary) | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Article") | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										63
									
								
								ios/WatchRunner Watch App/Views/ExploreView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								ios/WatchRunner Watch App/Views/ExploreView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										142
									
								
								ios/WatchRunner Watch App/Views/PostViews.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								ios/WatchRunner Watch App/Views/PostViews.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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") | ||||||
|  |     } | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user