// // NowRunningWidget.swift // Haru_DanimWidgets // // ⑥ 현재 진행 중 위젯 (CLAUDE.md §10) // 지금 측정 중인 행동을 실시간 타이머로 보여 주고, 위젯에서 바로 종료한다 (Interactive). // - 측정 중이 아니면: 빈 상태 안내 // - 측정 중이면: 대표 행동(설정 '대표 시간 기준' — 다이나믹 아일랜드와 같은 규칙)의 // 꼬리표 색 배경 + 이름 + 실시간 타이머 + 종료 버튼 // - 여러 개면: 소형·중형은 대표 1개 + "+N개 함께 추적 중", // 대형은 함께 측정 중인 행동들을 줄로 나열하고 각각 종료 가능 // import SwiftUI import SwiftData import WidgetKit import AppIntents // MARK: - 설정 struct NowRunningConfigIntent: WidgetConfigurationIntent { static let title: LocalizedStringResource = "현재 진행 중 위젯" static let description = IntentDescription("측정 중인 행동을 실시간으로 보여 주고 바로 종료할 수 있어요. 테마를 선택하세요.") @Parameter(title: "테마", default: .matchApp) var theme: WidgetThemeOption } // MARK: - 타임라인 /// 측정 중인 세션 하나의 스냅숏 struct RunningItemSnapshot: Identifiable { /// Action.uuid — 종료 버튼(RunActionIntent)이 사용 let id: UUID let name: String let symbolName: String let colorHex: String let startAt: Date var color: Color { Color(hex: colorHex) } } struct NowRunningEntry: TimelineEntry { let date: Date let locked: Bool let theme: WidgetThemeOption /// 대표 세션 (설정 '대표 시간 기준' 반영). nil = 측정 중 아님 let primary: RunningItemSnapshot? /// 대표 외에 함께 측정 중인 세션들 (시작 순) let others: [RunningItemSnapshot] /// 대표 행동의 오늘 누적이 실시간으로 흐르게 하는 기준 시각 (now − 오늘 누적초, 대형 표시용) let todayTickingBase: Date? } extension NowRunningEntry { /// 갤러리 미리보기·플레이스홀더용 샘플 (측정 중 2개 포함 — 대형 나열까지 보이도록) static func sample(theme: WidgetThemeOption = .matchApp) -> NowRunningEntry { let now = Date.now return NowRunningEntry( date: now, locked: false, theme: theme, primary: RunningItemSnapshot(id: UUID(), name: String(localized: "독서"), symbolName: "book.fill", colorHex: "#2F6B4F", startAt: now.addingTimeInterval(-45 * 60)), others: [ RunningItemSnapshot(id: UUID(), name: String(localized: "달리기"), symbolName: "figure.run", colorHex: "#9D5B4A", startAt: now.addingTimeInterval(-12 * 60)), ], todayTickingBase: now.addingTimeInterval(-65 * 60) ) } } struct NowRunningProvider: AppIntentTimelineProvider { @MainActor static func makeEntry(_ configuration: NowRunningConfigIntent, now: Date = .now) -> NowRunningEntry { IntentStore.refresh() guard WidgetStore.isUnlocked else { return NowRunningEntry(date: now, locked: true, theme: configuration.theme, primary: nil, others: [], todayTickingBase: nil) } let descriptor = FetchDescriptor( predicate: #Predicate { $0.endAt == nil }, sortBy: [SortDescriptor(\.startAt, order: .forward)] ) let running = ((try? IntentStore.context.fetch(descriptor)) ?? []).filter { $0.action != nil } // 대표 선택은 다이나믹 아일랜드(LiveActivityManager)와 같은 규칙 let mode = LiveActivityMode( rawValue: AppGroup.defaults.string(forKey: SettingsKeys.liveActivityMode) ?? UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? "" ) ?? .latest guard let session = (mode == .earliest ? running.first : running.last), let action = session.action else { return NowRunningEntry(date: now, locked: false, theme: configuration.theme, primary: nil, others: [], todayTickingBase: nil) } let others: [RunningItemSnapshot] = running.filter { $0 !== session }.compactMap { other in guard let otherAction = other.action else { return nil } return RunningItemSnapshot( id: otherAction.uuid, name: otherAction.name, symbolName: otherAction.symbolName, colorHex: otherAction.sortedTags.first?.colorHex ?? "#2F6B4F", startAt: other.startAt ) } let math = DayMath() let todaySeconds = Aggregator(math: math) .seconds(for: action, in: math.dayRange(containing: now), now: now) return NowRunningEntry( date: now, locked: false, theme: configuration.theme, primary: RunningItemSnapshot( id: action.uuid, name: action.name, symbolName: action.symbolName, colorHex: action.sortedTags.first?.colorHex ?? "#2F6B4F", startAt: session.startAt ), others: others, todayTickingBase: now.addingTimeInterval(-todaySeconds) ) } func placeholder(in context: Context) -> NowRunningEntry { .sample() } @MainActor func snapshot(for configuration: NowRunningConfigIntent, in context: Context) async -> NowRunningEntry { let entry = Self.makeEntry(configuration) // 위젯 갤러리에서는 측정 중이 아니거나 잠겨 있어도 어떤 모습인지 보이도록 샘플을 쓴다 if context.isPreview, entry.locked || entry.primary == nil { return .sample(theme: configuration.theme) } return entry } @MainActor func timeline(for configuration: NowRunningConfigIntent, in context: Context) async -> Timeline { // 타이머는 Text(style:.timer)가 엔트리 없이도 스스로 흐르고, 시작/종료 순간은 // DataChange.commit/인텐트의 명시적 리로드가 반영하므로 날짜 경계 안전망만 둔다 (①과 동일) let first = Self.makeEntry(configuration) return WidgetRefresh.timeline(first: first, live: false) { _ in first } } } // MARK: - 뷰 struct NowRunningWidgetView: View { @Environment(\.widgetFamily) private var envFamily /// 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용 var previewFamily: WidgetFamily? = nil private var family: WidgetFamily { previewFamily ?? envFamily } let entry: NowRunningEntry var body: some View { Group { if entry.locked { WidgetLockedView() .padding(12) } else if let primary = entry.primary { switch family { case .systemMedium: NowRunningHeroView(primary: primary, extraCount: entry.others.count, fullBleed: true) case .systemLarge: NowRunningLargeView(primary: primary, others: entry.others, todayTickingBase: entry.todayTickingBase) default: NowRunningSmallView(primary: primary, extraCount: entry.others.count) } } else { emptyView } } .widgetAppTheme(entry.theme) } /// 측정 중이 아닐 때의 빈 상태 private var emptyView: some View { VStack(spacing: 6) { Image(systemName: "timer") .font(.title3) .foregroundStyle(.secondary) Text("지금 측정 중인 행동이 없어요") .font(.caption2) .foregroundStyle(.secondary) .multilineTextAlignment(.center) if family != .systemSmall { Text("행동을 시작하면 여기에 실시간으로 표시돼요") .font(.caption2) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } } .padding(12) } } /// 종료 버튼 — 측정 중인 행동을 위젯에서 바로 끝낸다 (시간형 토글의 '종료' 방향) struct NowRunningStopButton: View { @Environment(\.widgetRenderingMode) private var renderingMode let actionID: UUID var size: CGFloat = 32 var body: some View { Button(intent: RunActionIntent(actionID: actionID.uuidString)) { Image(systemName: "stop.fill") .font(.system(size: size * 0.38, weight: .bold)) .foregroundStyle(renderingMode == .fullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) .frame(width: size, height: size) .background( renderingMode == .fullColor ? AnyShapeStyle(AppTheme.yellow) : AnyShapeStyle(Color.white.opacity(0.14)), in: Circle() ) // ⚠️ 여기에 invalidatableContent() 금지 (§10) — 아이콘·도형 라벨에 붙이면 // 래스터 서브트리가 별도 레이어로 분리돼 Button(intent:) 히트 영역에서 빠지고, // 종료 버튼을 눌러도 인텐트 대신 앱 열기로 샌다 (③ 링과 같은 실기기 버그가 // 이 버튼에서 재현됐던 원인. contentShape로도 못 살림). // 탭 접수 피드백은 옆의 타이머 텍스트 invalidatableContent가 담당한다. // 히트 영역을 라벨 프레임 전체로 명시 (§10 버튼 히트 영역 규칙) .contentShape(.rect) } .buttonStyle(.plain) .accessibilityLabel(Text("측정 종료")) } } /// 소형: 위젯 전체가 대표 행동의 꼬리표 색 (풀블리드) struct NowRunningSmallView: View { @Environment(\.widgetRenderingMode) private var renderingMode let primary: RunningItemSnapshot let extraCount: Int private var isFullColor: Bool { renderingMode == .fullColor } var body: some View { VStack(alignment: .leading, spacing: 3) { HStack { Image(safeSymbol: primary.symbolName) .font(.system(size: 18, weight: .semibold)) Spacer() Image(systemName: "record.circle") .font(.system(size: 11, weight: .bold)) .foregroundStyle(isFullColor ? AnyShapeStyle(AppTheme.yellow) : AnyShapeStyle(.primary)) } Spacer(minLength: 0) Text(primary.name) .font(.caption.weight(.semibold)) .lineLimit(1) HStack(alignment: .bottom) { VStack(alignment: .leading, spacing: 1) { Text(primary.startAt, style: .timer) .font(.title3.weight(.bold).monospacedDigit()) .lineLimit(1) .minimumScaleFactor(0.6) .invalidatableContent() if extraCount > 0 { Text("+\(extraCount)개 함께 추적 중") .font(.system(size: 9)) .opacity(0.85) .lineLimit(1) } } Spacer(minLength: 4) NowRunningStopButton(actionID: primary.id, size: 30) } } .foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .padding(14) .background(WidgetTintedCellBackground( color: isFullColor ? primary.color : primary.color.opacity(0.2), fullBleed: true )) } } /// 대표 행동 카드 — 중형은 위젯 전체(fullBleed), 대형은 상단 카드로 쓴다 struct NowRunningHeroView: View { @Environment(\.widgetRenderingMode) private var renderingMode let primary: RunningItemSnapshot /// "+N개 함께 추적 중" 표시용 (대형은 아래에 직접 나열하므로 0) var extraCount = 0 var fullBleed = false private var isFullColor: Bool { renderingMode == .fullColor } var body: some View { HStack(spacing: 12) { Image(safeSymbol: primary.symbolName) .font(.system(size: 22, weight: .semibold)) .frame(width: 46, height: 46) .background( Color.white.opacity(isFullColor ? 0.22 : 0.14), in: RoundedRectangle(cornerRadius: 12, style: .continuous) ) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 5) { Text(primary.name) .font(.headline) .lineLimit(1) Image(systemName: "record.circle") .font(.system(size: 10, weight: .bold)) .foregroundStyle(isFullColor ? AnyShapeStyle(AppTheme.yellow) : AnyShapeStyle(.primary)) } Text("\(primary.startAt, format: .dateTime.hour().minute()) 시작") .font(.caption) .opacity(0.85) if extraCount > 0 { Text("+\(extraCount)개 함께 추적 중") .font(.caption2) .opacity(0.85) } } Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 8) { Text(primary.startAt, style: .timer) .font(.title2.weight(.bold).monospacedDigit()) .multilineTextAlignment(.trailing) .frame(maxWidth: 105, alignment: .trailing) .lineLimit(1) .minimumScaleFactor(0.6) .invalidatableContent() NowRunningStopButton(actionID: primary.id, size: 32) } } .foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .padding(16) .background(WidgetTintedCellBackground( color: isFullColor ? primary.color : primary.color.opacity(0.2), fullBleed: fullBleed )) } } /// 대형: 대표 카드 + [함께 측정 중 나열(각각 종료 가능)] 또는 [오늘 누적·시작 시각 카드] struct NowRunningLargeView: View { let primary: RunningItemSnapshot let others: [RunningItemSnapshot] let todayTickingBase: Date? /// 나열 가능한 함께 측정 중 행동 수 (넘치면 "외 N개") private static let maxRows = 4 var body: some View { VStack(spacing: 8) { NowRunningHeroView(primary: primary) if others.isEmpty { todayCard } else { othersList } } .padding(10) } /// 함께 측정 중인 행동들 (시작 순) private var othersList: some View { VStack(spacing: 6) { ForEach(Array(others.prefix(Self.maxRows).enumerated()), id: \.offset) { _, item in NowRunningRowView(item: item) } if others.count > Self.maxRows { Text("외 \(others.count - Self.maxRows)개 측정 중") .font(.caption2) .foregroundStyle(.secondary) } } } /// 대표 하나만 측정 중일 때: 오늘 누적(실시간)과 시작 시각 private var todayCard: some View { HStack { VStack(alignment: .leading, spacing: 2) { Text("오늘 누적") .font(.caption) .foregroundStyle(.secondary) if let base = todayTickingBase { Text(base, style: .timer) .font(.headline.monospacedDigit()) .invalidatableContent() } } Spacer() VStack(alignment: .trailing, spacing: 2) { Text("시작 시각") .font(.caption) .foregroundStyle(.secondary) Text(primary.startAt, format: .dateTime.hour().minute()) .font(.headline.monospacedDigit()) } } .padding(.horizontal, 14) .padding(.vertical, 10) .background(WidgetCellBackground()) } } /// 대형의 '함께 측정 중' 행 — 행동별 타이머 + 종료 버튼 struct NowRunningRowView: View { @Environment(\.widgetRenderingMode) private var renderingMode let item: RunningItemSnapshot private var isFullColor: Bool { renderingMode == .fullColor } var body: some View { HStack(spacing: 10) { Image(safeSymbol: item.symbolName) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(isFullColor ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) .frame(width: 30, height: 30) .background( isFullColor ? AnyShapeStyle(item.color) : AnyShapeStyle(item.color.opacity(0.2)), in: RoundedRectangle(cornerRadius: 9, style: .continuous) ) Text(item.name) .font(.subheadline.weight(.semibold)) .lineLimit(1) Spacer(minLength: 6) Text(item.startAt, style: .timer) .font(.subheadline.weight(.semibold).monospacedDigit()) .multilineTextAlignment(.trailing) .frame(maxWidth: 80, alignment: .trailing) .lineLimit(1) .minimumScaleFactor(0.6) .invalidatableContent() NowRunningStopButton(actionID: item.id, size: 28) } .padding(.horizontal, 10) .padding(.vertical, 7) .background(WidgetCellBackground()) } } // MARK: - 위젯 정의 struct NowRunningWidget: Widget { var body: some WidgetConfiguration { AppIntentConfiguration( kind: "HaruNowRunningWidget", intent: NowRunningConfigIntent.self, provider: NowRunningProvider() ) { entry in NowRunningWidgetView(entry: entry) } .configurationDisplayName("현재 진행 중") .description("측정 중인 행동을 실시간으로 보고 위젯에서 바로 종료해요. (프리미엄)") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) .contentMarginsDisabled() } } // MARK: - 잠금화면 라이브 액티비티 배너 // (HaruDanimWidgets.swift는 @main이라 앱 타깃에서 제외 → 앱 내 위젯 미리보기에서도 // 렌더할 수 있게 이 파일에 둔다. ActivityConfiguration 연결은 TrackingLiveActivity) struct LockScreenActivityView: View { let state: TrackingActivityAttributes.ContentState var body: some View { HStack(spacing: 12) { Image(safeSymbol: state.symbolName) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(.white) .frame(width: 40, height: 40) .background( state.tintColor, in: RoundedRectangle(cornerRadius: 11, style: .continuous) ) VStack(alignment: .leading, spacing: 2) { Text(state.actionName) .font(.headline) .lineLimit(1) if state.extraCount > 0 { Text("+\(state.extraCount)개 함께 추적 중") .font(.caption) .foregroundStyle(.secondary) } else { Text("측정 중") .font(.caption) .foregroundStyle(.secondary) } } Spacer() Text(timerInterval: state.timerRange, countsDown: false) .font(.title2.weight(.bold).monospacedDigit()) .multilineTextAlignment(.trailing) .frame(maxWidth: 90) // 대표 측정 종료 — ⑥ 위젯과 같은 버튼(RunActionIntent 토글의 종료 방향). // 구버전 인플라이트 액티비티(actionID 없음)에는 버튼 없이 기존 표시 유지 if let id = state.actionID.flatMap(UUID.init(uuidString:)) { NowRunningStopButton(actionID: id, size: 36) } } .padding(14) } }