사용자 요구('이거까지 하고 배포'): 통계·기록에서도 건강 데이터를 하루다님 스타일로.
전부 표시 전용 — HealthCache 인메모리 읽기만, HK 신규 조회·스키마·집계 수학 무변경.
- 통계 탭 맨 아래: 하루=지표 값 칩 카드 / 주간·월간(롤링 포함)=
'건강 지표 (일별)' 추이 카드(지표 선택 캡슐 메뉴·요일/일 축·단위 라벨, 분홍 단일 선)
+ '건강 지표 합계·평균' 표(분모=기존 '이미 시작된 날/주' 규칙 상속, 0 지표 생략)
- 기록 탭 목록 맨 아래 '건강 데이터' 섹션 — 그날 칩(과거 날짜도 캐시로)
- 표시 규칙: 지표=타일 선택 상속, 마스터 스위치=설정 '건강 데이터 표시'(문구 확장),
미연결·맥=숨김, 내보내기 미포함(비교 카드 전례)
- 값 공급 단일 지점 HealthDisplayValues 신설(수면=유효 표시 구간 키, 일기 카드도 위임),
칩 UI HealthValueChipGrid 공용 — 빌드 9 최적화 그대로 유지
- 도움말·팝업·설정 푸터·whats-new 3언어 갱신(새 키 5·수정 3, 전 카탈로그 0/0)
검증: 자가 검증 62/20/27/10 ALL PASS(수치 불변), 시각 QA(하루·주간[합계 53,966보 시드
검산 일치]·월간 롤링[30일 분모]·기록 목록 과거 날짜·ja·18.5[경과일 분모 4일 검산 일치]),
Debug·Store·18.5 빌드 그린. 종목별 운동 통계는 1.6 후보 보류. CURRENT_PROJECT_VERSION 10.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
144 lines
7.0 KiB
Swift
144 lines
7.0 KiB
Swift
//
|
|
// WhatsNewView.swift
|
|
// Haru_Danim
|
|
//
|
|
// 업데이트 신기능 소개 팝업 (1.5 — Docs/plan-1.5.md §11)
|
|
// - 업데이트 후 첫 실행에 버전당 1회. 신규 설치는 온보딩이 담당하므로 표시하지 않는다
|
|
// (최초 실행 시 현재 버전을 시드). 온보딩과 동시 표시 금지 — 온보딩이 우선.
|
|
// - "여러 기기를 쓰면 모두 업데이트" 안내 포함 (혼합 버전 완화 — plan §10 R16)
|
|
// - 다음 버전에서는 contentVersion과 카드 내용만 바꿔 재사용한다
|
|
//
|
|
|
|
import SwiftData
|
|
import SwiftUI
|
|
|
|
struct WhatsNewView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
/// 마지막으로 소개를 본 앱 버전 (standard defaults — 기기 로컬)
|
|
static let lastShownKey = "whatsnew.lastShownVersion"
|
|
/// 소개 콘텐츠가 준비된 버전 — 앱 버전이 이 값일 때만 표시한다
|
|
static let contentVersion = "1.5"
|
|
|
|
static var appVersion: String {
|
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
|
|
}
|
|
|
|
/// 최초 실행(신규 설치)이면 현재 버전을 시드해 팝업을 건너뛴다 — 첫 안내는 온보딩 담당
|
|
@MainActor
|
|
static func markSeededIfFreshInstall(context: ModelContext) {
|
|
guard UserDefaults.standard.string(forKey: lastShownKey) == nil else { return }
|
|
let isFresh = !UserDefaults.standard.bool(forKey: OnboardingView.doneKey)
|
|
&& ((try? context.fetchCount(FetchDescriptor<Action>())) ?? 0) == 0
|
|
if isFresh {
|
|
UserDefaults.standard.set(appVersion, forKey: lastShownKey)
|
|
}
|
|
}
|
|
|
|
/// 표시 조건: 콘텐츠가 있는 버전 + 아직 이 버전 소개를 안 봄
|
|
static var shouldShow: Bool {
|
|
appVersion == contentVersion
|
|
&& UserDefaults.standard.string(forKey: lastShownKey) != appVersion
|
|
}
|
|
|
|
static func markShown() {
|
|
UserDefaults.standard.set(appVersion, forKey: lastShownKey)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 22) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("새로워진 하루 다님")
|
|
.font(.title.bold())
|
|
Text("버전 1.5에서 이런 것들이 더해졌어요.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.top, 28)
|
|
|
|
feature(
|
|
symbol: "applewatch",
|
|
tint: AppTheme.green,
|
|
title: String(localized: "워치 페이스에서 행동 기록 보기"),
|
|
body: String(localized: "'행동 기록' 컴플리케이션이 새로 생겼어요. 행동 편집에서 켜면 워치 페이스에서 누적 기록을 바로 보고, 눌러서 바로 실행할 수 있어요.")
|
|
)
|
|
feature(
|
|
symbol: "heart.fill",
|
|
tint: .pink,
|
|
title: String(localized: "모음 탭에서 건강 데이터 보기"),
|
|
body: String(localized: "걸음·운동·수면 같은 애플 건강 앱의 오늘 데이터를 모음 탭 타일로 볼 수 있어요. 통계·기록 탭에서도 함께 보여요. 값은 기기 밖으로 나가지 않아요.")
|
|
)
|
|
feature(
|
|
symbol: "figure.run",
|
|
tint: .pink,
|
|
title: String(localized: "건강 데이터로 다짐 만들기"),
|
|
body: String(localized: "'매일 8,000보', '수면 8시간', '달리기 30분'처럼 건강 데이터를 목표로 삼는 다짐을 만들 수 있어요. 운동은 종목별로도 돼요. (프리미엄)")
|
|
)
|
|
feature(
|
|
symbol: "book.closed.fill",
|
|
tint: AppTheme.green,
|
|
title: String(localized: "아이폰에서도 일기 쓰기"),
|
|
body: String(localized: "일기 탭이 아이폰에도 생겼어요. 하루 정리를 확인하고, 노트에서 '그리기'를 켜면 손가락으로 쓰고 그릴 수 있어요. (프리미엄)")
|
|
)
|
|
feature(
|
|
symbol: "heart.text.square.fill",
|
|
tint: .pink,
|
|
title: String(localized: "일기에서 건강 데이터 보기"),
|
|
body: String(localized: "하루 정리에 그날의 건강 값을 담는 카드가 생기고, 일기·기록 탭 타임테이블에는 수면·운동이 실제 시간 그대로 깔려요(색은 설정에서 변경). 낮에 자는 날이 있다면 설정의 '수면 표시 구간'을 요일별로 맞춰 보세요.")
|
|
)
|
|
feature(
|
|
symbol: "questionmark.circle.fill",
|
|
tint: AppTheme.yellow,
|
|
title: String(localized: "도움말 새 단장"),
|
|
body: String(localized: "도움말 전체 문구를 더 자연스럽게 다듬고, 새 기능 사용법을 더했어요.")
|
|
)
|
|
|
|
Label {
|
|
Text("아이폰·아이패드·맥을 함께 쓰신다면 모든 기기에서 하루 다님을 업데이트해 주세요. 버전이 섞이면 새 기능이 일부 기기에서 다르게 보일 수 있어요.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
} icon: {
|
|
Image(systemName: "arrow.triangle.2.circlepath")
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
.padding(.horizontal, 24)
|
|
.padding(.bottom, 16)
|
|
}
|
|
Button {
|
|
dismiss()
|
|
} label: {
|
|
Text("시작하기")
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(AppTheme.green)
|
|
.padding(.horizontal, 24)
|
|
.padding(.bottom, 20)
|
|
}
|
|
.background(AppTheme.background)
|
|
}
|
|
|
|
private func feature(symbol: String, tint: Color, title: String, body text: String) -> some View {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
Image(systemName: symbol)
|
|
.font(.title2)
|
|
.foregroundStyle(tint)
|
|
.frame(width: 34)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(title)
|
|
.font(.subheadline.weight(.semibold))
|
|
Text(text)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
}
|