✨ watchOS notification screen
This commit is contained in:
		| @@ -9,7 +9,32 @@ import SwiftUI | |||||||
|  |  | ||||||
| // The root view of the app. | // The root view of the app. | ||||||
| struct ContentView: View { | struct ContentView: View { | ||||||
|  |     @StateObject private var appState = AppState() | ||||||
|  |     @State private var selection: Panel? = .explore | ||||||
|  |  | ||||||
|  |     enum Panel: Hashable { | ||||||
|  |         case explore | ||||||
|  |         case notifications | ||||||
|  |     } | ||||||
|  |  | ||||||
|     var body: some View { |     var body: some View { | ||||||
|         ExploreView() |         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 title: String | ||||||
|     let url: 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)) |             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 { |     } else { | ||||||
|         urlString = "\(serverUrl)/drive/files/\(fileId)" |         urlString = "\(serverUrl)/drive/files/\(fileId)" | ||||||
|     } |     } | ||||||
|     print("[watchOS] Generated image URL: \(urlString)") |  | ||||||
|     return URL(string: urlString) |     return URL(string: urlString) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -17,23 +17,24 @@ struct ComposePostView: View { | |||||||
|             Form { |             Form { | ||||||
|                 TextField("Title", text: $viewModel.title) |                 TextField("Title", text: $viewModel.title) | ||||||
|                 TextField("Content", text: $viewModel.content) |                 TextField("Content", text: $viewModel.content) | ||||||
|                     .frame(height: 100) |  | ||||||
|             } |             } | ||||||
|             .navigationTitle("New Post") |             .navigationTitle("New Post") | ||||||
|             .toolbar { |             .toolbar { | ||||||
|                 ToolbarItem(placement: .cancellationAction) { |                 ToolbarItem(placement: .cancellationAction) { | ||||||
|                     Button("Cancel") { |                     Button("Cancel", systemImage: "xmark") { | ||||||
|                         dismiss() |                         dismiss() | ||||||
|                     } |                     } | ||||||
|  |                     .labelStyle(.iconOnly) | ||||||
|                 } |                 } | ||||||
|                 ToolbarItem(placement: .confirmationAction) { |                 ToolbarItem(placement: .confirmationAction) { | ||||||
|                     Button("Post") { |                     Button("Post", systemImage: "square.and.arrow.up") { | ||||||
|                         Task { |                         Task { | ||||||
|                             if let token = appState.token, let serverUrl = appState.serverUrl { |                             if let token = appState.token, let serverUrl = appState.serverUrl { | ||||||
|                                 await viewModel.createPost(token: token, serverUrl: serverUrl) |                                 await viewModel.createPost(token: token, serverUrl: serverUrl) | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|  |                     .labelStyle(.iconOnly) | ||||||
|                     .disabled(viewModel.isPosting) |                     .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