diff --git a/ios/WatchRunner Watch App/ContentView.swift b/ios/WatchRunner Watch App/ContentView.swift index 47e444c9..00c99583 100644 --- a/ios/WatchRunner Watch App/ContentView.swift +++ b/ios/WatchRunner Watch App/ContentView.swift @@ -9,7 +9,32 @@ import SwiftUI // The root view of the app. struct ContentView: View { - var body: some View { - ExploreView() + @StateObject private var appState = AppState() + @State private var selection: Panel? = .explore + + enum Panel: Hashable { + case explore + case notifications } -} \ No newline at end of file + + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Label("Explore", systemImage: "globe").tag(Panel.explore) + Label("Notifications", systemImage: "bell").tag(Panel.notifications) + } + .listStyle(.automatic) + } detail: { + switch selection { + case .explore: + ExploreView() + .environmentObject(appState) + case .notifications: + NotificationView() + .environmentObject(appState) + case .none: + Text("Select a panel") + } + } + } +} diff --git a/ios/WatchRunner Watch App/Models/Models.swift b/ios/WatchRunner Watch App/Models/Models.swift index ff6d83bd..c091eaef 100644 --- a/ios/WatchRunner Watch App/Models/Models.swift +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -124,3 +124,86 @@ struct SnWebArticle: Codable, Identifiable { let title: String let url: String } + +struct SnNotification: Codable, Identifiable { + let id: String + let topic: String + let title: String + let subtitle: String + let content: String + let meta: [String: AnyCodable]? + let priority: Int + let viewedAt: Date? + let accountId: String + let createdAt: Date + let updatedAt: Date + let deletedAt: Date? + + enum CodingKeys: String, CodingKey { + case id + case topic + case title + case subtitle + case content + case meta + case priority + case viewedAt = "viewedAt" + case accountId = "accountId" + case createdAt = "createdAt" + case updatedAt = "updatedAt" + case deletedAt = "deletedAt" + } +} + +struct AnyCodable: Codable { + let value: Any + + init(_ value: Any) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intValue = try? container.decode(Int.self) { + value = intValue + } else if let doubleValue = try? container.decode(Double.self) { + value = doubleValue + } else if let boolValue = try? container.decode(Bool.self) { + value = boolValue + } else if let stringValue = try? container.decode(String.self) { + value = stringValue + } else if let arrayValue = try? container.decode([AnyCodable].self) { + value = arrayValue + } else if let dictValue = try? container.decode([String: AnyCodable].self) { + value = dictValue + } else { + value = NSNull() + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case let intValue as Int: + try container.encode(intValue) + case let doubleValue as Double: + try container.encode(doubleValue) + case let boolValue as Bool: + try container.encode(boolValue) + case let stringValue as String: + try container.encode(stringValue) + case let arrayValue as [AnyCodable]: + try container.encode(arrayValue) + case let dictValue as [String: AnyCodable]: + try container.encode(dictValue) + default: + try container.encodeNil() + } + } +} + +struct NotificationResponse { + let notifications: [SnNotification] + let total: Int + let hasMore: Bool +} diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index ed9bc0b4..286959d4 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -66,4 +66,33 @@ class NetworkService { throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) } } + + func fetchNotifications(offset: Int = 0, take: Int = 20, token: String, serverUrl: String) async throws -> NotificationResponse { + guard let baseURL = URL(string: serverUrl) else { + throw URLError(.badURL) + } + var components = URLComponents(url: baseURL.appendingPathComponent("/ring/notifications"), resolvingAgainstBaseURL: false)! + var queryItems = [URLQueryItem(name: "offset", value: String(offset)), URLQueryItem(name: "take", value: String(take))] + components.queryItems = queryItems + + var request = URLRequest(url: components.url!) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") + request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") + + let (data, response) = try await session.data(for: request) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let notifications = try decoder.decode([SnNotification].self, from: data) + + let httpResponse = response as? HTTPURLResponse + let total = Int(httpResponse?.value(forHTTPHeaderField: "X-Total") ?? "0") ?? 0 + let hasMore = offset + notifications.count < total + + return NotificationResponse(notifications: notifications, total: total, hasMore: hasMore) + } } diff --git a/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift b/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift index 510340f2..cbe894ca 100644 --- a/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift +++ b/ios/WatchRunner Watch App/Utils/AttachmentUtils.swift @@ -16,6 +16,5 @@ func getAttachmentUrl(for fileId: String, serverUrl: String) -> URL? { } else { urlString = "\(serverUrl)/drive/files/\(fileId)" } - print("[watchOS] Generated image URL: \(urlString)") return URL(string: urlString) } diff --git a/ios/WatchRunner Watch App/Views/ComposePostView.swift b/ios/WatchRunner Watch App/Views/ComposePostView.swift index 7d1b0324..a78c679a 100644 --- a/ios/WatchRunner Watch App/Views/ComposePostView.swift +++ b/ios/WatchRunner Watch App/Views/ComposePostView.swift @@ -17,23 +17,24 @@ struct ComposePostView: View { Form { TextField("Title", text: $viewModel.title) TextField("Content", text: $viewModel.content) - .frame(height: 100) } .navigationTitle("New Post") .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { + Button("Cancel", systemImage: "xmark") { dismiss() } + .labelStyle(.iconOnly) } ToolbarItem(placement: .confirmationAction) { - Button("Post") { + Button("Post", systemImage: "square.and.arrow.up") { Task { if let token = appState.token, let serverUrl = appState.serverUrl { await viewModel.createPost(token: token, serverUrl: serverUrl) } } } + .labelStyle(.iconOnly) .disabled(viewModel.isPosting) } } diff --git a/ios/WatchRunner Watch App/Views/NotificationView.swift b/ios/WatchRunner Watch App/Views/NotificationView.swift new file mode 100644 index 00000000..53444436 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/NotificationView.swift @@ -0,0 +1,198 @@ + +// +// NotificationView.swift +// WatchRunner Watch App +// +// Created by LittleSheep on 2025/10/29. +// + +import SwiftUI +import Combine + +@MainActor +class NotificationViewModel: ObservableObject { + @Published var notifications = [SnNotification]() + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var errorMessage: String? + @Published var hasMore = false + + private let networkService = NetworkService() + private var hasFetched = false + private var offset = 0 + private let pageSize = 20 + + func fetchNotifications(token: String, serverUrl: String) async { + if hasFetched { return } + guard !isLoading else { return } + isLoading = true + errorMessage = nil + hasFetched = true + offset = 0 + + do { + let response = try await networkService.fetchNotifications(offset: offset, take: pageSize, token: token, serverUrl: serverUrl) + self.notifications = response.notifications + self.hasMore = response.hasMore + offset += response.notifications.count + } catch { + self.errorMessage = error.localizedDescription + print("[watchOS] fetchNotifications failed with error: \(error)") + hasFetched = false + } + + isLoading = false + } + + func loadMoreNotifications(token: String, serverUrl: String) async { + guard !isLoadingMore && hasMore else { return } + isLoadingMore = true + + do { + let response = try await networkService.fetchNotifications(offset: offset, take: pageSize, token: token, serverUrl: serverUrl) + self.notifications.append(contentsOf: response.notifications) + self.hasMore = response.hasMore + offset += response.notifications.count + } catch { + self.errorMessage = error.localizedDescription + print("[watchOS] loadMoreNotifications failed with error: \(error)") + } + + isLoadingMore = false + } +} + +struct NotificationView: View { + @EnvironmentObject var appState: AppState + @StateObject private var viewModel = NotificationViewModel() + + var body: some View { + Group { + if viewModel.isLoading { + ProgressView() + } else if let errorMessage = viewModel.errorMessage { + VStack { + Text("Error") + .font(.headline) + Text(errorMessage) + .font(.caption) + Button("Retry") { + Task { + if let token = appState.token, let serverUrl = appState.serverUrl { + await viewModel.fetchNotifications(token: token, serverUrl: serverUrl) + } + } + } + } + .padding() + } else if viewModel.notifications.isEmpty { + Text("No notifications") + } else { + List { + ForEach(viewModel.notifications) { notification in + NavigationLink(destination: NotificationDetailView(notification: notification)) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(notification.title) + .font(.headline) + Spacer() + if notification.viewedAt == nil { + Circle() + .fill(Color.blue) + .frame(width: 8, height: 8) + } + } + if !notification.subtitle.isEmpty { + Text(notification.subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + if notification.content.count > 100 { + Text(notification.content.prefix(100) + "...") + .font(.caption) + .foregroundColor(.gray) + .lineLimit(2) + } else { + Text(notification.content) + .font(.caption) + .foregroundColor(.gray) + .lineLimit(2) + } + Text(notification.createdAt, style: .relative) + .font(.caption2) + .foregroundColor(.gray) + } + .padding(.vertical, 8) + } + } + if viewModel.hasMore { + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else { + Button("Load More") { + Task { + if let token = appState.token, let serverUrl = appState.serverUrl { + await viewModel.loadMoreNotifications(token: token, serverUrl: serverUrl) + } + } + } + .frame(maxWidth: .infinity) + } + } + } + } + } + .onAppear { + if appState.isReady, let token = appState.token, let serverUrl = appState.serverUrl { + Task.detached { + await viewModel.fetchNotifications(token: token, serverUrl: serverUrl) + } + } + } + .navigationTitle("Notifications") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct NotificationDetailView: View { + let notification: SnNotification + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(notification.title) + .font(.headline) + + if !notification.subtitle.isEmpty { + Text(notification.subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Text(notification.content) + .font(.body) + + HStack { + Text(notification.createdAt, style: .date) + Text("ยท") + Text(notification.createdAt, style: .time) + } + .font(.caption) + .foregroundColor(.gray) + + if notification.viewedAt == nil { + Text("Unread") + .font(.caption) + .foregroundColor(.blue) + } + } + .padding() + } + .navigationTitle("Notification") + .navigationBarTitleDisplayMode(.inline) + } +}