Files
hyungi_document_server/clients/ds-watch/Sources/WatchModel.swift
T
hyungi e717de69ca feat(ds-watch): Apple Watch 앱 신규 — 4기능 셸 + 공부/할일/브리핑/이드 라이브 결선 + DS 아이콘
- standalone watchOS(WKApplication + WKWatchOnly), 다크 OLED, xcodegen 단일 타깃
- 4기능 = 이드(AI채팅)·공부(암기카드)·할일·브리핑
- 라이브: 공부 /study-cards(due·rate·flag) · 할일 /events(today·complete)
  · 브리핑 /briefing/latest · 이드 /eid/chat(SSE 누적, unavailable 처리)
- 1회 로그인(access 메모리 + refresh 쿠키 7일 영속) + 401 자동 refresh+재시도
- 햅틱 피드백 + 정직한 로딩/빈/오류 상태 + DS 초록 아이콘(원형 마스킹)
- 맥·아이폰은 웹 래퍼로(2026-06-15 결정), 순수 네이티브는 워치 전용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:55:52 +09:00

163 lines
6.0 KiB
Swift

import SwiftUI
import Observation
/// . refresh . .
@MainActor
@Observable
final class WatchModel {
enum Phase: Equatable { case checking, loggedOut, ready }
var phase: Phase = .checking
var loginError: String?
// (study)
var cards: [WCard] = []
var studyLoading = false
var studyError: String?
// (events)
var events: [WEvent] = []
var eventsLoading = false
var eventsError: String?
//
var briefing: WBriefing?
var briefingLoading = false
var briefingError: String?
// (chat)
struct ChatTurn: Identifiable, Sendable { let id: Int; let role: String; let text: String }
var chatTurns: [ChatTurn] = []
var chatSending = false
private var chatSeq = 0
private let client = WatchClient()
func bootstrap() async {
do { _ = try await client.refresh(); phase = .ready }
catch { phase = .loggedOut } // / =
}
func login(username: String, password: String, totp: String?) async {
loginError = nil
let code = totp?.trimmingCharacters(in: .whitespacesAndNewlines)
do {
try await client.login(username: username, password: password,
totp: (code?.isEmpty ?? true) ? nil : code)
phase = .ready
} catch {
loginError = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
}
func logout() async {
await client.logout()
cards = []; studyError = nil
phase = .loggedOut
}
func loadDue() async {
studyLoading = true; studyError = nil
do { cards = try await client.dueCards() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
studyLoading = false
}
/// (correct/wrong). ( ) .
func rate(cardId: Int, outcome: String) async {
do { try await client.rate(cardId: cardId, outcome: outcome) }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
}
func flag(cardId: Int) async {
do { try await client.flag(cardId: cardId) }
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
}
// MARK: (events)
func loadEvents() async {
eventsLoading = true; eventsError = nil
do { events = try await client.events() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { eventsError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
eventsLoading = false
}
func completeEvent(_ id: Int) async {
do { try await client.completeEvent(id: id); await loadEvents() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { eventsError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
}
// MARK:
func loadBriefing() async {
briefingLoading = true; briefingError = nil
do { briefing = try await client.briefing() }
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
catch { briefingError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
briefingLoading = false
}
// MARK: (chat)
func sendChat(_ text: String) async {
let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !t.isEmpty, !chatSending else { return }
chatSeq += 1; chatTurns.append(.init(id: chatSeq, role: "user", text: t))
chatSending = true
do {
let result = try await client.chat(t)
chatSeq += 1
if result.unavailable {
chatTurns.append(.init(id: chatSeq, role: "error", text: result.reason ?? "이드 연결 불가"))
} else {
chatTurns.append(.init(id: chatSeq, role: "assistant", text: result.answer))
}
} catch let e as WCError where e.isUnauthorized {
phase = .loggedOut
} catch {
chatSeq += 1
chatTurns.append(.init(id: chatSeq, role: "error",
text: (error as? LocalizedError)?.errorDescription ?? "\(error)"))
}
chatSending = false
}
}
/// 1 (refresh 7 1). TOTP 6 .
struct LoginView: View {
@Environment(WatchModel.self) private var model
@State private var username = ""
@State private var password = ""
@State private var totp = ""
@State private var busy = false
var body: some View {
ScrollView {
VStack(spacing: 8) {
Text("DS 로그인").font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink)
TextField("아이디", text: $username)
.textContentType(.username)
SecureField("비밀번호", text: $password)
TextField("OTP 6자리", text: $totp)
if let err = model.loginError {
Text(err).font(.system(size: 11)).foregroundStyle(WT.danger).multilineTextAlignment(.center)
}
Button {
busy = true
Task { await model.login(username: username, password: password, totp: totp); busy = false }
} label: {
if busy { ProgressView() } else { Text("로그인").frame(maxWidth: .infinity) }
}
.tint(WT.accent)
.disabled(busy || username.isEmpty || password.isEmpty)
}
.padding(.horizontal, 4)
}
}
}