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>
133 lines
5.6 KiB
Swift
133 lines
5.6 KiB
Swift
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("결과")
|
|
}
|
|
}
|