From e717de69ca3cef49b0e1912106778a8465da7db4 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 15 Jun 2026 14:52:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(ds-watch):=20Apple=20Watch=20=EC=95=B1=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=E2=80=94=204=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=85=B8=20+=20=EA=B3=B5=EB=B6=80/=ED=95=A0=EC=9D=BC/=EB=B8=8C?= =?UTF-8?q?=EB=A6=AC=ED=95=91/=EC=9D=B4=EB=93=9C=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EA=B2=B0=EC=84=A0=20+=20DS=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- clients/ds-watch/.gitignore | 5 + .../AppIcon.appiconset/Contents.json | 6 + .../AppIcon.appiconset/watch_1024.png | Bin 0 -> 38963 bytes .../Sources/Assets.xcassets/Contents.json | 3 + clients/ds-watch/Sources/DSWatchApp.swift | 28 ++ clients/ds-watch/Sources/Haptics.swift | 9 + clients/ds-watch/Sources/Net.swift | 262 ++++++++++++++++++ clients/ds-watch/Sources/RootMenu.swift | 46 +++ clients/ds-watch/Sources/Scaffolds.swift | 160 +++++++++++ clients/ds-watch/Sources/StudyView.swift | 132 +++++++++ clients/ds-watch/Sources/WatchModel.swift | 162 +++++++++++ clients/ds-watch/Sources/WatchTheme.swift | 12 + clients/ds-watch/project.yml | 55 ++++ 13 files changed, 880 insertions(+) create mode 100644 clients/ds-watch/.gitignore create mode 100644 clients/ds-watch/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 clients/ds-watch/Sources/Assets.xcassets/AppIcon.appiconset/watch_1024.png create mode 100644 clients/ds-watch/Sources/Assets.xcassets/Contents.json create mode 100644 clients/ds-watch/Sources/DSWatchApp.swift create mode 100644 clients/ds-watch/Sources/Haptics.swift create mode 100644 clients/ds-watch/Sources/Net.swift create mode 100644 clients/ds-watch/Sources/RootMenu.swift create mode 100644 clients/ds-watch/Sources/Scaffolds.swift create mode 100644 clients/ds-watch/Sources/StudyView.swift create mode 100644 clients/ds-watch/Sources/WatchModel.swift create mode 100644 clients/ds-watch/Sources/WatchTheme.swift create mode 100644 clients/ds-watch/project.yml 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 0000000000000000000000000000000000000000..9a824bce2f9ad556a7363e99c400dbf27373cf0b GIT binary patch literal 38963 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 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