mycode/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift
songyc macbook 24c3004ead feat(widget): show span accumulated value in quest ring widget
③ 다짐 진행률 위젯에서 '이하 유지' 다짐은 시작부터 링이 가득 차 있고
문구도 '오늘 한도 지킴'뿐이라 지금 몇 회/몇 시간 했는지 알 수 없던 문제.
퍼센트(또는 한도 지킴/초과) 문구와 연속 달성 문구 사이에 위젯 옵션의
진행률 기간 기준 실제 누적값 한 줄을 추가한다 ("1시간 20분" / "3회").
'이상 달성' 다짐에도 동일하게 표시. 주간·월간 진행률 캡과 무관한
원본 누적값이며, 다른 위젯(②④)과 나머지 레이아웃은 그대로.

- QuestCellSnapshot.valueLabel 추가 (spanRange 원본 값 포맷)
- QuestProgress.spanRange(_:now:) 헬퍼 추출
- 위젯 미리보기에 '이하 유지(소형)' 행 신설 (앵커 CM)
- l10n: '0분' ko/en/ja (IOS·Widgets 카탈로그)

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

360 lines
15 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 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)
}
// MARK:
@ViewBuilder
private var homeSection: some View {
actionRows
goalRows
ringRows
statusRows
statsRows
}
@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")
}
}
// 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