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>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# xcodegen 생성물 (project.yml 이 source of truth)
|
||||
DSWatch.xcodeproj/
|
||||
Support/
|
||||
.build/
|
||||
*.xcuserstate
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024", "filename" : "watch_1024.png" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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) ?? "이드 연결 불가")
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("결과")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user