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>
161 lines
6.8 KiB
Swift
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)
|
|
}
|