♻️ Refactor watchOS content view
This commit is contained in:
		| @@ -1,4 +1,3 @@ | ||||
|  | ||||
| // | ||||
| //  ContentView.swift | ||||
| //  WatchRunner Watch App | ||||
| @@ -7,871 +6,10 @@ | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Combine | ||||
| import WatchConnectivity | ||||
| import Kingfisher // Import Kingfisher | ||||
| import KingfisherWebP // Import KingfisherWebP | ||||
|  | ||||
| // MARK: - App State | ||||
|  | ||||
| @MainActor | ||||
| class AppState: ObservableObject { | ||||
|     @Published var token: String? = nil | ||||
|     @Published var serverUrl: String? = nil | ||||
|     @Published var isReady = false | ||||
|      | ||||
|     private var wcService = WatchConnectivityService() | ||||
|     private var cancellables = Set<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. | ||||
| struct ContentView: View { | ||||
|     var body: some View { | ||||
|         ExploreView() | ||||
|     } | ||||
| } | ||||
|  | ||||
| #if DEBUG | ||||
| extension SnActivity { | ||||
|     static var mock: [SnActivity] { | ||||
|         let mockPublisher = SnPublisher(id: "pub1", name: "Mock Publisher", nick: "mock_nick", description: "A publisher for testing", picture: SnCloudFile(id: "mock_avatar_id", mimeType: "image/png")) | ||||
|         let mockTag1 = SnPostTag(id: "tag1", slug: "swiftui", name: "SwiftUI") | ||||
|         let mockTag2 = SnPostTag(id: "tag2", slug: "watchos", name: "watchOS") | ||||
|         let mockAttachment1 = SnCloudFile(id: "mock_image_id_1", mimeType: "image/jpeg") | ||||
|         let mockAttachment2 = SnCloudFile(id: "mock_image_id_2", mimeType: "image/png") | ||||
|  | ||||
|         let post1 = SnPost(id: "1", title: "Hello from a Mock Post!", content: "This is a mock post content. It can be a bit longer to see how it wraps.", publisher: mockPublisher, attachments: [mockAttachment1, mockAttachment2], tags: [mockTag1, mockTag2]) | ||||
|         let activity1 = SnActivity(id: "1", type: "posts.new", data: .post(post1), createdAt: Date()) | ||||
|          | ||||
|         let realm1 = SnRealm(id: "r1", name: "SwiftUI Previews", description: "A place for designing in previews.") | ||||
|         let publisher1 = SnPublisher(id: "p1", name: "The Mock Times", nick: "mock_times", description: "All the news that's fit to mock.", picture: nil) | ||||
|         let article1 = SnWebArticle(id: "a1", title: "The Art of Mocking Data", url: "https://example.com") | ||||
|  | ||||
|         let discoveryItem1 = DiscoveryItem(type: "realm", data: .realm(realm1)) | ||||
|         let discoveryItem2 = DiscoveryItem(type: "publisher", data: .publisher(publisher1)) | ||||
|         let discoveryItem3 = DiscoveryItem(type: "article", data: .article(article1)) | ||||
|         let discoveryData = DiscoveryData(items: [discoveryItem1, discoveryItem2, discoveryItem3]) | ||||
|         let activity2 = SnActivity(id: "2", type: "discovery", data: .discovery(discoveryData), createdAt: Date()) | ||||
|          | ||||
|         return [activity1, activity2] | ||||
|     } | ||||
| } | ||||
| #endif | ||||
|  | ||||
| #Preview { | ||||
|     NavigationStack { | ||||
|         ActivityListView(filter: "Preview", mockActivities: SnActivity.mock) | ||||
|             .environmentObject(AppState()) | ||||
|     } | ||||
| } | ||||
| } | ||||
							
								
								
									
										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