mycode/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift
songyc macbook 49cada45c7 feat(widget): add Now Tracking home widget — live timer with a stop button
새 홈 위젯 ⑥ '현재 진행 중'(HaruNowRunningWidget, 소형/중형/대형):

- 측정 중이 아니면 빈 상태 안내("지금 측정 중인 행동이 없어요")
- 측정이 시작되면 대표 행동(설정 '대표 시간 기준' — 다이나믹
  아일랜드와 같은 규칙)의 꼬리표 색 배경 위에 이름·시작 시각·
  실시간 타이머(Text(style:.timer))·종료 버튼을 표시
- 여러 개 측정 중이면 소형/중형은 "+N개 함께 추적 중", 대형은
  함께 측정 중인 행동을 줄로 나열하고 각각 종료 가능(4개 초과분은
  "외 N개"), 대표 하나뿐이면 오늘 누적(실시간)·시작 시각 카드
- 종료는 기존 RunActionIntent(시간형 토글) 재사용 — 짧은 기록
  무시 규칙·프리미엄 게이트·히트 영역(contentShape) 규칙 동일 적용
- 타임라인은 ①과 같은 예산 전략(주기 폴링 없음, 날짜 경계 안전망),
  탭 즉시 피드백(invalidatableContent), 틴트/클리어 렌더링 모드 대응
- 갤러리 미리보기는 측정 중이 아니어도 샘플로 모습 표시

검증: Debug·Store 빌드 성공, 앱 내 위젯 미리보기(-widgetPreview,
앵커 R/RL/RM/RE)로 소·중·대형/여러 개/빈 상태 스크린샷 확인.
l10n: 신규 문자열 ko/en/ja 번역(IOS·Widgets 카탈로그 missing 0,
stale 0 — 문구 수정으로 생긴 stale 1건 삭제, 잘못 stale로 남아
있던 위젯 전용 키 3건은 정상 상태로 복구). 도움말 위젯 항목과
CLAUDE.md §10에 ⑥ 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 17:24:07 +09:00

