✨ 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 {
|
||||||
|
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()
|
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