✨ watchOS able to set status
This commit is contained in:
		| @@ -230,3 +230,19 @@ struct SnUserProfile: Codable { | |||||||
|     let experience: Int |     let experience: Int | ||||||
|     let levelingProgress: Double |     let levelingProgress: Double | ||||||
| } | } | ||||||
|  |  | ||||||
|  | struct SnAccountStatus: Codable { | ||||||
|  |     let id: String | ||||||
|  |     let attitude: Int | ||||||
|  |     let isOnline: Bool | ||||||
|  |     let isInvisible: Bool | ||||||
|  |     let isNotDisturb: Bool | ||||||
|  |     let isCustomized: Bool | ||||||
|  |     let label: String | ||||||
|  |     let meta: [String: AnyCodable]? | ||||||
|  |     let clearedAt: Date? | ||||||
|  |     let accountId: String | ||||||
|  |     let createdAt: Date | ||||||
|  |     let updatedAt: Date | ||||||
|  |     let deletedAt: Date? | ||||||
|  | } | ||||||
|   | |||||||
| @@ -121,4 +121,94 @@ class NetworkService { | |||||||
|  |  | ||||||
|         return try decoder.decode(SnAccount.self, from: data) |         return try decoder.decode(SnAccount.self, from: data) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     func fetchAccountStatus(token: String, serverUrl: String) async throws -> SnAccountStatus? { | ||||||
|  |         guard let baseURL = URL(string: serverUrl) else { | ||||||
|  |             throw URLError(.badURL) | ||||||
|  |         } | ||||||
|  |         let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses") | ||||||
|  |  | ||||||
|  |         var request = URLRequest(url: 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) | ||||||
|  |  | ||||||
|  |         if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 { | ||||||
|  |             return nil | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let decoder = JSONDecoder() | ||||||
|  |         decoder.dateDecodingStrategy = .iso8601 | ||||||
|  |         decoder.keyDecodingStrategy = .convertFromSnakeCase | ||||||
|  |  | ||||||
|  |         return try decoder.decode(SnAccountStatus.self, from: data) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func createOrUpdateStatus(attitude: Int, isInvisible: Bool, isNotDisturb: Bool, label: String?, token: String, serverUrl: String) async throws -> SnAccountStatus { | ||||||
|  |         // First check if status exists | ||||||
|  |         let existingStatus = try? await fetchAccountStatus(token: token, serverUrl: serverUrl) | ||||||
|  |         let method = existingStatus == nil ? "POST" : "PATCH" | ||||||
|  |  | ||||||
|  |         guard let baseURL = URL(string: serverUrl) else { | ||||||
|  |             throw URLError(.badURL) | ||||||
|  |         } | ||||||
|  |         let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses") | ||||||
|  |  | ||||||
|  |         var request = URLRequest(url: url) | ||||||
|  |         request.httpMethod = method | ||||||
|  |         request.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||||||
|  |         request.setValue("application/json", forHTTPHeaderField: "Accept") | ||||||
|  |         request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") | ||||||
|  |         request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent") | ||||||
|  |  | ||||||
|  |         var body: [String: Any] = [ | ||||||
|  |             "attitude": attitude, | ||||||
|  |             "is_invisible": isInvisible, | ||||||
|  |             "is_not_disturb": isNotDisturb | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |         if let label = label, !label.isEmpty { | ||||||
|  |             body["label"] = label | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         request.httpBody = try JSONSerialization.data(withJSONObject: body) | ||||||
|  |  | ||||||
|  |         let (data, response) = try await session.data(for: request) | ||||||
|  |  | ||||||
|  |         if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 201 && httpResponse.statusCode != 200 { | ||||||
|  |             let responseBody = String(data: data, encoding: .utf8) ?? "" | ||||||
|  |             print("[watchOS] createOrUpdateStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)") | ||||||
|  |             throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let decoder = JSONDecoder() | ||||||
|  |         decoder.dateDecodingStrategy = .iso8601 | ||||||
|  |         decoder.keyDecodingStrategy = .convertFromSnakeCase | ||||||
|  |  | ||||||
|  |         return try decoder.decode(SnAccountStatus.self, from: data) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     func clearStatus(token: String, serverUrl: String) async throws { | ||||||
|  |         guard let baseURL = URL(string: serverUrl) else { | ||||||
|  |             throw URLError(.badURL) | ||||||
|  |         } | ||||||
|  |         let url = baseURL.appendingPathComponent("/pass/accounts/me/statuses") | ||||||
|  |  | ||||||
|  |         var request = URLRequest(url: url) | ||||||
|  |         request.httpMethod = "DELETE" | ||||||
|  |         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) | ||||||
|  |  | ||||||
|  |         if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 204 { | ||||||
|  |             let responseBody = String(data: data, encoding: .utf8) ?? "" | ||||||
|  |             print("[watchOS] clearStatus failed with status code: \(httpResponse.statusCode), body: \(responseBody)") | ||||||
|  |             throw URLError(URLError.Code(rawValue: httpResponse.statusCode)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ import SwiftUI | |||||||
| struct AccountView: View { | struct AccountView: View { | ||||||
|     @EnvironmentObject var appState: AppState |     @EnvironmentObject var appState: AppState | ||||||
|     @State private var user: SnAccount? |     @State private var user: SnAccount? | ||||||
|  |     @State private var status: SnAccountStatus? | ||||||
|     @State private var isLoading = false |     @State private var isLoading = false | ||||||
|     @State private var error: Error? |     @State private var error: Error? | ||||||
|      |      | ||||||
| @@ -102,20 +103,64 @@ struct AccountView: View { | |||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                      |                      | ||||||
|                     // Bio |                     // Status | ||||||
|                     if let bio = user.profile.bio, !bio.isEmpty { |                     VStack(alignment: .leading, spacing: 8) { | ||||||
|                         Text(bio) |                         HStack { | ||||||
|                             .font(.body) |                             Text("Status") | ||||||
|                             .multilineTextAlignment(.center) |                                 .font(.subheadline) | ||||||
|                                 .foregroundColor(.secondary) |                                 .foregroundColor(.secondary) | ||||||
|  |                             Spacer() | ||||||
|  |                             NavigationLink( | ||||||
|  |                                 destination: StatusCreationView(initialStatus: status) | ||||||
|  |                                     .environmentObject(appState) | ||||||
|  |                             ) { | ||||||
|  |                                 ZStack { | ||||||
|  |                                     Circle() | ||||||
|  |                                         .fill(Color.blue.opacity(0.1)) | ||||||
|  |                                         .frame(width: 28, height: 28) | ||||||
|  |                                     Image(systemName: "pencil") | ||||||
|  |                                         .foregroundColor(.blue) | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                             .buttonStyle(.plain) | ||||||
|  |                             .frame(width: 28, height: 28) | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         if let status = status { | ||||||
|  |                             VStack(alignment: .leading, spacing: 4) { | ||||||
|  |                                 HStack { | ||||||
|  |                                     Circle() | ||||||
|  |                                         .fill(status.isOnline ? Color.green : Color.gray) | ||||||
|  |                                         .frame(width: 8, height: 8) | ||||||
|  |                                     Text(status.label.isEmpty ? "No status" : status.label) | ||||||
|  |                                         .font(.body) | ||||||
|  |                                 } | ||||||
|  |                                  | ||||||
|  |                                 if status.isInvisible { | ||||||
|  |                                     Text("Invisible") | ||||||
|  |                                         .font(.caption) | ||||||
|  |                                         .foregroundColor(.secondary) | ||||||
|  |                                 } | ||||||
|  |                                 if status.isNotDisturb { | ||||||
|  |                                     Text("Do Not Disturb") | ||||||
|  |                                         .font(.caption) | ||||||
|  |                                         .foregroundColor(.secondary) | ||||||
|  |                                 } | ||||||
|  |                                 if let clearedAt = status.clearedAt { | ||||||
|  |                                     Text("Clears: \(clearedAt.formatted(date: .abbreviated, time: .shortened))") | ||||||
|  |                                         .font(.caption) | ||||||
|  |                                         .foregroundColor(.secondary) | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|                         } else { |                         } else { | ||||||
|                         Text("No bio available") |                             Text("No status set") | ||||||
|                                 .font(.body) |                                 .font(.body) | ||||||
|                                 .foregroundColor(.secondary) |                                 .foregroundColor(.secondary) | ||||||
|                         } |                         } | ||||||
|  |                     } | ||||||
|                      |                      | ||||||
|                     // Level and Progress |                     // Level and Progress | ||||||
|                     VStack(spacing: 8) { |                     VStack(alignment: .leading, spacing: 8) { | ||||||
|                         Text("Level \(user.profile.level)") |                         Text("Level \(user.profile.level)") | ||||||
|                             .font(.title3) |                             .font(.title3) | ||||||
|                             .bold() |                             .bold() | ||||||
| @@ -127,10 +172,25 @@ struct AccountView: View { | |||||||
|                             .foregroundColor(.secondary) |                             .foregroundColor(.secondary) | ||||||
|                     } |                     } | ||||||
|                      |                      | ||||||
|  |                     // Bio | ||||||
|  |                     if let bio = user.profile.bio, !bio.isEmpty { | ||||||
|  |                         Text(bio) | ||||||
|  |                             .font(.body) | ||||||
|  |                             .multilineTextAlignment(.center) | ||||||
|  |                             .foregroundColor(.secondary) | ||||||
|  |                             .frame(alignment: .leading) | ||||||
|  |                     } else { | ||||||
|  |                         Text("No bio available") | ||||||
|  |                             .font(.body) | ||||||
|  |                             .foregroundColor(.secondary) | ||||||
|  |                             .frame(alignment: .leading) | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|                     // Member since |                     // Member since | ||||||
|                     Text("Member since: \(user.createdAt.formatted(.dateTime.month(.abbreviated).year()))") |                     Text("Joined at \(user.createdAt.formatted(.dateTime.month(.abbreviated).year()))") | ||||||
|                         .font(.caption) |                         .font(.caption) | ||||||
|                         .foregroundColor(.secondary) |                         .foregroundColor(.secondary) | ||||||
|  |                         .frame(alignment: .leading) | ||||||
|                 } |                 } | ||||||
|                 .padding() |                 .padding() | ||||||
|                 // Load images when user data is available |                 // Load images when user data is available | ||||||
| @@ -168,6 +228,7 @@ struct AccountView: View { | |||||||
|          |          | ||||||
|         do { |         do { | ||||||
|             user = try await networkService.fetchUserProfile(token: token, serverUrl: serverUrl) |             user = try await networkService.fetchUserProfile(token: token, serverUrl: serverUrl) | ||||||
|  |             status = try await networkService.fetchAccountStatus(token: token, serverUrl: serverUrl) | ||||||
|         } catch { |         } catch { | ||||||
|             self.error = error |             self.error = error | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -145,7 +145,6 @@ struct PostDetailView: View { | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|             .padding() |             .padding() | ||||||
|             .frame(width: .infinity) |  | ||||||
|         } |         } | ||||||
|         .navigationTitle("Post") |         .navigationTitle("Post") | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										132
									
								
								ios/WatchRunner Watch App/Views/StatusCreationView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								ios/WatchRunner Watch App/Views/StatusCreationView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,132 @@ | |||||||
|  | // | ||||||
|  | //  StatusCreationView.swift | ||||||
|  | //  WatchRunner Watch App | ||||||
|  | // | ||||||
|  | //  Created by LittleSheep on 2025/10/30. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | struct StatusCreationView: View { | ||||||
|  |     @EnvironmentObject var appState: AppState | ||||||
|  |     @Environment(\.dismiss) var dismiss | ||||||
|  |      | ||||||
|  |     let initialStatus: SnAccountStatus? | ||||||
|  |      | ||||||
|  |     @State private var attitude: Int | ||||||
|  |     @State private var isInvisible: Bool | ||||||
|  |     @State private var isNotDisturb: Bool | ||||||
|  |     @State private var label: String | ||||||
|  |     @State private var isSubmitting: Bool = false | ||||||
|  |     @State private var error: Error? = nil | ||||||
|  |      | ||||||
|  |     private let networkService = NetworkService() | ||||||
|  |      | ||||||
|  |     init(initialStatus: SnAccountStatus? = nil) { | ||||||
|  |         self.initialStatus = initialStatus | ||||||
|  |         _attitude = State(initialValue: initialStatus?.attitude ?? 1) | ||||||
|  |         _isInvisible = State(initialValue: initialStatus?.isInvisible ?? false) | ||||||
|  |         _isNotDisturb = State(initialValue: initialStatus?.isNotDisturb ?? false) | ||||||
|  |         _label = State(initialValue: initialStatus?.label ?? "") | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         ScrollView { | ||||||
|  |             VStack(spacing: 16) { | ||||||
|  |                 // Title | ||||||
|  |                 Text("Set Status") | ||||||
|  |                     .font(.headline) | ||||||
|  |                     .padding(.top) | ||||||
|  |                  | ||||||
|  |                 // Label TextField | ||||||
|  |                 TextField("Status label", text: $label) | ||||||
|  |                     .textFieldStyle(.automatic) | ||||||
|  |                     .padding(.horizontal) | ||||||
|  |                  | ||||||
|  |                 // Attitude Picker | ||||||
|  |                 VStack(alignment: .leading, spacing: 8) { | ||||||
|  |                     Text("Mood") | ||||||
|  |                         .font(.subheadline) | ||||||
|  |                         .foregroundColor(.secondary) | ||||||
|  |                      | ||||||
|  |                     Picker("Attitude", selection: $attitude) { | ||||||
|  |                         Text("😊 Positive").tag(0) | ||||||
|  |                         Text("😐 Neutral").tag(1) | ||||||
|  |                         Text("😢 Negative").tag(2) | ||||||
|  |                     } | ||||||
|  |                     .pickerStyle(.wheel) | ||||||
|  |                     .frame(height: 80) | ||||||
|  |                 } | ||||||
|  |                 .padding(.horizontal) | ||||||
|  |                  | ||||||
|  |                 // Toggles | ||||||
|  |                 VStack(spacing: 12) { | ||||||
|  |                     Toggle("Invisible", isOn: $isInvisible) | ||||||
|  |                         .padding(.horizontal) | ||||||
|  |                      | ||||||
|  |                     Toggle("Do Not Disturb", isOn: $isNotDisturb) | ||||||
|  |                         .padding(.horizontal) | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 // Error message | ||||||
|  |                 if let error = error { | ||||||
|  |                     Text("Error: \(error.localizedDescription)") | ||||||
|  |                         .foregroundColor(.red) | ||||||
|  |                         .font(.caption) | ||||||
|  |                         .padding(.horizontal) | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 // Buttons | ||||||
|  |                 HStack(spacing: 12) { | ||||||
|  |                     Button("Cancel") { | ||||||
|  |                         dismiss() | ||||||
|  |                     } | ||||||
|  |                     .buttonStyle(.glass) | ||||||
|  |                      | ||||||
|  |                     Button(isSubmitting ? "Saving..." : "Save") { | ||||||
|  |                         Task { | ||||||
|  |                             await submitStatus() | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     .buttonStyle(.glassProminent) | ||||||
|  |                     .disabled(isSubmitting) | ||||||
|  |                 } | ||||||
|  |                 .padding(.horizontal) | ||||||
|  |                 .padding(.bottom) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .navigationTitle("Status") | ||||||
|  |         .navigationBarTitleDisplayMode(.inline) | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     private func submitStatus() async { | ||||||
|  |         guard let token = appState.token, let serverUrl = appState.serverUrl else { | ||||||
|  |             error = NSError(domain: "StatusCreationView", code: 1, userInfo: [NSLocalizedDescriptionKey: "Authentication not available"]) | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         isSubmitting = true | ||||||
|  |         error = nil | ||||||
|  |          | ||||||
|  |         do { | ||||||
|  |             _ = try await networkService.createOrUpdateStatus( | ||||||
|  |                 attitude: attitude, | ||||||
|  |                 isInvisible: isInvisible, | ||||||
|  |                 isNotDisturb: isNotDisturb, | ||||||
|  |                 label: label.isEmpty ? nil : label, | ||||||
|  |                 token: token, | ||||||
|  |                 serverUrl: serverUrl | ||||||
|  |             ) | ||||||
|  |             dismiss() | ||||||
|  |         } catch { | ||||||
|  |             self.error = error | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         isSubmitting = false | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     StatusCreationView() | ||||||
|  |         .environmentObject(AppState()) | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user