e717de69ca
- 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>
163 lines
6.0 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|