Files
hyungi_document_server/clients/ds-watch/Sources/Scaffolds.swift
T
hyungi e717de69ca 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>
2026-07-01 06:55:52 +09:00

161 lines
6.8 KiB
Swift

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)
}