mycode/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift
songyc macbook ddd46a4084 feat(widget): lock-screen quest dots fill by progress ratio
- 잠금화면 위젯 '다짐 점'이 달성/미달성 이진 채움이라 진행 정도를 알 수 없다는 보고 → 각 점이 진행률(ratio)만큼 아래에서부터 차오르게 변경. 표시 전용 소형 뷰 수정이라 위험 낮음 판단 후 진행
- ratio를 그대로 쓰므로 달성=완전 채움(기존과 동일), '이하 유지'는 ratio가 1/0이라 한도 안=채움/초과=빈 점 동작 불변. 작은 크기(5~6pt)에선 부채꼴보다 물높이 방식이 가독성 좋아 채택
- 위젯 미리보기 잠금 섹션에 '다짐 여러 개+주간' 항목 추가 — 부분 채움(반쯤 찬 점/빈 점/가득 점) 시뮬 스크린샷으로 circular/rectangular 모두 확인
- 새 문자열 없음(카탈로그 클린), 도움말 무변경(스타일 세부 서술 없음). CLAUDE.md §10 갱신. Debug/Store 빌드 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
2026-07-19 05:36:46 +09:00

426 lines
18 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
// + ''
var dotsWeek = LockGoalConfigIntent()
dotsWeek.style = .dots
dotsWeek.span = .week
if let manyQuests = WidgetStore.defaultGoals(8).max(by: { $0.quests.count < $1.quests.count }) {
dotsWeek.goal = GoalEntity(manyQuests)
}
lockEntries = [
("gauge", LockGoalProvider.makeEntry(gauge)),
("number", LockGoalProvider.makeEntry(number)),
("dots", LockGoalProvider.makeEntry(dots)),
("dots·주간·다짐多", LockGoalProvider.makeEntry(dotsWeek)),
]
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 {
liveActivityRow
actionRows
goalRows
ringRows
statusRows
statsRows
nowRunningRows
}
/// ( )
/// · ( )
@ViewBuilder
private var liveActivityRow: some View {
row("잠금화면 실시간 현황 (측정 종료 버튼)") {
LockScreenActivityView(state: .init(
actionName: String(localized: "독서"),
symbolName: "book.fill",
colorHex: "#4A7A9D",
startedAt: .now.addingTimeInterval(-25 * 60),
extraCount: 1,
actionID: UUID().uuidString
))
.background(.background, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.frame(width: 372)
}
}
@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