diff --git a/clients/ds-watch/.gitignore b/clients/ds-watch/.gitignore new file mode 100644 index 0000000..8817480 --- /dev/null +++ b/clients/ds-watch/.gitignore @@ -0,0 +1,5 @@ +# xcodegen 생성물 (project.yml 이 source of truth) +DSWatch.xcodeproj/ +Support/ +.build/ +*.xcuserstate diff --git a/clients/ds-watch/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/clients/ds-watch/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..0c11169 --- /dev/null +++ b/clients/ds-watch/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024", "filename" : "watch_1024.png" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/clients/ds-watch/Sources/Assets.xcassets/AppIcon.appiconset/watch_1024.png b/clients/ds-watch/Sources/Assets.xcassets/AppIcon.appiconset/watch_1024.png new file mode 100644 index 0000000..9a824bc Binary files /dev/null and b/clients/ds-watch/Sources/Assets.xcassets/AppIcon.appiconset/watch_1024.png differ diff --git a/clients/ds-watch/Sources/Assets.xcassets/Contents.json b/clients/ds-watch/Sources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..d8b757a --- /dev/null +++ b/clients/ds-watch/Sources/Assets.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/clients/ds-watch/Sources/DSWatchApp.swift b/clients/ds-watch/Sources/DSWatchApp.swift new file mode 100644 index 0000000..95e9d8e --- /dev/null +++ b/clients/ds-watch/Sources/DSWatchApp.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// DS 애플워치 앱 (standalone). 4기능 = 이드(AI채팅)·공부(암기카드)·할 일·브리핑. +/// 공부 = 라이브 결선(/study-cards/due·rate) / 나머지 = 스캐폴드. 다크 OLED. +@main +struct DSWatchApp: App { + @State private var model = WatchModel() + + var body: some Scene { + WindowGroup { + RootGate() + .environment(model) + .task { await model.bootstrap() } + } + } +} + +/// 인증 게이트: checking(쿠키 복귀) → loggedOut(로그인) → ready(메뉴). +struct RootGate: View { + @Environment(WatchModel.self) private var model + var body: some View { + switch model.phase { + case .checking: ProgressView() + case .loggedOut: LoginView() + case .ready: RootMenu() + } + } +} diff --git a/clients/ds-watch/Sources/Haptics.swift b/clients/ds-watch/Sources/Haptics.swift new file mode 100644 index 0000000..67a6706 --- /dev/null +++ b/clients/ds-watch/Sources/Haptics.swift @@ -0,0 +1,9 @@ +import WatchKit + +/// 워치 햅틱 — 평정/완료의 손목 탭 피드백(워치 고유 감각). +@MainActor +enum Haptics { + static func success() { WKInterfaceDevice.current().play(.success) } + static func retry() { WKInterfaceDevice.current().play(.retry) } + static func click() { WKInterfaceDevice.current().play(.click) } +} diff --git a/clients/ds-watch/Sources/Net.swift b/clients/ds-watch/Sources/Net.swift new file mode 100644 index 0000000..2c3fe7c --- /dev/null +++ b/clients/ds-watch/Sources/Net.swift @@ -0,0 +1,262 @@ +import Foundation + +/// 워치 전용 경량 API 클라이언트. DS 공개 TLS(document.hyungi.net) 직접 도달 — 워치는 Tailscale 불가. +/// access 토큰=메모리 / refresh 쿠키=HTTPCookieStorage(7일 영속) 라 1회 로그인 후 자동 유지. +/// 계약은 백엔드 Pydantic 모델에서 추출(study_cards.py CardItem/RateBody) — 지어내지 않음. +enum WatchAPI { + static let baseString = "https://document.hyungi.net/api" +} + +/// GET /study-cards/due 의 CardItem (워치가 쓰는 필드만). +struct WCard: Decodable, Identifiable, Sendable { + let id: Int + let format: String + let cue: String + let fact: String + let clozeText: String? + let needsReview: Bool + let reviewStage: Int? + + enum CodingKeys: String, CodingKey { + case id, format, cue, fact + case clozeText = "cloze_text" + case needsReview = "needs_review" + case reviewStage = "review_stage" + } +} + +/// GET /events/today 의 EventResponse (워치 할일이 쓰는 필드만). +struct WEvent: Decodable, Identifiable, Sendable { + let id: Int + let title: String + let status: String + let dueAt: String? + let completedAt: String? + enum CodingKeys: String, CodingKey { + case id, title, status + case dueAt = "due_at" + case completedAt = "completed_at" + } + var isDone: Bool { status == "completed" || completedAt != nil } +} +private struct WEventList: Decodable { let items: [WEvent] } + +/// GET /briefing/latest 의 토픽/국가관점 (워치 글랜스용 부분집합). +struct WPerspective: Decodable, Identifiable, Sendable { + let country: String + let summary: String + var id: String { country } +} +struct WTopic: Decodable, Identifiable, Sendable { + let id: Int + let topicLabel: String + let headline: String + let countryPerspectives: [WPerspective] + enum CodingKeys: String, CodingKey { + case id, headline + case topicLabel = "topic_label" + case countryPerspectives = "country_perspectives" + } +} +struct WBriefing: Decodable, Sendable { + let status: String + let headlineOneliner: String? + let topics: [WTopic] + enum CodingKeys: String, CodingKey { + case status, topics + case headlineOneliner = "headline_oneliner" + } +} + +/// 이드 채팅 결과 — SSE 누적 답변 또는 unavailable(맥미니 대기/고장). +struct ChatResult: Sendable { + let answer: String + let unavailable: Bool + let reason: String? +} + +private struct AccessTokenBody: Decodable { let accessToken: String + enum CodingKeys: String, CodingKey { case accessToken = "access_token" } } + +enum WCError: Error, LocalizedError { + case transport(String) + case http(Int, String?) + case decoding(String) + var errorDescription: String? { + switch self { + case .transport(let m): return "네트워크 오류: \(m)" + case .http(let s, let m): return m ?? "서버 오류 (\(s))" + case .decoding(let m): return "응답 해석 실패: \(m)" + } + } + var isUnauthorized: Bool { if case .http(401, _) = self { return true }; return false } +} + +actor WatchClient { + private let session: URLSession + private var accessToken: String? + + init() { + let cfg = URLSessionConfiguration.default + cfg.httpCookieStorage = .shared + cfg.httpShouldSetCookies = true + cfg.waitsForConnectivity = true + session = URLSession(configuration: cfg) + } + + private func url(_ path: String) -> URL { URL(string: WatchAPI.baseString + "/" + path)! } + + private func send(_ req: URLRequest) async throws -> (Data, HTTPURLResponse) { + do { + let (d, r) = try await session.data(for: req) + guard let h = r as? HTTPURLResponse else { throw WCError.transport("no HTTP response") } + return (d, h) + } catch let e as WCError { throw e } + catch { throw WCError.transport("\(error.localizedDescription)") } + } + + private static func decodeMessage(_ data: Data) -> String? { + guard let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + if let s = o["detail"] as? String { return s } + if let d = o["detail"] as? [String: Any] { return d["message"] as? String } + return nil + } + + // MARK: auth + + func login(username: String, password: String, totp: String?) async throws { + var req = URLRequest(url: url("auth/login")) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + var body: [String: Any] = ["username": username, "password": password] + if let totp, !totp.isEmpty { body["totp_code"] = totp } + req.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, http) = try await send(req) + guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) } + accessToken = try decodeToken(data) + } + + @discardableResult + func refresh() async throws -> String { + var req = URLRequest(url: url("auth/refresh")) + req.httpMethod = "POST" + let (data, http) = try await send(req) + guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) } + let t = try decodeToken(data) + accessToken = t + return t + } + + func logout() async { + accessToken = nil + var req = URLRequest(url: url("auth/logout")); req.httpMethod = "POST" + _ = try? await send(req) + } + + private func decodeToken(_ data: Data) throws -> String { + do { return try JSONDecoder().decode(AccessTokenBody.self, from: data).accessToken } + catch { throw WCError.decoding("token: \(error)") } + } + + // MARK: authed request (401 → single refresh + retry) + + private func authed(_ path: String, method: String = "GET", json: [String: Any]? = nil) async throws -> Data { + func make(_ token: String?) throws -> URLRequest { + var r = URLRequest(url: url(path)) + r.httpMethod = method + if let token { r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + if let json { r.httpBody = try JSONSerialization.data(withJSONObject: json); r.setValue("application/json", forHTTPHeaderField: "Content-Type") } + return r + } + let (data, http) = try await send(make(accessToken)) + if http.statusCode == 401 { + let newToken = try await refresh() + let (d2, h2) = try await send(make(newToken)) + guard (200..<300).contains(h2.statusCode) else { throw WCError.http(h2.statusCode, Self.decodeMessage(d2)) } + return d2 + } + guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) } + return data + } + + // MARK: study cards + + func dueCards() async throws -> [WCard] { + let data = try await authed("study-cards/due") + do { return try JSONDecoder().decode([WCard].self, from: data) } + catch { throw WCError.decoding("due: \(error)") } + } + + func rate(cardId: Int, outcome: String) async throws { + _ = try await authed("study-cards/\(cardId)/rate", method: "POST", json: ["outcome": outcome]) + } + + func flag(cardId: Int) async throws { + _ = try await authed("study-cards/\(cardId)", method: "PATCH", json: ["needs_review": true]) + } + + // MARK: events (할일) + + func events() async throws -> [WEvent] { + let data = try await authed("events/today") + do { return try JSONDecoder().decode(WEventList.self, from: data).items } + catch { throw WCError.decoding("events: \(error)") } + } + + func completeEvent(id: Int) async throws { + _ = try await authed("events/\(id)/complete", method: "POST") + } + + // MARK: briefing (모닝 브리핑) + + func briefing() async throws -> WBriefing { + let data = try await authed("briefing/latest") + do { return try JSONDecoder().decode(WBriefing.self, from: data) } + catch { throw WCError.decoding("briefing: \(error)") } + } + + // MARK: eid chat (SSE 누적 — 맥미니 26B via DS 프록시) + + func chat(_ text: String) async throws -> ChatResult { + let payload: [String: Any] = ["mode": "daily", "messages": [["role": "user", "content": text]]] + func make(_ token: String?) throws -> URLRequest { + var r = URLRequest(url: url("eid/chat")) + r.httpMethod = "POST" + r.timeoutInterval = 120 + if let token { r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + r.setValue("application/json", forHTTPHeaderField: "Content-Type") + r.setValue("text/event-stream", forHTTPHeaderField: "Accept") + r.httpBody = try JSONSerialization.data(withJSONObject: payload) + return r + } + var (stream, resp) = try await session.bytes(for: make(accessToken)) + if (resp as? HTTPURLResponse)?.statusCode == 401 { + let t = try await refresh() + (stream, resp) = try await session.bytes(for: make(t)) + } + guard let http = resp as? HTTPURLResponse else { throw WCError.transport("no HTTP response") } + let ctype = http.value(forHTTPHeaderField: "Content-Type") ?? "" + + if ctype.contains("text/event-stream") { + var answer = "" + for try await line in stream.lines { + guard line.hasPrefix("data:") else { continue } + let body = line.dropFirst(5).trimmingCharacters(in: .whitespaces) + if body == "[DONE]" || body.isEmpty { continue } + if let d = body.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: d) as? [String: Any], + let choices = obj["choices"] as? [[String: Any]], + let delta = choices.first?["delta"] as? [String: Any], + let content = delta["content"] as? String { + answer += content + } + } + return ChatResult(answer: answer, unavailable: answer.isEmpty, + reason: answer.isEmpty ? "빈 응답" : nil) + } + // 비-스트림 = unavailable JSONResponse (맥미니 대기/고장) — 사유 추출. + var raw = Data() + for try await b in stream { raw.append(b) } + return ChatResult(answer: "", unavailable: true, reason: Self.decodeMessage(raw) ?? "이드 연결 불가") + } +} diff --git a/clients/ds-watch/Sources/RootMenu.swift b/clients/ds-watch/Sources/RootMenu.swift new file mode 100644 index 0000000..617cc17 --- /dev/null +++ b/clients/ds-watch/Sources/RootMenu.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// 워치 홈 = 4기능 메뉴. 작은 화면이라 큰 탭타깃 리스트. +struct RootMenu: View { + var body: some View { + NavigationStack { + List { + NavigationLink { EidView() } label: { + MenuRow(symbol: "bubble.left.and.bubble.right.fill", title: "이드", sub: "AI 채팅") + } + NavigationLink { StudyView() } label: { + MenuRow(symbol: "rectangle.on.rectangle.angled.fill", title: "공부", sub: "암기 카드") + } + NavigationLink { TodoView() } label: { + MenuRow(symbol: "checklist", title: "할 일", sub: "오늘") + } + NavigationLink { BriefingView() } label: { + MenuRow(symbol: "newspaper.fill", title: "브리핑", sub: "모닝") + } + } + .navigationTitle("DS") + } + .tint(WT.accent) + } +} + +struct MenuRow: View { + let symbol: String + let title: String + let sub: String + var body: some View { + HStack(spacing: 10) { + Image(systemName: symbol) + .font(.system(size: 16)) + .foregroundStyle(WT.accent) + .frame(width: 24) + VStack(alignment: .leading, spacing: 1) { + Text(title).font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink) + Text(sub).font(.system(size: 11)).foregroundStyle(WT.muted) + } + } + .padding(.vertical, 3) + } +} + +#Preview { RootMenu() } diff --git a/clients/ds-watch/Sources/Scaffolds.swift b/clients/ds-watch/Sources/Scaffolds.swift new file mode 100644 index 0000000..9b643c8 --- /dev/null +++ b/clients/ds-watch/Sources/Scaffolds.swift @@ -0,0 +1,160 @@ +import SwiftUI + +// MARK: - 할 일 (Todo) — GET /events/today + 탭하면 POST /complete + +struct TodoView: View { + @Environment(WatchModel.self) private var model + @State private var loaded = false + + var body: some View { + Group { + if model.eventsLoading && model.events.isEmpty { + ProgressView() + } else if let e = model.eventsError, model.events.isEmpty { + retry("불러오기 실패\n\(e)") { await model.loadEvents() } + } else if model.events.isEmpty { + retry("오늘 할 일이 없어요", color: WT.muted) { await model.loadEvents() } + } else { + List(model.events) { ev in + Button { + if !ev.isDone { Haptics.success() } + Task { await model.completeEvent(ev.id) } + } label: { + HStack(spacing: 10) { + Image(systemName: ev.isDone ? "checkmark.circle.fill" : "circle") + .font(.system(size: 17)) + .foregroundStyle(ev.isDone ? WT.accent : WT.muted) + Text(ev.title) + .font(.system(size: 14)) + .foregroundStyle(ev.isDone ? WT.muted : WT.ink) + .strikethrough(ev.isDone, color: WT.muted) + Spacer() + } + .padding(.vertical, 2) + } + .buttonStyle(.plain) + } + } + } + .navigationTitle("할 일") + .task { if !loaded { loaded = true; await model.loadEvents() } } + } +} + +// MARK: - 브리핑 (모닝) — GET /briefing/latest, 글랜스→정독 스크롤 + +struct BriefingView: View { + @Environment(WatchModel.self) private var model + @State private var loaded = false + + var body: some View { + ScrollView { + if model.briefingLoading && model.briefing == nil { + ProgressView().padding(.top, 20) + } else if let e = model.briefingError, model.briefing == nil { + retry("불러오기 실패\n\(e)") { await model.loadBriefing() } + } else if let b = model.briefing, !b.topics.isEmpty { + VStack(alignment: .leading, spacing: 10) { + if let one = b.headlineOneliner, !one.isEmpty { + Text(one).font(.system(size: 15, weight: .semibold)).foregroundStyle(WT.ink) + } + ForEach(b.topics) { t in + VStack(alignment: .leading, spacing: 5) { + Text(t.headline).font(.system(size: 13, weight: .semibold)).foregroundStyle(WT.ink) + ForEach(t.countryPerspectives) { p in + HStack(alignment: .top, spacing: 5) { + Text(p.country.uppercased()) + .font(.system(size: 9, weight: .bold)).foregroundStyle(WT.accent) + .frame(minWidth: 22, alignment: .leading) + Text(p.summary).font(.system(size: 11)).foregroundStyle(WT.muted).lineLimit(4) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(WT.card, in: RoundedRectangle(cornerRadius: 12)) + } + } + } else { + retry("오늘 브리핑이 아직 없어요", color: WT.muted) { await model.loadBriefing() } + } + } + .navigationTitle("브리핑") + .task { if !loaded { loaded = true; await model.loadBriefing() } } + } +} + +// MARK: - 이드 (AI 채팅) — POST /eid/chat (맥미니 26B via DS 프록시) + +struct EidView: View { + @Environment(WatchModel.self) private var model + @State private var draft = "" + + var body: some View { + ScrollView { + VStack(spacing: 8) { + HStack(spacing: 6) { + TextField("물어보기…", text: $draft) + .textFieldStyle(.plain) + .padding(8) + .background(WT.card, in: RoundedRectangle(cornerRadius: 10)) + Button { + let t = draft; draft = "" + Task { await model.sendChat(t) } + } label: { + Image(systemName: "arrow.up.circle.fill").font(.system(size: 22)) + } + .buttonStyle(.plain) + .foregroundStyle(WT.accent) + .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.chatSending) + } + if model.chatSending { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("이드 생각 중…").font(.system(size: 11)).foregroundStyle(WT.muted) + } + } + ForEach(model.chatTurns.reversed()) { turn in + ChatBubble(turn: turn) + } + if model.chatTurns.isEmpty && !model.chatSending { + Text("음성·키보드로 묻고\n맥미니 26B 가 답합니다") + .font(.system(size: 11)).foregroundStyle(WT.muted) + .multilineTextAlignment(.center).padding(.top, 8) + } + } + } + .navigationTitle("이드") + } +} + +private struct ChatBubble: View { + let turn: WatchModel.ChatTurn + var body: some View { + let isUser = turn.role == "user" + let isError = turn.role == "error" + HStack { + if isUser { Spacer(minLength: 24) } + Text(turn.text) + .font(.system(size: 12)) + .foregroundStyle(isUser ? .black : (isError ? WT.danger : WT.ink)) + .frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading) + .padding(8) + .background(isUser ? WT.accent : (isError ? WT.danger.opacity(0.15) : WT.card), + in: RoundedRectangle(cornerRadius: 10)) + if !isUser { Spacer(minLength: 24) } + } + } +} + +// MARK: - 공용 상태/재시도 + +@MainActor +private func retry(_ text: String, color: Color = WT.danger, _ action: @escaping () async -> Void) -> some View { + VStack(spacing: 10) { + Text(text).font(.system(size: 13)).foregroundStyle(color).multilineTextAlignment(.center) + Button("다시 불러오기") { Task { await action() } }.tint(WT.accent) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 6).padding(.top, 16) +} diff --git a/clients/ds-watch/Sources/StudyView.swift b/clients/ds-watch/Sources/StudyView.swift new file mode 100644 index 0000000..01ca65b --- /dev/null +++ b/clients/ds-watch/Sources/StudyView.swift @@ -0,0 +1,132 @@ +import SwiftUI + +/// 암기 카드 학습 (라이브) — 능동 회상(앞면 cue → 답 보기 → 뒷면 fact) + 2단 평정(다시/알아요). +/// 확정 워치 설계(B5): 2단 평정만(애매는 웹), '이 카드 이상해요' 플래그(교정은 웹/폰), 다크 OLED. +/// 데이터 = GET /study-cards/due, 평정 = POST /{id}/rate (correct/wrong), 플래그 = PATCH needs_review. +struct StudyView: View { + @Environment(WatchModel.self) private var model + @State private var index = 0 + @State private var revealed = false + @State private var correctCount = 0 + @State private var flagged = false + @State private var loaded = false + + var body: some View { + Group { + if model.studyLoading && model.cards.isEmpty { + ProgressView() + } else if let err = model.studyError, model.cards.isEmpty { + stateText("불러오기 실패\n\(err)", color: WT.danger, retry: true) + } else if model.cards.isEmpty { + stateText("복습할 카드가 없어요", color: WT.muted, retry: true) + } else if index >= model.cards.count { + ResultView(total: model.cards.count, correct: correctCount) { Task { await reload() } } + } else { + cardScreen(model.cards[index]) + } + } + .navigationTitle("공부") + .task { if !loaded { loaded = true; await model.loadDue(); reset() } } + } + + private func cardScreen(_ c: WCard) -> some View { + VStack(spacing: 8) { + HStack { + Text("\(index + 1) / \(model.cards.count)").font(.system(size: 11)).foregroundStyle(WT.muted) + Spacer() + Button { + flagged = true + Haptics.click() + Task { await model.flag(cardId: c.id) } + } label: { + Image(systemName: flagged ? "flag.fill" : "flag") + .font(.system(size: 11)).foregroundStyle(flagged ? WT.amber : WT.muted) + } + .buttonStyle(.plain) + } + + ScrollView { + VStack(spacing: 10) { + Text(c.cue) + .font(.system(size: 17, weight: .semibold)).foregroundStyle(WT.ink) + .multilineTextAlignment(.center) + if revealed { + Divider().overlay(WT.muted.opacity(0.4)) + Text(c.fact) + .font(.system(size: 15)).foregroundStyle(WT.accent) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + .padding(12) + .background(WT.card, in: RoundedRectangle(cornerRadius: 14)) + } + + if revealed { + HStack(spacing: 8) { + rateButton("다시", sub: "내일", color: WT.danger) { advance(c, correct: false) } + rateButton("알아요", sub: nil, color: WT.accent) { advance(c, correct: true) } + } + } else { + Button { withAnimation(.easeOut(duration: 0.15)) { revealed = true } } label: { + Text("답 보기").frame(maxWidth: .infinity) + } + .tint(WT.accent) + } + } + .padding(.horizontal, 4) + } + + private func rateButton(_ title: String, sub: String?, color: Color, _ action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 1) { + Text(title).font(.system(size: 14, weight: .semibold)) + if let sub { Text(sub).font(.system(size: 9)).opacity(0.8) } + } + .frame(maxWidth: .infinity).padding(.vertical, 2) + } + .tint(color) + } + + private func stateText(_ text: String, color: Color, retry: Bool) -> some View { + VStack(spacing: 10) { + Text(text).font(.system(size: 13)).foregroundStyle(color).multilineTextAlignment(.center) + if retry { Button("다시 불러오기") { Task { await reload() } }.tint(WT.accent) } + } + .padding(.horizontal, 6) + } + + private func advance(_ c: WCard, correct: Bool) { + if correct { correctCount += 1 } + Haptics.success() // 평정 손목 탭 (다시/알아요 동일 확정 피드백) + Task { await model.rate(cardId: c.id, outcome: correct ? "correct" : "wrong") } + flagged = false + revealed = false + index += 1 + } + + private func reload() async { await model.loadDue(); reset() } + private func reset() { index = 0; revealed = false; correctCount = 0; flagged = false } +} + +/// 세션 결과 — 정직한 tally만(서버 미제공 streak 등 날조 X). +struct ResultView: View { + let total: Int + let correct: Int + let onRestart: () -> Void + + var body: some View { + ScrollView { + VStack(spacing: 10) { + Image(systemName: "checkmark.seal.fill").font(.system(size: 30)).foregroundStyle(WT.accent) + Text("오늘 복습 완료").font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink) + Text("\(correct) / \(total) 알아요").font(.system(size: 13)).foregroundStyle(WT.muted) + Text("애매하거나 몰랐던 카드는 내일 다시 만나요") + .font(.system(size: 11)).foregroundStyle(WT.muted).multilineTextAlignment(.center) + Button("다시 불러오기", action: onRestart).tint(WT.accent).padding(.top, 4) + } + .frame(maxWidth: .infinity).padding(.vertical, 6) + } + .navigationTitle("결과") + } +} diff --git a/clients/ds-watch/Sources/WatchModel.swift b/clients/ds-watch/Sources/WatchModel.swift new file mode 100644 index 0000000..814cce7 --- /dev/null +++ b/clients/ds-watch/Sources/WatchModel.swift @@ -0,0 +1,162 @@ +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) + } + } +} diff --git a/clients/ds-watch/Sources/WatchTheme.swift b/clients/ds-watch/Sources/WatchTheme.swift new file mode 100644 index 0000000..d6f9a46 --- /dev/null +++ b/clients/ds-watch/Sources/WatchTheme.swift @@ -0,0 +1,12 @@ +import SwiftUI + +/// 워치 다크 OLED 토큰 (시안 watch-app: --wgreen #37d67a). 검정 배경 = OLED 절전·대비. +enum WT { + static let bg = Color.black + static let card = Color(white: 0.12) + static let accent = Color(red: 0x37 / 255, green: 0xd6 / 255, blue: 0x7a / 255) // #37d67a + static let ink = Color.white + static let muted = Color(white: 0.62) + static let amber = Color(red: 0xf2 / 255, green: 0xb6 / 255, blue: 0x3c / 255) + static let danger = Color(red: 0xe5 / 255, green: 0x6a / 255, blue: 0x5a / 255) +} diff --git a/clients/ds-watch/project.yml b/clients/ds-watch/project.yml new file mode 100644 index 0000000..b22a49e --- /dev/null +++ b/clients/ds-watch/project.yml @@ -0,0 +1,55 @@ +# DS Apple Watch 앱 (단일 타깃 standalone watchOS, WKApplication). 맥/아이폰은 웹 래퍼로 가고 +# 순수 네이티브는 워치 전용(2026-06-15 사용자 결정). 시뮬레이터 빌드·스크린샷으로 검증, 실기기 +# 설치는 사용자 Xcode 서명. project.yml = source of truth, *.xcodeproj/Support 는 생성물(gitignore). +name: DSWatch +options: + bundleIdPrefix: net.hyungi + deploymentTarget: + watchOS: "11.0" + createIntermediateGroups: true + minimumXcodeGenVersion: "2.40.0" + +settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + WATCHOS_DEPLOYMENT_TARGET: "11.0" + CODE_SIGN_STYLE: Automatic + CODE_SIGNING_ALLOWED: "NO" + CODE_SIGNING_REQUIRED: "NO" + +targets: + DSWatch: + type: application + platform: watchOS + deploymentTarget: "11.0" + sources: + - path: Sources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dswatch + PRODUCT_NAME: DS + GENERATE_INFOPLIST_FILE: "NO" + MARKETING_VERSION: "0.1" + CURRENT_PROJECT_VERSION: "1" + TARGETED_DEVICE_FAMILY: "4" # Apple Watch + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + info: + path: Support/Info.plist + properties: + CFBundleDisplayName: DS + CFBundleName: DS + CFBundleVersion: "1" + CFBundleShortVersionString: "0.1" + WKApplication: true # 단일 타깃 standalone 워치 앱 (컴패니언 불요) + WKWatchOnly: true # 컴패니언 iOS 앱 없는 watch-only (설치 필수 키) + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + +schemes: + DSWatch: + build: + targets: + DSWatch: all + run: + config: Debug