399 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WidgetPreviewScreen.swift
// Haru_Danim
//
// DEBUG : .
// `-widgetPreview YES`( ) / `-widgetPreview lock`() .
// ( )
//
#if DEBUG
import SwiftUI
import WidgetKit
struct WidgetPreviewScreen: View {
let showLock: Bool
//
@State private var actionSmall: ActionRunEntry?
@State private var actionMedium: ActionRunEntry?
@State private var actionLarge4: ActionRunEntry?
@State private var actionLarge8: ActionRunEntry?
//
@State private var goalSmall: GoalProgressEntry?
@State private var goalMediumTwo: GoalProgressEntry?
@State private var goalMediumQuests: GoalProgressEntry?
@State private var goalLargeFour: GoalProgressEntry?
@State private var goalLargeTwoQuests: GoalProgressEntry?
@State private var goalLargeAllQuests: GoalProgressEntry?
//
@State private var ringSmall: QuestRingEntry?
@State private var ringMedium: QuestRingEntry?
@State private var ringLarge: QuestRingEntry?
/// ' ' ( + )
@State private var ringAtMostSmall: QuestRingEntry?
//
@State private var statusSmall: QuestStatusEntry?
@State private var statusMedium: QuestStatusEntry?
@State private var statusLargeMany: QuestStatusEntry?
@State private var statusLargeFourGoals: QuestStatusEntry?
@State private var statusLargeTwoGoals: QuestStatusEntry?
//
@State private var statsSmall: StatsChartEntry?
@State private var statsMedium: StatsChartEntry?
@State private var statsLarge: StatsChartEntry?
//
@State private var nowEntry: NowRunningEntry?
@State private var nowMulti: NowRunningEntry?
@State private var nowEmpty: NowRunningEntry?
//
@State private var lockEntries: [(String, LockGoalEntry)] = []
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if showLock {
lockSection
} else {
homeSection
}
}
.padding()
}
.background {
Color(white: 0.9).ignoresSafeArea()
}
.onAppear {
load()
// -widgetPreviewScroll B|C|D|S
if let anchor = UserDefaults.standard.string(forKey: "widgetPreviewScroll") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { proxy.scrollTo(anchor, anchor: .top) }
}
}
}
}
}
private func load() {
// -premium PremiumGate
_ = PremiumManager.shared
if showLock {
var gauge = LockGoalConfigIntent()
gauge.style = .gauge
var number = LockGoalConfigIntent()
number.style = .number
var dots = LockGoalConfigIntent()
dots.style = .dots
lockEntries = [
("gauge", LockGoalProvider.makeEntry(gauge)),
("number", LockGoalProvider.makeEntry(number)),
("dots", LockGoalProvider.makeEntry(dots)),
]
return
}
// : (4) + 8
let actionConfig = ActionRunConfigIntent()
actionSmall = ActionRunProvider.makeEntry(actionConfig, family: .systemSmall)
actionMedium = ActionRunProvider.makeEntry(actionConfig, family: .systemMedium)
actionLarge4 = ActionRunProvider.makeEntry(actionConfig, family: .systemLarge)
var actionEight = ActionRunConfigIntent()
actionEight.largeStyle = .eightCells
actionLarge8 = ActionRunProvider.makeEntry(actionEight, family: .systemLarge)
// : 2 + 3
let goalConfig = GoalProgressConfigIntent()
goalSmall = GoalProgressProvider.makeEntry(goalConfig, family: .systemSmall)
goalMediumTwo = GoalProgressProvider.makeEntry(goalConfig, family: .systemMedium)
var goalQuests = GoalProgressConfigIntent()
goalQuests.mediumStyle = .goalWithQuests
goalMediumQuests = GoalProgressProvider.makeEntry(goalQuests, family: .systemMedium)
goalLargeFour = GoalProgressProvider.makeEntry(goalConfig, family: .systemLarge)
var goalTwoQ = GoalProgressConfigIntent()
goalTwoQ.largeStyle = .twoGoalsWithQuests
goalLargeTwoQuests = GoalProgressProvider.makeEntry(goalTwoQ, family: .systemLarge)
var goalAllQ = GoalProgressConfigIntent()
goalAllQ.largeStyle = .oneGoalAllQuests
goalLargeAllQuests = GoalProgressProvider.makeEntry(goalAllQ, family: .systemLarge)
//
let ringConfig = QuestRingConfigIntent()
ringSmall = QuestRingProvider.makeEntry(ringConfig, family: .systemSmall)
ringMedium = QuestRingProvider.makeEntry(ringConfig, family: .systemMedium)
ringLarge = QuestRingProvider.makeEntry(ringConfig, family: .systemLarge)
if let atMost = IntentStore.quests().first(where: { $0.direction == .atMost }) {
var atMostConfig = QuestRingConfigIntent()
atMostConfig.quests = [QuestEntity(atMost)]
ringAtMostSmall = QuestRingProvider.makeEntry(atMostConfig, family: .systemSmall)
}
// : 3
let statusConfig = QuestStatusConfigIntent()
statusSmall = QuestStatusProvider.makeEntry(statusConfig, family: .systemSmall)
statusMedium = QuestStatusProvider.makeEntry(statusConfig, family: .systemMedium)
statusLargeMany = QuestStatusProvider.makeEntry(statusConfig, family: .systemLarge)
var statusFour = QuestStatusConfigIntent()
statusFour.largeStyle = .fourGoals
statusLargeFourGoals = QuestStatusProvider.makeEntry(statusFour, family: .systemLarge)
var statusTwo = QuestStatusConfigIntent()
statusTwo.largeStyle = .twoGoals
statusLargeTwoGoals = QuestStatusProvider.makeEntry(statusTwo, family: .systemLarge)
//
let statsConfig = StatsChartConfigIntent()
statsSmall = StatsChartProvider.makeEntry(statsConfig, family: .systemSmall)
statsMedium = StatsChartProvider.makeEntry(statsConfig, family: .systemMedium)
statsLarge = StatsChartProvider.makeEntry(statsConfig, family: .systemLarge)
// : ( ) + /
nowEntry = NowRunningProvider.makeEntry(NowRunningConfigIntent())
nowMulti = .sample()
nowEmpty = NowRunningEntry(date: .now, locked: false, theme: .matchApp,
primary: nil, others: [], todayTickingBase: nil)
}
// MARK:
@ViewBuilder
private var homeSection: some View {
actionRows
goalRows
ringRows
statusRows
statsRows
nowRunningRows
}
@ViewBuilder
private var actionRows: some View {
if let small = actionSmall, let medium = actionMedium {
row("① 행동 실행 · 소형/중형(2×2)") {
widgetBox(.systemSmall) { ActionRunWidgetView(previewFamily: .systemSmall, entry: small) }
widgetBox(.systemMedium) { ActionRunWidgetView(previewFamily: .systemMedium, entry: medium) }
}
}
if let large = actionLarge4 {
row("① 행동 실행 · 대형(큰 셀 4개)") {
widgetBox(.systemLarge) { ActionRunWidgetView(previewFamily: .systemLarge, entry: large) }
}
.id("A4")
}
if let large = actionLarge8 {
row("① 행동 실행 · 대형(작은 셀 8개)") {
widgetBox(.systemLarge) { ActionRunWidgetView(previewFamily: .systemLarge, entry: large) }
}
.id("A8")
}
}
@ViewBuilder
private var goalRows: some View {
if let small = goalSmall, let medium = goalMediumTwo {
row("② 목표 진행률 · 소형/중형(목표 2개)") {
widgetBox(.systemSmall) { GoalProgressWidgetView(previewFamily: .systemSmall, entry: small) }
widgetBox(.systemMedium) { GoalProgressWidgetView(previewFamily: .systemMedium, entry: medium) }
}
.id("B")
}
if let medium = goalMediumQuests {
row("② 목표 진행률 · 중형(목표+다짐)") {
widgetBox(.systemMedium) { GoalProgressWidgetView(previewFamily: .systemMedium, entry: medium) }
}
.id("B2")
}
if let large = goalLargeFour {
row("② 목표 진행률 · 대형(목표 4개)") {
widgetBox(.systemLarge) { GoalProgressWidgetView(previewFamily: .systemLarge, entry: large) }
}
.id("B4")
}
if let large = goalLargeTwoQuests {
row("② 목표 진행률 · 대형(목표+다짐 2개)") {
widgetBox(.systemLarge) { GoalProgressWidgetView(previewFamily: .systemLarge, entry: large) }
}
.id("B2Q")
}
if let large = goalLargeAllQuests {
row("② 목표 진행률 · 대형(모든 다짐)") {
widgetBox(.systemLarge) { GoalProgressWidgetView(previewFamily: .systemLarge, entry: large) }
}
.id("BALL")
}
}
@ViewBuilder
private var ringRows: some View {
if let small = ringSmall, let medium = ringMedium {
row("③ 다짐 진행률 · 소형/중형") {
widgetBox(.systemSmall) { QuestRingWidgetView(previewFamily: .systemSmall, entry: small) }
widgetBox(.systemMedium) { QuestRingWidgetView(previewFamily: .systemMedium, entry: medium) }
}
.id("C")
}
if let atMostSmall = ringAtMostSmall {
row("③ 다짐 진행률 · 이하 유지(소형)") {
widgetBox(.systemSmall) { QuestRingWidgetView(previewFamily: .systemSmall, entry: atMostSmall) }
}
.id("CM")
}
if let large = ringLarge {
row("③ 다짐 진행률 · 대형(2×2)") {
widgetBox(.systemLarge) { QuestRingWidgetView(previewFamily: .systemLarge, entry: large) }
}
.id("CL")
}
}
@ViewBuilder
private var statusRows: some View {
if let small = statusSmall, let medium = statusMedium {
row("④ 다짐 현황 · 소형(2×2)/중형(4×2)") {
widgetBox(.systemSmall) { QuestStatusWidgetView(previewFamily: .systemSmall, entry: small) }
widgetBox(.systemMedium) { QuestStatusWidgetView(previewFamily: .systemMedium, entry: medium) }
}
.id("D")
}
if let large = statusLargeMany {
row("④ 다짐 현황 · 대형(다짐 16개)") {
widgetBox(.systemLarge) { QuestStatusWidgetView(previewFamily: .systemLarge, entry: large) }
}
.id("D16")
}
if let large = statusLargeFourGoals {
row("④ 다짐 현황 · 대형(목표 4개)") {
widgetBox(.systemLarge) { QuestStatusWidgetView(previewFamily: .systemLarge, entry: large) }
}
.id("D4G")
}
if let large = statusLargeTwoGoals {
row("④ 다짐 현황 · 대형(목표 2개)") {
widgetBox(.systemLarge) { QuestStatusWidgetView(previewFamily: .systemLarge, entry: large) }
}
.id("D2G")
}
}
@ViewBuilder
private var statsRows: some View {
if let small = statsSmall, let medium = statsMedium {
row("⑤ 통계 · 소형/중형") {
widgetBox(.systemSmall) { StatsChartWidgetView(previewFamily: .systemSmall, entry: small) }
widgetBox(.systemMedium) { StatsChartWidgetView(previewFamily: .systemMedium, entry: medium) }
}
.id("S")
}
if let large = statsLarge {
row("⑤ 통계 · 대형") {
widgetBox(.systemLarge) { StatsChartWidgetView(previewFamily: .systemLarge, entry: large) }
}
.id("SL")
}
}
@ViewBuilder
private var nowRunningRows: some View {
if let entry = nowEntry {
row("⑥ 현재 진행 중 · 소형/중형") {
widgetBox(.systemSmall) { NowRunningWidgetView(previewFamily: .systemSmall, entry: entry) }
widgetBox(.systemMedium) { NowRunningWidgetView(previewFamily: .systemMedium, entry: entry) }
}
.id("R")
row("⑥ 현재 진행 중 · 대형") {
widgetBox(.systemLarge) { NowRunningWidgetView(previewFamily: .systemLarge, entry: entry) }
}
.id("RL")
}
if let multi = nowMulti {
row("⑥ 현재 진행 중 · 대형(여러 개, 샘플)") {
widgetBox(.systemLarge) { NowRunningWidgetView(previewFamily: .systemLarge, entry: multi) }
}
.id("RM")
}
if let empty = nowEmpty {
row("⑥ 현재 진행 중 · 빈 상태(소형/중형)") {
widgetBox(.systemSmall) { NowRunningWidgetView(previewFamily: .systemSmall, entry: empty) }
widgetBox(.systemMedium) { NowRunningWidgetView(previewFamily: .systemMedium, entry: empty) }
}
.id("RE")
}
}
// MARK:
@ViewBuilder
private var lockSection: some View {
ForEach(lockEntries, id: \.0) { name, entry in
row("잠금화면 · \(name)") {
ZStack {
Color.black
LockGoalWidgetView(previewFamily: .accessoryCircular, entry: entry)
.foregroundStyle(.white)
}
.frame(width: 72, height: 72)
.clipShape(Circle())
ZStack {
Color.black
LockGoalWidgetView(previewFamily: .accessoryRectangular, entry: entry)
.foregroundStyle(.white)
.padding(6)
}
.frame(width: 172, height: 76)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
if let entry = lockEntries.first?.1 {
row("잠금화면 · inline") {
ZStack {
Color.black
LockGoalWidgetView(previewFamily: .accessoryInline, entry: entry)
.foregroundStyle(.white)
}
.frame(width: 230, height: 32)
.clipShape(Capsule())
}
}
}
// MARK:
private func row(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 12, content: content)
}
}
/// -widgetRenderMode accented|vibrant /
/// white-on-white ( fullColor)
private var forcedRenderingMode: WidgetRenderingMode {
switch UserDefaults.standard.string(forKey: "widgetRenderMode") {
case "accented": return .accented
case "vibrant": return .vibrant
default: return .fullColor
}
}
private func widgetBox(_ family: WidgetFamily, @ViewBuilder content: () -> some View) -> some View {
let size: CGSize = switch family {
case .systemMedium: CGSize(width: 338, height: 158)
case .systemLarge: CGSize(width: 338, height: 354)
default: CGSize(width: 158, height: 158)
}
// containerBackground
// /
// contentMarginsDisabled
return content()
.frame(width: size.width, height: size.height)
.background(AppTheme.background)
.environment(\.colorScheme, WidgetAppearance.appScheme)
.environment(\.widgetRenderingMode, forcedRenderingMode)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: .black.opacity(0.08), radius: 6, y: 2)
}
}
#endif