✨ 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