mycode/myApp/HaruDanim/IOS/Views/WidgetPreviewScreen.swift
songyc macbook e1537b32f3 feat(widget): stop button on Live Activity lock-screen banner
- 잠금화면 실시간 현황(라이브 액티비티)에 종료 수단이 없다는 보고 → 배너 오른쪽에 노란 정지 버튼 추가. 워치·⑥ 위젯과 같은 검증된 경로(NowRunningStopButton → RunActionIntent 토글) 재사용이라 위험 낮음 판단 후 진행
- ContentState에 actionID(optional) 추가 — 앱 업데이트 전 인플라이트 액티비티(키 없음)도 안전 디코딩(WatchSnapshot 선례), 없으면 버튼 없이 기존 표시
- LockScreenActivityView를 NowRunningWidget.swift로 이동 — @main 파일은 앱 타깃에서 제외라 앱 내 위젯 미리보기(-widgetPreview)에서 배너를 렌더하기 위함(동작 불변). 미리보기 최상단에 배너 박스 신설
- 다이나믹 아일랜드 스모크 확인(상태 필드 추가 회귀 없음 — 콤팩트 정상 표시). 실기기 확인 항목: 잠금화면에서 정지 버튼 탭 시 즉시 종료
- 도움말 '다이나믹 아일랜드·잠금화면' 문구에 정지 버튼 안내 추가(ko/en/ja, stale 정리), CLAUDE.md §9 갱신. Debug/Store 빌드 성공, 카탈로그 9종 클린

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

418 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
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 {
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