✨ watchOS notification screen
This commit is contained in:
		| @@ -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 | ||||
|     } | ||||
| } | ||||
|  | ||||
|     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") | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|                 } | ||||
|             } | ||||
|   | ||||
							
								
								
									
										198
									
								
								ios/WatchRunner Watch App/Views/NotificationView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										198
									
								
								ios/WatchRunner Watch App/Views/NotificationView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user