diff --git a/ios/WatchRunner Watch App/Models/Models.swift b/ios/WatchRunner Watch App/Models/Models.swift index 2ca62509..f30d00bc 100644 --- a/ios/WatchRunner Watch App/Models/Models.swift +++ b/ios/WatchRunner Watch App/Models/Models.swift @@ -230,3 +230,19 @@ struct SnUserProfile: Codable { let experience: Int 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? +} diff --git a/ios/WatchRunner Watch App/Services/NetworkService.swift b/ios/WatchRunner Watch App/Services/NetworkService.swift index 7a87ea01..45de32bd 100644 --- a/ios/WatchRunner Watch App/Services/NetworkService.swift +++ b/ios/WatchRunner Watch App/Services/NetworkService.swift @@ -121,4 +121,94 @@ class NetworkService { 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)) + } + } } diff --git a/ios/WatchRunner Watch App/Views/AccountView.swift b/ios/WatchRunner Watch App/Views/AccountView.swift index 030159fc..1fec3fd3 100644 --- a/ios/WatchRunner Watch App/Views/AccountView.swift +++ b/ios/WatchRunner Watch App/Views/AccountView.swift @@ -10,6 +10,7 @@ import SwiftUI struct AccountView: View { @EnvironmentObject var appState: AppState @State private var user: SnAccount? + @State private var status: SnAccountStatus? @State private var isLoading = false @State private var error: Error? @@ -102,20 +103,64 @@ struct AccountView: View { } } - // Bio - if let bio = user.profile.bio, !bio.isEmpty { - Text(bio) - .font(.body) - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - } else { - Text("No bio available") - .font(.body) - .foregroundColor(.secondary) + // Status + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Status") + .font(.subheadline) + .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 { + Text("No status set") + .font(.body) + .foregroundColor(.secondary) + } } // Level and Progress - VStack(spacing: 8) { + VStack(alignment: .leading, spacing: 8) { Text("Level \(user.profile.level)") .font(.title3) .bold() @@ -127,10 +172,25 @@ struct AccountView: View { .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 - Text("Member since: \(user.createdAt.formatted(.dateTime.month(.abbreviated).year()))") + Text("Joined at \(user.createdAt.formatted(.dateTime.month(.abbreviated).year()))") .font(.caption) .foregroundColor(.secondary) + .frame(alignment: .leading) } .padding() // Load images when user data is available @@ -168,6 +228,7 @@ struct AccountView: View { do { user = try await networkService.fetchUserProfile(token: token, serverUrl: serverUrl) + status = try await networkService.fetchAccountStatus(token: token, serverUrl: serverUrl) } catch { self.error = error } diff --git a/ios/WatchRunner Watch App/Views/PostViews.swift b/ios/WatchRunner Watch App/Views/PostViews.swift index 726b40e3..248677f5 100644 --- a/ios/WatchRunner Watch App/Views/PostViews.swift +++ b/ios/WatchRunner Watch App/Views/PostViews.swift @@ -145,7 +145,6 @@ struct PostDetailView: View { } } .padding() - .frame(width: .infinity) } .navigationTitle("Post") } diff --git a/ios/WatchRunner Watch App/Views/StatusCreationView.swift b/ios/WatchRunner Watch App/Views/StatusCreationView.swift new file mode 100644 index 00000000..38db1397 --- /dev/null +++ b/ios/WatchRunner Watch App/Views/StatusCreationView.swift @@ -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()) +}