✨ 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)
|
||||||
} else {
|
Spacer()
|
||||||
Text("No bio available")
|
NavigationLink(
|
||||||
.font(.body)
|
destination: StatusCreationView(initialStatus: status)
|
||||||
.foregroundColor(.secondary)
|
.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
|
// 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