diff --git a/ios/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift index d4af4eda..47e444c9 100644 --- a/ios/WatchRunner Watch App/ContentView.swift +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -1,4 +1,3 @@ - // // ContentView.swift // WatchRunner Watch App @@ -7,871 +6,10 @@ // import SwiftUI -import Combine -import WatchConnectivity -import Kingfisher // Import Kingfisher -import KingfisherWebP // Import KingfisherWebP - -// MARK: - App State - -@MainActor -class AppState: ObservableObject { - @Published var token: String? = nil - @Published var serverUrl: String? = nil - @Published var isReady = false - - private var wcService = WatchConnectivityService() - private var cancellables = Set() - - init() { - wcService.$token.combineLatest(wcService.$serverUrl) - .receive(on: DispatchQueue.main) - .sink { [weak self] token, serverUrl in - self?.token = token - self?.serverUrl = serverUrl - if token != nil && serverUrl != nil { - self?.isReady = true - } - } - .store(in: &cancellables) - } - - func requestData() { - wcService.requestDataFromPhone() - } -} - -// MARK: - Watch Connectivity - -class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { - @Published var token: String? - @Published var serverUrl: String? - - private let session: WCSession - - override init() { - self.session = .default - super.init() - print("[watchOS] Activating WCSession") - self.session.delegate = self - self.session.activate() - } - - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - if let error = error { - print("[watchOS] WCSession activation failed with error: \(error.localizedDescription)") - return - } - print("[watchOS] WCSession activated with state: \(activationState.rawValue)") - if activationState == .activated { - requestDataFromPhone() - } - } - - func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { - print("[watchOS] Received message: \(message)") - DispatchQueue.main.async { - if let token = message["token"] as? String { - self.token = token - } - if let serverUrl = message["serverUrl"] as? String { - self.serverUrl = serverUrl - } - } - } - - func requestDataFromPhone() { - guard session.isReachable else { - print("[watchOS] Phone is not reachable") - return - } - - print("[watchOS] Requesting data from phone") - session.sendMessage(["request": "data"]) { [weak self] response in - print("[watchOS] Received reply: \(response)") - DispatchQueue.main.async { - if let token = response["token"] as? String { - self?.token = token - } - if let serverUrl = response["serverUrl"] as? String { - self?.serverUrl = serverUrl - } - } - } errorHandler: { error in - print("[watchOS] sendMessage failed with error: \(error.localizedDescription)") - } - } -} - - -// MARK: - Models - -struct AppToken: Codable { - let token: String -} - -struct SnActivity: Codable, Identifiable { - let id: String - let type: String - let data: ActivityData? - let createdAt: Date -} - -enum ActivityData: Codable { - case post(SnPost) - case discovery(DiscoveryData) - case unknown - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let post = try? container.decode(SnPost.self) { - self = .post(post) - return - } - if let discoveryData = try? container.decode(DiscoveryData.self) { - self = .discovery(discoveryData) - return - } - self = .unknown - } - - func encode(to encoder: Encoder) throws { - // Not needed for decoding - } -} - -struct SnPost: Codable, Identifiable { - let id: String - let title: String? - let content: String? - let publisher: SnPublisher - let attachments: [SnCloudFile] - let tags: [SnPostTag] -} - -struct DiscoveryData: Codable { - let items: [DiscoveryItem] -} - -struct DiscoveryItem: Codable, Identifiable { - var id = UUID() - let type: String - let data: DiscoveryItemData - - enum CodingKeys: String, CodingKey { - case type, data - } -} - -enum DiscoveryItemData: Codable { - case realm(SnRealm) - case publisher(SnPublisher) - case article(SnWebArticle) - case unknown - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let realm = try? container.decode(SnRealm.self) { - self = .realm(realm) - return - } - if let publisher = try? container.decode(SnPublisher.self) { - self = .publisher(publisher) - return - } - if let article = try? container.decode(SnWebArticle.self) { - self = .article(article) - return - } - self = .unknown - } - - func encode(to encoder: Encoder) throws { - // Not needed for decoding - } -} - -struct SnRealm: Codable, Identifiable { - let id: String - let name: String - let description: String? -} - -struct SnPublisher: Codable, Identifiable { - let id: String - let name: String - let nick: String? - let description: String? - let picture: SnCloudFile? -} - -struct SnCloudFile: Codable, Identifiable { - let id: String - let mimeType: String? -} - -struct SnPostTag: Codable, Identifiable { - let id: String - let slug: String - let name: String? -} - -struct SnWebArticle: Codable, Identifiable { - let id: String - let title: String - let url: String -} - -// MARK: - Helper Functions - -func getAttachmentUrl(for fileId: String, serverUrl: String) -> URL? { - let urlString: String - if fileId.starts(with: "http") { - urlString = fileId - } else { - urlString = "\(serverUrl)/drive/files/\(fileId)" - } - print("[watchOS] Generated image URL: \(urlString)") - return URL(string: urlString) -} - -// MARK: - Network Service - -class NetworkService { - private let session = URLSession.shared - - func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> [SnActivity] { - guard let baseURL = URL(string: serverUrl) else { - throw URLError(.badURL) - } - var components = URLComponents(url: baseURL.appendingPathComponent("/sphere/activities"), resolvingAgainstBaseURL: false)! - var queryItems = [URLQueryItem(name: "take", value: "20")] - if filter.lowercased() != "explore" { - queryItems.append(URLQueryItem(name: "filter", value: filter.lowercased())) - } - if let cursor = cursor { - queryItems.append(URLQueryItem(name: "cursor", value: cursor)) - } - components.queryItems = queryItems - - var request = URLRequest(url: components.url!) - request.httpMethod = "GET" - request.setValue("application/json", forHTTPHeaderField: "Accept") - - request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") - request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - - let (data, _) = try await session.data(for: request) - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - decoder.keyDecodingStrategy = .convertFromSnakeCase - - return try decoder.decode([SnActivity].self, from: data) - } -} - -// MARK: - View Models - -@MainActor -class ActivityViewModel: ObservableObject { - @Published var activities: [SnActivity] = [] - @Published var isLoading = false - @Published var errorMessage: String? - - private let networkService = NetworkService() - let filter: String - private var isMock = false - - init(filter: String, mockActivities: [SnActivity]? = nil) { - self.filter = filter - if let mockActivities = mockActivities { - self.activities = mockActivities - self.isMock = true - } - } - - func fetchActivities(token: String, serverUrl: String) async { - if isMock { return } - guard !isLoading else { return } - isLoading = true - errorMessage = nil - - do { - let fetchedActivities = try await networkService.fetchActivities(filter: filter, token: token, serverUrl: serverUrl) - self.activities = fetchedActivities - } catch { - self.errorMessage = error.localizedDescription - print("[watchOS] fetchActivities failed with error: \(error)") - } - - isLoading = false - } -} - -// MARK: - Custom Layouts - -struct FlowLayout: Layout { - var alignment: HorizontalAlignment = .leading - var spacing: CGFloat = 10 - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let containerWidth = proposal.width ?? 0 - let sizes = subviews.map { $0.sizeThatFits(.unspecified) } - - var currentX: CGFloat = 0 - var currentY: CGFloat = 0 - var lineHeight: CGFloat = 0 - var totalHeight: CGFloat = 0 - - for size in sizes { - if currentX + size.width > containerWidth { - // New line - currentX = 0 - currentY += lineHeight + spacing - totalHeight = currentY + size.height - lineHeight = 0 - } - - currentX += size.width + spacing - lineHeight = max(lineHeight, size.height) - } - totalHeight = currentY + lineHeight - - return CGSize(width: containerWidth, height: totalHeight) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let containerWidth = bounds.width - let sizes = subviews.map { $0.sizeThatFits(.unspecified) } - - var currentX: CGFloat = 0 - var currentY: CGFloat = 0 - var lineHeight: CGFloat = 0 - var lineElements: [(offset: Int, size: CGSize)] = [] - - func placeLine() { - let lineWidth = lineElements.map { $0.size.width }.reduce(0, +) + CGFloat(lineElements.count - 1) * spacing - var startX: CGFloat = 0 - switch alignment { - case .leading: - startX = bounds.minX - case .center: - startX = bounds.minX + (containerWidth - lineWidth) / 2 - case .trailing: - startX = bounds.maxX - lineWidth - default: - startX = bounds.minX - } - - var xOffset = startX - for (offset, size) in lineElements { - subviews[offset].place(at: CGPoint(x: xOffset, y: bounds.minY + currentY), proposal: ProposedViewSize(size)) // Use bounds.minY + currentY - xOffset += size.width + spacing - } - lineElements.removeAll() // Clear elements for the next line - } - - for (offset, size) in sizes.enumerated() { - if currentX + size.width > containerWidth && !lineElements.isEmpty { - // New line - placeLine() - currentX = 0 - currentY += lineHeight + spacing - lineHeight = 0 - } - - lineElements.append((offset, size)) - currentX += size.width + spacing - lineHeight = max(lineHeight, size.height) - } - placeLine() // Place the last line - } -} - -// MARK: - Image Loader - -@MainActor -class ImageLoader: ObservableObject { - @Published var image: Image? - @Published var errorMessage: String? - @Published var isLoading = false - - private var dataTask: URLSessionDataTask? - private let session: URLSession - - init(session: URLSession = .shared) { - self.session = session - } - - func loadImage(from initialUrl: URL, token: String) async { - isLoading = true - errorMessage = nil - image = nil - - do { - // First request with Authorization header - var request = URLRequest(url: initialUrl) - request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") - request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") - - let (data, response) = try await session.data(for: request) - - if let httpResponse = response as? HTTPURLResponse { - if httpResponse.statusCode == 302, let redirectLocation = httpResponse.allHeaderFields["Location"] as? String, let redirectUrl = URL(string: redirectLocation) { - print("[watchOS] Redirecting to: \(redirectUrl)") - // Second request to the redirected URL (S3 signed URL) without Authorization header - let (redirectData, _) = try await session.data(from: redirectUrl) - if let uiImage = UIImage(data: redirectData) { - self.image = Image(uiImage: uiImage) - } else { - // Try KingfisherWebP for WebP - let processor = WebPProcessor.default // Correct usage - if let kfImage = processor.process(item: .data(redirectData), options: KingfisherParsedOptionsInfo( - [ - .processor(processor), - .loadDiskFileSynchronously, - .cacheOriginalImage - ] - )) { - self.image = Image(uiImage: kfImage) - } else { - self.errorMessage = "Invalid image data from redirect (could not decode with KingfisherWebP)." - } - } - } else if httpResponse.statusCode == 200 { - if let uiImage = UIImage(data: data) { - self.image = Image(uiImage: uiImage) - } else { - // Try KingfisherWebP for WebP - let processor = WebPProcessor.default // Correct usage - if let kfImage = processor.process(item: .data(data), options: KingfisherParsedOptionsInfo( - [ - .processor(processor), - .loadDiskFileSynchronously, - .cacheOriginalImage - ] - )) { - self.image = Image(uiImage: kfImage) - } else { - self.errorMessage = "Invalid image data (could not decode with KingfisherWebP)." - } - } - } else { - self.errorMessage = "HTTP Status Code: \(httpResponse.statusCode)" - } - } - } catch { - self.errorMessage = error.localizedDescription - print("[watchOS] Image loading failed: \(error.localizedDescription)") - } - isLoading = false - } - - func cancel() { - dataTask?.cancel() - } -} - -// MARK: - Views - -struct ActivityListView: View { - @StateObject private var viewModel: ActivityViewModel - @EnvironmentObject var appState: AppState - - init(filter: String, mockActivities: [SnActivity]? = nil) { - _viewModel = StateObject(wrappedValue: ActivityViewModel(filter: filter, mockActivities: mockActivities)) - } - - var body: some View { - Group { - if viewModel.isLoading { - ProgressView() - } else if let errorMessage = viewModel.errorMessage { - VStack { - Text("Error fetching data") - .font(.headline) - Text(errorMessage) - .font(.caption) - .lineLimit(nil) - } - .padding() - } else if viewModel.activities.isEmpty { - Text("No activities found.") - } else { - List(viewModel.activities) { activity in - switch activity.type { - case "posts.new", "posts.new.replies": - if case .post(let post) = activity.data { - NavigationLink(destination: PostDetailView(post: post)) { - PostRowView(post: post) - } - } - case "discovery": - if case .discovery(let discoveryData) = activity.data { - DiscoveryView(discoveryData: discoveryData) - } - default: - Text("Unknown activity type: \(activity.type)") - } - } - } - } - .task { - // Only fetch if appState is ready and token/serverUrl are available - if appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl { - await viewModel.fetchActivities(token: token, serverUrl: serverUrl) - } - } - .navigationTitle(viewModel.filter) - .navigationBarTitleDisplayMode(.inline) - } -} - -struct PostRowView: View { - let post: SnPost - @EnvironmentObject var appState: AppState - @StateObject private var imageLoader = ImageLoader() // Instantiate ImageLoader - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { - if imageLoader.isLoading { - ProgressView() - .frame(width: 24, height: 24) - } else if let image = imageLoader.image { - image - .resizable() - .frame(width: 24, height: 24) - .clipShape(Circle()) - } else if let errorMessage = imageLoader.errorMessage { - Text("Failed: \(errorMessage)") - .font(.caption) - .foregroundColor(.red) - .frame(width: 24, height: 24) - } else { - // Placeholder if no image and not loading - Image(systemName: "person.circle.fill") - .resizable() - .frame(width: 24, height: 24) - .clipShape(Circle()) - .foregroundColor(.gray) - } - } - Text(post.publisher.nick ?? post.publisher.name) - .font(.subheadline) - .bold() - } - .task(id: post.publisher.picture?.id) { // Use task(id:) to reload image when pictureId changes - if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { - await imageLoader.loadImage(from: imageUrl, token: token) - } - } - - if let title = post.title, !title.isEmpty { - Text(title) - .font(.headline) - } - - if let content = post.content, !content.isEmpty { - Text(content) - .font(.body) - } - } - } -} - -struct PostDetailView: View { - let post: SnPost - @EnvironmentObject var appState: AppState - @StateObject private var publisherImageLoader = ImageLoader() // Instantiate ImageLoader for publisher avatar - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 8) { - HStack { - if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { - if publisherImageLoader.isLoading { - ProgressView() - .frame(width: 32, height: 32) - } else if let image = publisherImageLoader.image { - image - .resizable() - .frame(width: 32, height: 32) - .clipShape(Circle()) - } else if let errorMessage = publisherImageLoader.errorMessage { - Text("Failed: \(errorMessage)") - .font(.caption) - .foregroundColor(.red) - .frame(width: 32, height: 32) - } else { - Image(systemName: "person.circle.fill") - .resizable() - .frame(width: 32, height: 32) - .clipShape(Circle()) - .foregroundColor(.gray) - } - } - Text("@\(post.publisher.name)") - .font(.headline) - } - .task(id: post.publisher.picture?.id) { // Use task(id:) to reload image when pictureId changes - if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { - await publisherImageLoader.loadImage(from: imageUrl, token: token) - } - } - - if let title = post.title, !title.isEmpty { - Text(title) - .font(.title2) - .bold() - } - - if let content = post.content, !content.isEmpty { - Text(content) - .font(.body) - } - - if !post.attachments.isEmpty { - Divider() - Text("Attachments").font(.headline) - ForEach(post.attachments) { attachment in - AttachmentImageView(attachment: attachment) - } - } - - if !post.tags.isEmpty { - Divider() - Text("Tags").font(.headline) - FlowLayout(alignment: .leading, spacing: 4) { - ForEach(post.tags) { tag in - Text("#\(tag.name ?? tag.slug)") - .font(.caption) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(Capsule().fill(Color.accentColor.opacity(0.2))) - .cornerRadius(5) - } - } - } - } - .padding() - } - .navigationTitle("Post") - } -} - -struct AttachmentImageView: View { - let attachment: SnCloudFile - @EnvironmentObject var appState: AppState - @StateObject private var imageLoader = ImageLoader() - - var body: some View { - Group { - if imageLoader.isLoading { - ProgressView() - } else if let image = imageLoader.image { - image - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity) - } else if let errorMessage = imageLoader.errorMessage { - Text("Failed to load attachment: \(errorMessage)") - .font(.caption) - .foregroundColor(.red) - } else { - Text("File: \(attachment.id)") - } - } - .task(id: attachment.id) { - if let serverUrl = appState.serverUrl, let imageUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl), let token = appState.token, attachment.mimeType?.starts(with: "image") == true { - await imageLoader.loadImage(from: imageUrl, token: token) - } - } - } -} - -struct DiscoveryView: View { - let discoveryData: DiscoveryData - - var body: some View { - NavigationLink(destination: DiscoveryDetailView(discoveryData: discoveryData)) { - VStack(alignment: .leading) { - Text("Discovery") - .font(.headline) - Text("\(discoveryData.items.count) new items to discover") - .font(.subheadline) - .foregroundColor(.secondary) - } - } - } -} - -struct DiscoveryDetailView: View { - let discoveryData: DiscoveryData - - var body: some View { - List(discoveryData.items) { item in - NavigationLink(destination: destinationView(for: item)) { - itemView(for: item) - } - } - .navigationTitle("Discovery") - } - - @ViewBuilder - private func itemView(for item: DiscoveryItem) -> some View { - VStack(alignment: .leading) { - switch item.data { - case .realm(let realm): - Text("Realm").font(.headline) - Text(realm.name).foregroundColor(.secondary) - case .publisher(let publisher): - Text("Publisher").font(.headline) - Text(publisher.name).foregroundColor(.secondary) - case .article(let article): - Text("Article").font(.headline) - Text(article.title).foregroundColor(.secondary) - case .unknown: - Text("Unknown item") - } - } - } - - @ViewBuilder - private func destinationView(for item: DiscoveryItem) -> some View { - switch item.data { - case .realm(let realm): - RealmDetailView(realm: realm) - case .publisher(let publisher): - PublisherDetailView(publisher: publisher) - case .article(let article): - ArticleDetailView(article: article) - case .unknown: - Text("Detail view not available") - } - } -} - -struct RealmDetailView: View { - let realm: SnRealm - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(realm.name).font(.headline) - if let description = realm.description { - Text(description).font(.body) - } - } - .navigationTitle("Realm") - } -} - -struct PublisherDetailView: View { - let publisher: SnPublisher - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(publisher.name).font(.headline) - if let description = publisher.description { - Text(description).font(.body) - } - } - .navigationTitle("Publisher") - } -} - -struct ArticleDetailView: View { - let article: SnWebArticle - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(article.title).font(.headline) - Text(article.url).font(.caption).foregroundColor(.secondary) - } - .navigationTitle("Article") - } -} - - -// The main view with the TabView for filtering. -struct ExploreView: View { - @StateObject private var appState = AppState() - - var body: some View { - Group { - if appState.isReady { - TabView { - NavigationStack { - ActivityListView(filter: "Explore") - } - .tabItem { - Label("Explore", systemImage: "safari") - } - - NavigationStack { - ActivityListView(filter: "Subscriptions") - } - .tabItem { - Label("Subscriptions", systemImage: "star") - } - - NavigationStack { - ActivityListView(filter: "Friends") - } - .tabItem { - Label("Friends", systemImage: "person.2") - } - } - .environmentObject(appState) - } else { - ProgressView { Text("Connecting to phone...") } - .onAppear { - appState.requestData() - } - } - } - } -} // The root view of the app. struct ContentView: View { var body: some View { ExploreView() } -} - -#if DEBUG -extension SnActivity { - static var mock: [SnActivity] { - let mockPublisher = SnPublisher(id: "pub1", name: "Mock Publisher", nick: "mock_nick", description: "A publisher for testing", picture: SnCloudFile(id: "mock_avatar_id", mimeType: "image/png")) - let mockTag1 = SnPostTag(id: "tag1", slug: "swiftui", name: "SwiftUI") - let mockTag2 = SnPostTag(id: "tag2", slug: "watchos", name: "watchOS") - let mockAttachment1 = SnCloudFile(id: "mock_image_id_1", mimeType: "image/jpeg") - let mockAttachment2 = SnCloudFile(id: "mock_image_id_2", mimeType: "image/png") - - let post1 = SnPost(id: "1", title: "Hello from a Mock Post!", content: "This is a mock post content. It can be a bit longer to see how it wraps.", publisher: mockPublisher, attachments: [mockAttachment1, mockAttachment2], tags: [mockTag1, mockTag2]) - let activity1 = SnActivity(id: "1", type: "posts.new", data: .post(post1), createdAt: Date()) - - let realm1 = SnRealm(id: "r1", name: "SwiftUI Previews", description: "A place for designing in previews.") - let publisher1 = SnPublisher(id: "p1", name: "The Mock Times", nick: "mock_times", description: "All the news that's fit to mock.", picture: nil) - let article1 = SnWebArticle(id: "a1", title: "The Art of Mocking Data", url: "https://example.com") - - let discoveryItem1 = DiscoveryItem(type: "realm", data: .realm(realm1)) - let discoveryItem2 = DiscoveryItem(type: "publisher", data: .publisher(publisher1)) - let discoveryItem3 = DiscoveryItem(type: "article", data: .article(article1)) - let discoveryData = DiscoveryData(items: [discoveryItem1, discoveryItem2, discoveryItem3]) - let activity2 = SnActivity(id: "2", type: "discovery", data: .discovery(discoveryData), createdAt: Date()) - - return [activity1, activity2] - } -} -#endif - -#Preview { - NavigationStack { - ActivityListView(filter: "Preview", mockActivities: SnActivity.mock) - .environmentObject(AppState()) - } -} +} \ No newline at end of file diff --git a/ios/WatchRunner Watch App/Layouts/FlowLayout.swift b/ios/WatchRunner Watch App/Layouts/FlowLayout.swift new file mode 100644 index 00000000..564e769c --- /dev/null +++ b/ios/WatchRunner Watch App/Layouts/FlowLayout.swift @@ -0,0 +1,88 @@ +// +// FlowLayout.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI + +// MARK: - Custom Layouts + +struct FlowLayout: Layout { + var alignment: HorizontalAlignment = .leading + var spacing: CGFloat = 10 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let containerWidth = proposal.width ?? 0 + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + var totalHeight: CGFloat = 0 + + for size in sizes { + if currentX + size.width > containerWidth { + // New line + currentX = 0 + currentY += lineHeight + spacing + totalHeight = currentY + size.height + lineHeight = 0 + } + + currentX += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + totalHeight = currentY + lineHeight + + return CGSize(width: containerWidth, height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let containerWidth = bounds.width + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var lineHeight: CGFloat = 0 + var lineElements: [(offset: Int, size: CGSize)] = [] + + func placeLine() { + let lineWidth = lineElements.map { $0.size.width }.reduce(0, +) + CGFloat(lineElements.count - 1) * spacing + var startX: CGFloat = 0 + switch alignment { + case .leading: + startX = bounds.minX + case .center: + startX = bounds.minX + (containerWidth - lineWidth) / 2 + case .trailing: + startX = bounds.maxX - lineWidth + default: + startX = bounds.minX + } + + var xOffset = startX + for (offset, size) in lineElements { + subviews[offset].place(at: CGPoint(x: xOffset, y: bounds.minY + currentY), proposal: ProposedViewSize(size)) // Use bounds.minY + currentY + xOffset += size.width + spacing + } + lineElements.removeAll() // Clear elements for the next line + } + + for (offset, size) in sizes.enumerated() { + if currentX + size.width > containerWidth && !lineElements.isEmpty { + // New line + placeLine() + currentX = 0 + currentY += lineHeight + spacing + lineHeight = 0 + } + + lineElements.append((offset, size)) + currentX += size.width + spacing + lineHeight = max(lineHeight, size.height) + } + placeLine() // Place the last line + } +} diff --git a/ios/WatchRunner Watch App/Models/Models.swift b/ios/WatchRunner Watch App/Models/Models.swift new file mode 100644 index 00000000..ff6d83bd --- /dev/null +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -0,0 +1,126 @@ +// +// Models.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import Foundation + +// MARK: - Models + +struct AppToken: Codable { + let token: String +} + +struct SnActivity: Codable, Identifiable { + let id: String + let type: String + let data: ActivityData? + let createdAt: Date +} + +enum ActivityData: Codable { + case post(SnPost) + case discovery(DiscoveryData) + case unknown + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let post = try? container.decode(SnPost.self) { + self = .post(post) + return + } + if let discoveryData = try? container.decode(DiscoveryData.self) { + self = .discovery(discoveryData) + return + } + self = .unknown + } + + func encode(to encoder: Encoder) throws { + // Not needed for decoding + } +} + +struct SnPost: Codable, Identifiable { + let id: String + let title: String? + let content: String? + let publisher: SnPublisher + let attachments: [SnCloudFile] + let tags: [SnPostTag] +} + +struct DiscoveryData: Codable { + let items: [DiscoveryItem] +} + +struct DiscoveryItem: Codable, Identifiable { + var id = UUID() + let type: String + let data: DiscoveryItemData + + enum CodingKeys: String, CodingKey { + case type, data + } +} + +enum DiscoveryItemData: Codable { + case realm(SnRealm) + case publisher(SnPublisher) + case article(SnWebArticle) + case unknown + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let realm = try? container.decode(SnRealm.self) { + self = .realm(realm) + return + } + if let publisher = try? container.decode(SnPublisher.self) { + self = .publisher(publisher) + return + } + if let article = try? container.decode(SnWebArticle.self) { + self = .article(article) + return + } + self = .unknown + } + + func encode(to encoder: Encoder) throws { + // Not needed for decoding + } +} + +struct SnRealm: Codable, Identifiable { + let id: String + let name: String + let description: String? +} + +struct SnPublisher: Codable, Identifiable { + let id: String + let name: String + let nick: String? + let description: String? + let picture: SnCloudFile? +} + +struct SnCloudFile: Codable, Identifiable { + let id: String + let mimeType: String? +} + +struct SnPostTag: Codable, Identifiable { + let id: String + let slug: String + let name: String? +} + +struct SnWebArticle: Codable, Identifiable { + let id: String + let title: String + let url: String +} diff --git a/ios/WatchRunner Watch App/Previews/CustomPreviews.swift b/ios/WatchRunner Watch App/Previews/CustomPreviews.swift new file mode 100644 index 00000000..73afa65d --- /dev/null +++ b/ios/WatchRunner Watch App/Previews/CustomPreviews.swift @@ -0,0 +1,15 @@ +// +// CustomPreviews.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI + +#Preview { + NavigationStack { + ActivityListView(filter: "Preview", mockActivities: SnActivity.mock) + .environmentObject(AppState()) + } +} diff --git a/ios/WatchRunner Watch App/Previews/MockData.swift b/ios/WatchRunner Watch App/Previews/MockData.swift new file mode 100644 index 00000000..4e68232f --- /dev/null +++ b/ios/WatchRunner Watch App/Previews/MockData.swift @@ -0,0 +1,35 @@ +// +// MockData.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import Foundation + +#if DEBUG +extension SnActivity { + static var mock: [SnActivity] { + let mockPublisher = SnPublisher(id: "pub1", name: "Mock Publisher", nick: "mock_nick", description: "A publisher for testing", picture: SnCloudFile(id: "mock_avatar_id", mimeType: "image/png")) + let mockTag1 = SnPostTag(id: "tag1", slug: "swiftui", name: "SwiftUI") + let mockTag2 = SnPostTag(id: "tag2", slug: "watchos", name: "watchOS") + let mockAttachment1 = SnCloudFile(id: "mock_image_id_1", mimeType: "image/jpeg") + let mockAttachment2 = SnCloudFile(id: "mock_image_id_2", mimeType: "image/png") + + let post1 = SnPost(id: "1", title: "Hello from a Mock Post!", content: "This is a mock post content. It can be a bit longer to see how it wraps.", publisher: mockPublisher, attachments: [mockAttachment1, mockAttachment2], tags: [mockTag1, mockTag2]) + let activity1 = SnActivity(id: "1", type: "posts.new", data: .post(post1), createdAt: Date()) + + let realm1 = SnRealm(id: "r1", name: "SwiftUI Previews", description: "A place for designing in previews.") + let publisher1 = SnPublisher(id: "p1", name: "The Mock Times", nick: "mock_times", description: "All the news that's fit to mock.", picture: nil) + let article1 = SnWebArticle(id: "a1", title: "The Art of Mocking Data", url: "https://example.com") + + let discoveryItem1 = DiscoveryItem(type: "realm", data: .realm(realm1)) + let discoveryItem2 = DiscoveryItem(type: "publisher", data: .publisher(publisher1)) + let discoveryItem3 = DiscoveryItem(type: "article", data: .article(article1)) + let discoveryData = DiscoveryData(items: [discoveryItem1, discoveryItem2, discoveryItem3]) + let activity2 = SnActivity(id: "2", type: "discovery", data: .discovery(discoveryData), createdAt: Date()) + + return [activity1, activity2] + } +} +#endif diff --git a/ios/WatchRunner Watch App/Services/ImageLoader.swift b/ios/WatchRunner Watch App/Services/ImageLoader.swift new file mode 100644 index 00000000..3f4e1b86 --- /dev/null +++ b/ios/WatchRunner Watch App/Services/ImageLoader.swift @@ -0,0 +1,103 @@ +// +// ImageLoader.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI +import Kingfisher +import KingfisherWebP +import Combine + +// MARK: - Image Loader + +@MainActor +class ImageLoader: ObservableObject { + @Published var image: Image? + @Published var errorMessage: String? + @Published var isLoading = false + + private var dataTask: URLSessionDataTask? + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + deinit { + dataTask?.cancel() + } + + func loadImage(from initialUrl: URL, token: String) async { + isLoading = true + errorMessage = nil + image = nil + + do { + // First request with Authorization header + var request = URLRequest(url: initialUrl) + request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") + request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") + + let (data, response) = try await session.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 302, let redirectLocation = httpResponse.allHeaderFields["Location"] as? String, let redirectUrl = URL(string: redirectLocation) { + print("[watchOS] Redirecting to: \(redirectUrl)") + // Second request to the redirected URL (S3 signed URL) without Authorization header + let (redirectData, _) = try await session.data(from: redirectUrl) + if let uiImage = UIImage(data: redirectData) { + self.image = Image(uiImage: uiImage) + print("[watchOS] Image loaded successfully from redirect URL.") + } else { + // Try KingfisherWebP for WebP + let processor = WebPProcessor.default // Correct usage + if let kfImage = processor.process(item: .data(redirectData), options: KingfisherParsedOptionsInfo( + [ + .processor(processor), + .loadDiskFileSynchronously, + .cacheOriginalImage + ] + )) { + self.image = Image(uiImage: kfImage) + print("[watchOS] Image loaded successfully from redirect URL using KingfisherWebP.") + } else { + self.errorMessage = "Invalid image data from redirect (could not decode with KingfisherWebP)." + } + } + } else if httpResponse.statusCode == 200 { + if let uiImage = UIImage(data: data) { + self.image = Image(uiImage: uiImage) + print("[watchOS] Image loaded successfully from initial URL.") + } else { + // Try KingfisherWebP for WebP + let processor = WebPProcessor.default // Correct usage + if let kfImage = processor.process(item: .data(data), options: KingfisherParsedOptionsInfo( + [ + .processor(processor), + .loadDiskFileSynchronously, + .cacheOriginalImage + ] + )) { + self.image = Image(uiImage: kfImage) + print("[watchOS] Image loaded successfully from initial URL using KingfisherWebP.") + } else { + self.errorMessage = "Invalid image data (could not decode with KingfisherWebP)." + } + } + } else { + self.errorMessage = "HTTP Status Code: \(httpResponse.statusCode)" + } + } + } catch { + self.errorMessage = error.localizedDescription + print("[watchOS] Image loading failed: \(error.localizedDescription)") + } + isLoading = false + } + + func cancel() { + dataTask?.cancel() + } +} diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift new file mode 100644 index 00000000..ed9bc0b4 --- /dev/null +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -0,0 +1,69 @@ +// +// NetworkService.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import Foundation + +// MARK: - Network Service + +class NetworkService { + private let session = URLSession.shared + + func fetchActivities(filter: String, cursor: String? = nil, token: String, serverUrl: String) async throws -> [SnActivity] { + guard let baseURL = URL(string: serverUrl) else { + throw URLError(.badURL) + } + var components = URLComponents(url: baseURL.appendingPathComponent("/sphere/activities"), resolvingAgainstBaseURL: false)! + var queryItems = [URLQueryItem(name: "take", value: "20")] + if filter.lowercased() != "explore" { + queryItems.append(URLQueryItem(name: "filter", value: filter.lowercased())) + } + if let cursor = cursor { + queryItems.append(URLQueryItem(name: "cursor", value: cursor)) + } + components.queryItems = queryItems + + var request = URLRequest(url: components.url!) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") + request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") + + let (data, _) = try await session.data(for: request) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .convertFromSnakeCase + + return try decoder.decode([SnActivity].self, from: data) + } + + func createPost(title: String, content: String, token: String, serverUrl: String) async throws { + guard let baseURL = URL(string: serverUrl) else { + throw URLError(.badURL) + } + let url = baseURL.appendingPathComponent("/sphere/posts") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") + request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") + + let body: [String: Any] = ["title": title, "content": content] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: request) + + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 { + let responseBody = String(data: data, encoding: .utf8) ?? "" + print("[watchOS] createPost failed with status code: \(httpResponse.statusCode), body: \(responseBody)") + throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) + } + } +} diff --git a/ios/WatchRunner Watch App/State/AppState.swift b/ios/WatchRunner Watch App/State/AppState.swift new file mode 100644 index 00000000..3e75a0a6 --- /dev/null +++ b/ios/WatchRunner Watch App/State/AppState.swift @@ -0,0 +1,38 @@ +// +// AppState.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI +import Combine + +// MARK: - App State + +@MainActor +class AppState: ObservableObject { + @Published var token: String? = nil + @Published var serverUrl: String? = nil + @Published var isReady = false + + private var wcService = WatchConnectivityService() + private var cancellables = Set() + + init() { + wcService.$token.combineLatest(wcService.$serverUrl) + .receive(on: DispatchQueue.main) + .sink { [weak self] token, serverUrl in + self?.token = token + self?.serverUrl = serverUrl + if token != nil && serverUrl != nil { + self?.isReady = true + } + } + .store(in: &cancellables) + } + + func requestData() { + wcService.requestDataFromPhone() + } +} diff --git a/ios/WatchRunner Watch App/State/WatchConnectivityService.swift b/ios/WatchRunner Watch App/State/WatchConnectivityService.swift new file mode 100644 index 00000000..c246edd2 --- /dev/null +++ b/ios/WatchRunner Watch App/State/WatchConnectivityService.swift @@ -0,0 +1,72 @@ +// +// WatchConnectivityService.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import Foundation +import WatchConnectivity +import Combine + +// MARK: - Watch Connectivity + +class WatchConnectivityService: NSObject, WCSessionDelegate, ObservableObject { + @Published var token: String? + @Published var serverUrl: String? + + private let session: WCSession + + override init() { + self.session = .default + super.init() + print("[watchOS] Activating WCSession") + self.session.delegate = self + self.session.activate() + } + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + if let error = error { + print("[watchOS] WCSession activation failed with error: \(error.localizedDescription)") + return + } + print("[watchOS] WCSession activated with state: \(activationState.rawValue)") + if activationState == .activated { + requestDataFromPhone() + } + } + + func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { + print("[watchOS] Received message: \(message)") + DispatchQueue.main.async { + if let token = message["token"] as? String { + self.token = token + } + if let serverUrl = message["serverUrl"] as? String { + self.serverUrl = serverUrl + } + } + } + + func requestDataFromPhone() { + guard session.isReachable else { + print("[watchOS] Phone is not reachable") + return + } + + print("[watchOS] Requesting data from phone") + session.sendMessage(["request": "data"]) { [weak self] response in + print("[watchOS] Received reply: \(response)") + DispatchQueue.main.async { + if let token = response["token"] as? String { + self?.token = token + } + if let serverUrl = response["serverUrl"] as? String { + self?.serverUrl = serverUrl + } + } + } errorHandler: { error in + print("[watchOS] sendMessage failed with error: \(error.localizedDescription)") + } + } +} diff --git a/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift b/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift new file mode 100644 index 00000000..510340f2 --- /dev/null +++ b/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift @@ -0,0 +1,21 @@ +// +// AttachmentUtils.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import Foundation + +// MARK: - Helper Functions + +func getAttachmentUrl(for fileId: String, serverUrl: String) -> URL? { + let urlString: String + if fileId.starts(with: "http") { + urlString = fileId + } else { + urlString = "\(serverUrl)/drive/files/\(fileId)" + } + print("[watchOS] Generated image URL: \(urlString)") + return URL(string: urlString) +} diff --git a/ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift b/ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift new file mode 100644 index 00000000..c5a3fde2 --- /dev/null +++ b/ios/WatchRunner Watch App/ViewModels/ActivityViewModel.swift @@ -0,0 +1,47 @@ +// +// ActivityViewModel.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import Foundation +import Combine + +// MARK: - View Models + +@MainActor +class ActivityViewModel: ObservableObject { + @Published var activities: [SnActivity] = [] + @Published var isLoading = false + @Published var errorMessage: String? + + private let networkService = NetworkService() + let filter: String + private var isMock = false + + init(filter: String, mockActivities: [SnActivity]? = nil) { + self.filter = filter + if let mockActivities = mockActivities { + self.activities = mockActivities + self.isMock = true + } + } + + func fetchActivities(token: String, serverUrl: String) async { + if isMock { return } + guard !isLoading else { return } + isLoading = true + errorMessage = nil + + do { + let fetchedActivities = try await networkService.fetchActivities(filter: filter, token: token, serverUrl: serverUrl) + self.activities = fetchedActivities + } catch { + self.errorMessage = error.localizedDescription + print("[watchOS] fetchActivities failed with error: \(error)") + } + + isLoading = false + } +} diff --git a/ios/WatchRunner Watch App/ViewModels/ComposePostViewModel.swift b/ios/WatchRunner Watch App/ViewModels/ComposePostViewModel.swift new file mode 100644 index 00000000..7a41fe13 --- /dev/null +++ b/ios/WatchRunner Watch App/ViewModels/ComposePostViewModel.swift @@ -0,0 +1,35 @@ +// +// ComposePostViewModel.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import Foundation +import Combine + +@MainActor +class ComposePostViewModel: ObservableObject { + @Published var title = "" + @Published var content = "" + @Published var isPosting = false + @Published var errorMessage: String? + @Published var didPost = false + + private let networkService = NetworkService() + + func createPost(token: String, serverUrl: String) async { + guard !isPosting else { return } + isPosting = true + errorMessage = nil + + do { + try await networkService.createPost(title: title, content: content, token: token, serverUrl: serverUrl) + didPost = true + } catch { + errorMessage = error.localizedDescription + } + + isPosting = false + } +} diff --git a/ios/WatchRunner Watch App/Views/ActivityListView.swift b/ios/WatchRunner Watch App/Views/ActivityListView.swift new file mode 100644 index 00000000..8ac17ba3 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ActivityListView.swift @@ -0,0 +1,63 @@ +// +// ActivityListView.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI + +// MARK: - Views + +struct ActivityListView: View { + @StateObject private var viewModel: ActivityViewModel + @EnvironmentObject var appState: AppState + + init(filter: String, mockActivities: [SnActivity]? = nil) { + _viewModel = StateObject(wrappedValue: ActivityViewModel(filter: filter, mockActivities: mockActivities)) + } + + var body: some View { + Group { + if viewModel.isLoading { + ProgressView() + } else if let errorMessage = viewModel.errorMessage { + VStack { + Text("Error fetching data") + .font(.headline) + Text(errorMessage) + .font(.caption) + .lineLimit(nil) + } + .padding() + } else if viewModel.activities.isEmpty { + Text("No activities found.") + } else { + List(viewModel.activities) { activity in + switch activity.type { + case "posts.new", "posts.new.replies": + if case .post(let post) = activity.data { + NavigationLink(destination: PostDetailView(post: post)) { + PostRowView(post: post) + } + } + case "discovery": + if case .discovery(let discoveryData) = activity.data { + DiscoveryView(discoveryData: discoveryData) + } + default: + Text("Unknown activity type: \(activity.type)") + } + } + } + } + .task { + // Only fetch if appState is ready and token/serverUrl are available + if appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl { + await viewModel.fetchActivities(token: token, serverUrl: serverUrl) + } + } + .navigationTitle(viewModel.filter) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/ios/WatchRunner Watch App/Views/AttachmentImageView.swift b/ios/WatchRunner Watch App/Views/AttachmentImageView.swift new file mode 100644 index 00000000..a6b35ef5 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/AttachmentImageView.swift @@ -0,0 +1,38 @@ +// +// AttachmentImageView.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI + +struct AttachmentImageView: View { + let attachment: SnCloudFile + @EnvironmentObject var appState: AppState + @StateObject private var imageLoader = ImageLoader() + + var body: some View { + Group { + if imageLoader.isLoading { + ProgressView() + } else if let image = imageLoader.image { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity) + } else if let errorMessage = imageLoader.errorMessage { + Text("Failed to load attachment: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + } else { + Text("File: \(attachment.id)") + } + } + .task(id: attachment.id) { + if let serverUrl = appState.serverUrl, let imageUrl = getAttachmentUrl(for: attachment.id, serverUrl: serverUrl), let token = appState.token, attachment.mimeType?.starts(with: "image") == true { + await imageLoader.loadImage(from: imageUrl, token: token) + } + } + } +} diff --git a/ios/WatchRunner Watch App/Views/ComposePostView.swift b/ios/WatchRunner Watch App/Views/ComposePostView.swift new file mode 100644 index 00000000..7d1b0324 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ComposePostView.swift @@ -0,0 +1,52 @@ +// +// ComposePostView.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI + +struct ComposePostView: View { + @StateObject private var viewModel = ComposePostViewModel() + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + TextField("Title", text: $viewModel.title) + TextField("Content", text: $viewModel.content) + .frame(height: 100) + } + .navigationTitle("New Post") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Post") { + Task { + if let token = appState.token, let serverUrl = appState.serverUrl { + await viewModel.createPost(token: token, serverUrl: serverUrl) + } + } + } + .disabled(viewModel.isPosting) + } + } + .onChange(of: viewModel.didPost) { + if viewModel.didPost { + dismiss() + } + } + .alert("Error", isPresented: .constant(viewModel.errorMessage != nil), actions: { + Button("OK") { viewModel.errorMessage = nil } + }, message: { + Text(viewModel.errorMessage ?? "") + }) + } + } +} diff --git a/ios/WatchRunner Watch App/Views/DiscoveryViews.swift b/ios/WatchRunner Watch App/Views/DiscoveryViews.swift new file mode 100644 index 00000000..fb66e62f --- /dev/null +++ b/ios/WatchRunner Watch App/Views/DiscoveryViews.swift @@ -0,0 +1,110 @@ +// +// DiscoveryViews.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI + +struct DiscoveryView: View { + let discoveryData: DiscoveryData + + var body: some View { + NavigationLink(destination: DiscoveryDetailView(discoveryData: discoveryData)) { + VStack(alignment: .leading) { + Text("Discovery") + .font(.headline) + Text("\(discoveryData.items.count) new items to discover") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } +} + +struct DiscoveryDetailView: View { + let discoveryData: DiscoveryData + + var body: some View { + List(discoveryData.items) { item in + NavigationLink(destination: destinationView(for: item)) { + itemView(for: item) + } + } + .navigationTitle("Discovery") + } + + @ViewBuilder + private func itemView(for item: DiscoveryItem) -> some View { + VStack(alignment: .leading) { + switch item.data { + case .realm(let realm): + Text("Realm").font(.headline) + Text(realm.name).foregroundColor(.secondary) + case .publisher(let publisher): + Text("Publisher").font(.headline) + Text(publisher.name).foregroundColor(.secondary) + case .article(let article): + Text("Article").font(.headline) + Text(article.title).foregroundColor(.secondary) + case .unknown: + Text("Unknown item") + } + } + } + + @ViewBuilder + private func destinationView(for item: DiscoveryItem) -> some View { + switch item.data { + case .realm(let realm): + RealmDetailView(realm: realm) + case .publisher(let publisher): + PublisherDetailView(publisher: publisher) + case .article(let article): + ArticleDetailView(article: article) + case .unknown: + Text("Detail view not available") + } + } +} + +struct RealmDetailView: View { + let realm: SnRealm + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(realm.name).font(.headline) + if let description = realm.description { + Text(description).font(.body) + } + } + .navigationTitle("Realm") + } +} + +struct PublisherDetailView: View { + let publisher: SnPublisher + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(publisher.name).font(.headline) + if let description = publisher.description { + Text(description).font(.body) + } + } + .navigationTitle("Publisher") + } +} + +struct ArticleDetailView: View { + let article: SnWebArticle + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(article.title).font(.headline) + Text(article.url).font(.caption).foregroundColor(.secondary) + } + .navigationTitle("Article") + } +} diff --git a/ios/WatchRunner Watch App/Views/ExploreView.swift b/ios/WatchRunner Watch App/Views/ExploreView.swift new file mode 100644 index 00000000..d6b043de --- /dev/null +++ b/ios/WatchRunner Watch App/Views/ExploreView.swift @@ -0,0 +1,63 @@ +// +// ExploreView.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI + +// The main view with the TabView for filtering. +struct ExploreView: View { + @StateObject private var appState = AppState() + @State private var isComposing = false + + var body: some View { + NavigationStack { + Group { + if appState.isReady { + TabView { + NavigationStack { + ActivityListView(filter: "Explore") + } + .tabItem { + Label("Explore", systemImage: "safari") + } + + NavigationStack { + ActivityListView(filter: "Subscriptions") + } + .tabItem { + Label("Subscriptions", systemImage: "star") + } + + NavigationStack { + ActivityListView(filter: "Friends") + } + .tabItem { + Label("Friends", systemImage: "person.2") + } + } + .environmentObject(appState) + } else { + ProgressView { Text("Connecting to phone...") } + .onAppear { + appState.requestData() + } + } + } + .navigationTitle("Explore") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { isComposing = true }) { + Label("Compose", systemImage: "plus") + } + } + } + .sheet(isPresented: $isComposing) { + ComposePostView() + .environmentObject(appState) + } + } + } +} \ No newline at end of file diff --git a/ios/WatchRunner Watch App/Views/PostViews.swift b/ios/WatchRunner Watch App/Views/PostViews.swift new file mode 100644 index 00000000..8007eccc --- /dev/null +++ b/ios/WatchRunner Watch App/Views/PostViews.swift @@ -0,0 +1,142 @@ +// +// PostViews.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI + +struct PostRowView: View { + let post: SnPost + @EnvironmentObject var appState: AppState + @StateObject private var imageLoader = ImageLoader() // Instantiate ImageLoader + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { + if imageLoader.isLoading { + ProgressView() + .frame(width: 24, height: 24) + } else if let image = imageLoader.image { + image + .resizable() + .frame(width: 24, height: 24) + .clipShape(Circle()) + } else if let errorMessage = imageLoader.errorMessage { + Text("Failed: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + .frame(width: 24, height: 24) + } else { + // Placeholder if no image and not loading + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 24, height: 24) + .clipShape(Circle()) + .foregroundColor(.gray) + } + } + Text(post.publisher.nick ?? post.publisher.name) + .font(.subheadline) + .bold() + } + .task(id: post.publisher.picture?.id) { // Use task(id:) to reload image when pictureId changes + if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { + await imageLoader.loadImage(from: imageUrl, token: token) + } + } + + if let title = post.title, !title.isEmpty { + Text(title) + .font(.headline) + } + + if let content = post.content, !content.isEmpty { + Text(content) + .font(.body) + } + } + } +} + +struct PostDetailView: View { + let post: SnPost + @EnvironmentObject var appState: AppState + @StateObject private var publisherImageLoader = ImageLoader() // Instantiate ImageLoader for publisher avatar + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + HStack { + if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { + if publisherImageLoader.isLoading { + ProgressView() + .frame(width: 32, height: 32) + } else if let image = publisherImageLoader.image { + image + .resizable() + .frame(width: 32, height: 32) + .clipShape(Circle()) + } else if let errorMessage = publisherImageLoader.errorMessage { + Text("Failed: \(errorMessage)") + .font(.caption) + .foregroundColor(.red) + .frame(width: 32, height: 32) + } else { + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 32, height: 32) + .clipShape(Circle()) + .foregroundColor(.gray) + } + } + Text("@\(post.publisher.name)") + .font(.headline) + } + .task(id: post.publisher.picture?.id) { // Use task(id:) to reload image when pictureId changes + if let serverUrl = appState.serverUrl, let pictureId = post.publisher.picture?.id, let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl), let token = appState.token { + await publisherImageLoader.loadImage(from: imageUrl, token: token) + } + } + + if let title = post.title, !title.isEmpty { + Text(title) + .font(.title2) + .bold() + } + + if let content = post.content, !content.isEmpty { + Text(content) + .font(.body) + } + + if !post.attachments.isEmpty { + Divider() + Text("Attachments").font(.headline) + ForEach(post.attachments) { attachment in + AttachmentImageView(attachment: attachment) + } + } + + if !post.tags.isEmpty { + Divider() + Text("Tags").font(.headline) + FlowLayout(alignment: .leading, spacing: 4) { + ForEach(post.tags) { tag in + Text("#\(tag.name ?? tag.slug)") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Color.accentColor.opacity(0.2))) + .cornerRadius(5) + } + } + } + } + .padding() + } + .navigationTitle("Post") + } +}