feat(1.5-p6): 소킹 중 추가 5건 — 아이폰 일기 탭·일기 건강 카드·타임테이블 수면/운동 실구간·수면 표시 구간(요일별)·맥 필터 안내, 빌드 4
- 아이폰 일기 개방: AppTab.phoneCases·radialOrder, '그리기' 토글(손가락)·가로 도구줄, 일기 잠금 설정 전 기기 - 건강 카드: DiarySection.health(목록 끝), DiaryHealthCard(지표 선택 공용화·타일 상속·맥 숨김), 인쇄 요약 동승 - 타임테이블 실구간: HealthIntervals 캐시(App Group 400일, CloudKit 금지), injectingHealthBlocks(수면 인디고·운동 주황), 필터 '건강 데이터' 토글+맥 안내 - 수면 표시 구간: SleepWindowPrefs(전체+요일별), 평문 sleep 키=유효 구간 값, 백필 서명 매핑 포함, 새 수면 다짐 기본값 시드 - 실측 수정: 일기 목표 카드 건강 다짐 값 단위, ja 건강 칩 말줄임 - 검증 61/20/27/10 ALL PASS·3종 빌드·카탈로그 0/0·시각 QA 10여 장(26.5/18.5/맥 분기) - 마케팅: 06-bubble 재촬영, 10-week→10-diary 교체, 아이패드 03 재촬영(3언어), whats-new/설명 갱신 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
@ -157,3 +157,16 @@
|
||||
- 업데이트 후 첫 실행 시 스플래시 뒤 **버전당 1회** 표시되는 신기능 소개 시트(통상의 "이번 업데이트" 팝업 관습): 1.5 신기능 카드(행동 컴플리케이션·건강 타일·건강 다짐·도움말 개편) + **"여러 기기를 쓰신다면 모든 기기에서 업데이트해 주세요" 안내 1줄**(혼합 버전 완화 — §10 R16 연계).
|
||||
- 표시 규칙: standard defaults에 마지막 표시 버전 기록(예: `whatsnew.lastShownVersion`) — 저장된 버전과 현재 버전이 다르고 소개 콘텐츠가 있는 버전일 때만 표시, 닫는 즉시 기록(재표시 없음). **신규 설치는 미표시**(첫 실행 안내는 온보딩 담당 — 최초 실행 시 현재 버전을 시드). 온보딩과 동시 표시 금지(온보딩이 우선).
|
||||
- 구조는 OnboardingView 스타일 재사용, 향후 버전에서 콘텐츠만 교체 가능한 구조로. l10n 3언어, 검증 인자(예: `-showWhatsNew`) 신설.
|
||||
|
||||
## 12. 1.5 추가분 — 소킹 중 사용자 요구 5건 (2026-08-22 저녁, 빌드 4) ✅구현 완료
|
||||
|
||||
사용자 결정: 1.6이 아니라 **1.5에서 해결**. 세부 UX는 아리나 위임(전제: 기존 기능·사용성 무변화, 버그·충돌 없음). 확정 4결정: ①아이폰 노트 필기= b안(맥 '그리기' 토글 문법, 손가락) ②타임테이블 건강 블록 기본 켬 ③건강 카드 지표 선택= 전 날짜 공통(기기 로컬) ④새 수면 다짐 기본 구간= 타일 설정 시드.
|
||||
|
||||
1. **맥 일기 필터창 건강 안내** — RecordFilterSheet 건강 섹션이 맥(HealthDataStore 불가)에선 토글 대신 안내 문구.
|
||||
2. **아이폰 일기 탭** — AppTab.phoneCases 개방(+radialOrder 필터 제거, 버블 8개 자동 배치). 노트 도구줄= 맥과 같은 가로 스크롤+'그리기' 토글(DeviceLayout.isPhone 신설, drawingPolicy anyInput, 배치↔그리기 상호 배타). 일기 잠금 설정 전 기기 노출, 아이패드 경로 무변경.
|
||||
3. **일기 '건강 데이터' 카드** — DiarySection.health(케이스 마지막 — 기존 사용자 목록 끝 추가), DiaryHealthCard(HealthCache 하루 값, 수면은 SleepWindowPrefs 유효 구간 키), 지표 선택 diary.healthMetrics(비면 타일 상속), 맥 숨김, 인쇄 요약 DiaryPrintHealthRow 동승.
|
||||
4. **일기 타임테이블 수면·운동 실구간** — **HealthIntervals 캐시 신설**(App Group health.intervals.v1, 400일, 실구간을 논리적 하루로 분할·병합 저장 — 다짐의 '깨어난 날 통귀속'과 다른 사실 그대로 표시, CloudKit 금지 동일). refreshRecent(3일)+백필이 유지, 화면·내보내기는 injectingHealthBlocks 주입(캘린더 전례, 최하층 반투명+실선 스타일), 색= 수면 인디고·운동 주황(지표 고정색), 필터 시트 '건강 데이터' 토글(diary.timetableHealthHidden — 전 날짜 공통, 기본 켬), 일기 필터 버튼 상시 노출화.
|
||||
5. **수면 표시 구간 설정** — SleepWindowPrefs(전체 스펙+요일별, 시작=끝이면 24시간 창), 설정 → 건강 데이터 → '수면 표시 구간'. 평문 "sleep" 캐시 키= 그날 유효 구간 값(타일·카드 공유), 백필 서명에 요일 매핑 포함, QuestEditor 새 수면 다짐 기본값 시드. 발단: 사용자 교대·야간 근무 실측(주말 낮 수면이 기본 창 밖 → 타일 0).
|
||||
6. 부수 실측 수정: 일기 목표 카드 건강 다짐 값 단위("8,432회/8,000보" → 지표 단위).
|
||||
|
||||
검증: 자가 검증 61/20/27/10 ALL PASS, 3종 빌드 그린, 전 카탈로그 missing/stale 0, 시각 QA 10여 장(아이폰 요약·카드·블록·노트·필터·버블8·수면 구간, 아이패드, 맥 분기[카드 숨김], 18.5). 마케팅: 아이폰 06-bubble 재촬영(버블 8개)·10-week→**10-diary** 교체·아이패드 03-diary-summary 재촬영(건강 카드·블록), whats-new-1.5/설명 3언어 갱신. 빌드 4.
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
> **상태: 보류** — 사용자 결정(2026-08-22): "다음 업데이트 사항으로 두자. 생각이 바뀔 수도, 더 다양한 방법이 떠오를 수도 있으니 일단 보류."
|
||||
> ⚠️ **구현 금지** — 사용자가 재논의·진행을 지시할 때만 착수. 그 전까지 이 문서는 아이디어 기록 전용.
|
||||
>
|
||||
> 📌 **같은 날 저녁 대부분 1.5로 조기 흡수됨**(사용자 재결정 — plan-1.5.md §12): 후보 1의 **일기 표면**(하루 정리 타임테이블 수면·운동 블록)과 후보 2(수면 표시 구간)는 **1.5(4)에 구현 완료**. 1.6에 남은 잔여분: **기록 탭 타임테이블에도 건강 블록 표시**(HealthIntervals 캐시·주입 문법·색은 이미 있어 표시 계층만 — 난이도 하), 물 점 표시(미구현 — 1.5(4)는 수면·운동만).
|
||||
|
||||
## 후보 1 — 타임테이블에 건강 데이터 표시
|
||||
## 후보 1 — 타임테이블에 건강 데이터 표시 (일기 표면은 1.5(4)에 구현 — 잔여: 기록 탭)
|
||||
|
||||
- **발단**: 사용자 제안 2026-08-22 (1.5 TestFlight 소킹 시작 직전). "일기나 기록의 타임테이블에서도 건강 데이터가 보여졌으면. 꼭 필요한 건 아니고 있으면 좋을 것 같은 정도."
|
||||
- **범위 시안(v1)**: 수면·운동(·마음챙김 — 구간 샘플 보유) = **구간 블록**, 물 = **시각+양 점**(횟수 기록과 같은 문법). 걸음·kcal·거리·스탠드는 연속/파생 지표라 타임테이블에 부적합 → **제외로 못 박기**.
|
||||
|
||||
@ -601,7 +601,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -643,7 +643,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -686,7 +686,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -722,7 +722,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -758,7 +758,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
|
||||
@ -794,7 +794,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
|
||||
@ -827,7 +827,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Widgets/Info.plist;
|
||||
@ -853,7 +853,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Widgets/Info.plist;
|
||||
|
||||
@ -37,6 +37,9 @@ struct ContentView: View {
|
||||
NavigationStack { DataImportView() }
|
||||
} else if UserDefaults.standard.bool(forKey: "dataResetPreview") {
|
||||
NavigationStack { DataResetView() }
|
||||
} else if UserDefaults.standard.bool(forKey: "sleepWindowPreview") {
|
||||
// 검증용: -sleepWindowPreview YES → 수면 표시 구간 설정 화면 직접 표시 (1.5 추가분)
|
||||
NavigationStack { SleepWindowSettingsView() }
|
||||
} else {
|
||||
RootNavigationView()
|
||||
}
|
||||
@ -266,9 +269,11 @@ enum AppTab: String, CaseIterable, Identifiable {
|
||||
|
||||
static let moreTabValue = "more"
|
||||
|
||||
/// iPhone에 노출되는 탭 (일기는 iPad 전용 — 탭바/더보기/라디얼 메뉴에서 제외)
|
||||
/// iPhone에 노출되는 탭. 일기는 1.0~1.5(3)에서 iPad 전용이었으나 1.5 추가분에서
|
||||
/// 아이폰에도 개방 — 노트 필기는 맥과 같은 '그리기' 토글로 손가락 입력을 받는다 (§6.7).
|
||||
/// 기본 노출 탭에는 들어가지 않으므로 기존 사용자에게는 더보기·버블 끝에 조용히 추가된다
|
||||
static var phoneCases: [AppTab] {
|
||||
allCases.filter { $0 != .diary }
|
||||
allCases
|
||||
}
|
||||
|
||||
/// 사이드바(iPad·Mac)에 노출되는 탭.
|
||||
@ -326,7 +331,7 @@ enum AppTab: String, CaseIterable, Identifiable {
|
||||
for tab in AppTab.phoneCases where !seen.contains(tab) {
|
||||
result.append(tab)
|
||||
}
|
||||
return result.filter { $0 != .diary }
|
||||
return result
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@ -117,6 +117,16 @@ final class HealthDataStore {
|
||||
values = values.mapValues { ($0 * 0.9).rounded() }
|
||||
}
|
||||
HealthCache.setValues(values, dayKey: key)
|
||||
// 실구간도 시드 — 일기 타임테이블 수면·운동 블록 렌더 검증·촬영용.
|
||||
// 수면은 자정을 걸친 모양(새벽 몫 + 밤 몫), 달리기는 아침 구간
|
||||
let dayStart = math.dayRange(forKey: key).lowerBound
|
||||
func at(_ minutes: Double) -> Double {
|
||||
dayStart.addingTimeInterval(minutes * 60).timeIntervalSince1970
|
||||
}
|
||||
HealthIntervals.merge([
|
||||
HealthIntervals.sleepKey: [key: [[at(0), at(7 * 60 + 28)], [at(23 * 60 + 20), at(24 * 60)]]],
|
||||
HealthIntervals.workoutKey(.running): [key: [[at(7 * 60 + 42), at(8 * 60 + 13)]]],
|
||||
])
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -144,6 +154,7 @@ final class HealthDataStore {
|
||||
sleepWindows: keys.sleepWindows, workoutKinds: keys.workoutKinds
|
||||
)
|
||||
HealthCache.setValues(values, dayKey: key)
|
||||
HealthIntervals.merge(await dayIntervals(forDayKey: key, math: math))
|
||||
if offset == 0 {
|
||||
var tiles: [HealthMetric: Double] = [:]
|
||||
for metric in HealthMetric.allCases { tiles[metric] = values[metric.rawValue] ?? 0 }
|
||||
@ -152,9 +163,11 @@ final class HealthDataStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 건강 다짐이 실제로 쓰는 캐시 키 집합 — 수면 구간 변형(기본 구간 포함)·운동 종목
|
||||
/// 건강 다짐이 실제로 쓰는 캐시 키 집합 — 수면 구간 변형(기본 구간 포함)·운동 종목.
|
||||
/// 타일·일기 카드용 수면 표시 구간 설정(SleepWindowPrefs — 전체+요일별)도 함께 계산 대상이다
|
||||
private func questHealthKeys() -> (sleepWindows: Set<String>, workoutKinds: Set<HealthWorkoutKind>) {
|
||||
var windows: Set<String> = [Self.defaultSleepSpec]
|
||||
windows.formUnion(SleepWindowPrefs.allSpecs)
|
||||
var kinds: Set<HealthWorkoutKind> = []
|
||||
let quests = (try? DataStore.shared.mainContext.fetch(FetchDescriptor<Quest>())) ?? []
|
||||
for quest in quests {
|
||||
@ -194,10 +207,13 @@ final class HealthDataStore {
|
||||
for spec in sleepWindows {
|
||||
guard let parsed = Self.windowSpec(spec) else { continue }
|
||||
let window = Self.sleepWindow(forDayRange: range, endMinutes: parsed.endMinutes, length: parsed.length)
|
||||
let value = await sleepDuration(window: window)
|
||||
result["sleep@\(spec)"] = value
|
||||
if spec == Self.defaultSleepSpec { result[HealthMetric.sleep.rawValue] = value }
|
||||
result["sleep@\(spec)"] = await sleepDuration(window: window)
|
||||
}
|
||||
// 평문 "sleep" 키(타일·일기 카드) = 그날의 유효 표시 구간 값 (1.5 추가분 —
|
||||
// 설정에서 구간을 바꾸면 교대·야간 근무의 낮잠도 그날 값으로 잡힌다)
|
||||
let effectiveSpec = SleepWindowPrefs.spec(forDayKey: key)
|
||||
result[HealthMetric.sleep.rawValue] =
|
||||
result["sleep@\(effectiveSpec)"] ?? result["sleep@\(Self.defaultSleepSpec)"] ?? 0
|
||||
if !workoutKinds.isEmpty {
|
||||
let byKind = await workoutDurations(in: range)
|
||||
for kind in workoutKinds {
|
||||
@ -269,6 +285,50 @@ final class HealthDataStore {
|
||||
return Self.mergedDuration(intervals)
|
||||
}
|
||||
|
||||
/// 구간 합집합 (겹침 병합, 시작 오름차순) — mergedDuration의 구간 보존판.
|
||||
/// 아이폰+워치가 같은 잠을 겹쳐 기록해도 타임테이블에 블록이 이중으로 그려지지 않게 한다
|
||||
static func mergedIntervals(_ intervals: [(Date, Date)]) -> [(Date, Date)] {
|
||||
let sorted = intervals.sorted { $0.0 < $1.0 }
|
||||
var result: [(Date, Date)] = []
|
||||
for (start, end) in sorted {
|
||||
if let last = result.last, start <= last.1 {
|
||||
result[result.count - 1].1 = max(last.1, end)
|
||||
} else {
|
||||
result.append((start, end))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// 그 논리적 하루와 겹치는 수면·운동 실구간 (HealthIntervals 갱신값 — refreshRecent 전용).
|
||||
/// 이전에 있던 종류가 이번 조회에 없으면 빈 배열로 명시해 지운다 (건강 앱에서의 삭제 반영)
|
||||
private func dayIntervals(forDayKey dayKey: Date, math: DayMath) async -> [String: [Date: [[Double]]]] {
|
||||
let range = math.dayRange(forKey: dayKey)
|
||||
var perKey: [String: [[Double]]] = [:]
|
||||
for existing in HealthIntervals.workoutKeys(dayKey: dayKey) { perKey[existing] = [] }
|
||||
|
||||
let samples = await categorySamples(.sleepAnalysis, in: range)
|
||||
let asleepValues = Set(HKCategoryValueSleepAnalysis.allAsleepValues.map(\.rawValue))
|
||||
let sleepIntervals = Self.mergedIntervals(samples.compactMap { sample in
|
||||
guard asleepValues.contains(sample.value) else { return nil }
|
||||
let start = max(sample.startDate, range.lowerBound)
|
||||
let end = min(sample.endDate, range.upperBound)
|
||||
return end > start ? (start, end) : nil
|
||||
})
|
||||
perKey[HealthIntervals.sleepKey] = sleepIntervals.map {
|
||||
[$0.0.timeIntervalSince1970, $0.1.timeIntervalSince1970]
|
||||
}
|
||||
|
||||
for workout in await workoutSamples(in: range) {
|
||||
let start = max(workout.startDate, range.lowerBound)
|
||||
let end = min(workout.endDate, range.upperBound)
|
||||
guard start < end else { continue }
|
||||
perKey[HealthIntervals.workoutKey(Self.workoutKind(for: workout.workoutActivityType)), default: []]
|
||||
.append([start.timeIntervalSince1970, end.timeIntervalSince1970])
|
||||
}
|
||||
return perKey.mapValues { [dayKey: $0] }
|
||||
}
|
||||
|
||||
/// 구간 합집합 길이 (겹침 병합)
|
||||
static func mergedDuration(_ intervals: [(Date, Date)]) -> TimeInterval {
|
||||
let sorted = intervals.sorted { $0.0 < $1.0 }
|
||||
@ -373,7 +433,7 @@ final class HealthDataStore {
|
||||
let cal = math.calendar
|
||||
let todayKey = math.dayKey(for: now)
|
||||
let keys = questHealthKeys()
|
||||
let signature = "\(HealthCache.dayToken(todayKey))|\(keys.sleepWindows.sorted().joined(separator: ","))|\(keys.workoutKinds.map(\.rawValue).sorted().joined(separator: ","))"
|
||||
let signature = "\(HealthCache.dayToken(todayKey))|\(keys.sleepWindows.sorted().joined(separator: ","))|\(keys.workoutKinds.map(\.rawValue).sorted().joined(separator: ","))|\(SleepWindowPrefs.mappingSignature)"
|
||||
guard AppGroup.defaults.string(forKey: "health.backfillSignature") != signature else { return }
|
||||
|
||||
guard let startKey = cal.date(byAdding: .day, value: -(HealthCache.retentionDays - 1), to: todayKey)
|
||||
@ -446,30 +506,60 @@ final class HealthDataStore {
|
||||
key = next
|
||||
}
|
||||
merged["sleep@\(spec)"] = days
|
||||
if spec == Self.defaultSleepSpec { merged[HealthMetric.sleep.rawValue] = days }
|
||||
}
|
||||
// 평문 "sleep" 키 = 각 날짜의 유효 표시 구간 값 (SleepWindowPrefs — allValues와 동일 규칙)
|
||||
var plainSleep: [Date: Double] = [:]
|
||||
var plainKey = startKey
|
||||
while plainKey <= todayKey {
|
||||
let spec = SleepWindowPrefs.spec(forDayKey: plainKey)
|
||||
if let value = merged["sleep@\(spec)"]?[plainKey] { plainSleep[plainKey] = value }
|
||||
guard let next = cal.date(byAdding: .day, value: 1, to: plainKey) else { break }
|
||||
plainKey = next
|
||||
}
|
||||
merged[HealthMetric.sleep.rawValue] = plainSleep
|
||||
|
||||
// 수면 실구간 — 일기 타임테이블 표시용 (논리적 하루로 분할 후 병합, HealthIntervals.
|
||||
// 다짐의 '깨어난 날 통귀속'과 달리 사실 그대로 이틀에 나뉘는 의도된 규칙)
|
||||
var sleepIntervalDays: [Date: [(Date, Date)]] = [:]
|
||||
for (start, end) in asleepIntervals {
|
||||
let clippedStart = max(start, lower)
|
||||
let clippedEnd = min(end, upper)
|
||||
guard clippedStart < clippedEnd else { continue }
|
||||
for segment in math.splitByDay(start: clippedStart, end: clippedEnd)
|
||||
where segment.range.lowerBound < segment.range.upperBound {
|
||||
sleepIntervalDays[segment.dayKey, default: []]
|
||||
.append((segment.range.lowerBound, segment.range.upperBound))
|
||||
}
|
||||
}
|
||||
var intervalMerged: [String: [Date: [[Double]]]] = [:]
|
||||
intervalMerged[HealthIntervals.sleepKey] = sleepIntervalDays.mapValues { list in
|
||||
Self.mergedIntervals(list).map { [$0.0.timeIntervalSince1970, $0.1.timeIntervalSince1970] }
|
||||
}
|
||||
|
||||
// ⑤ 운동 종목 — 범위 1회 조회 후 종목×하루 버킷팅
|
||||
if !keys.workoutKinds.isEmpty {
|
||||
let workouts = await workoutSamples(in: lower..<upper)
|
||||
var byKindDays: [HealthWorkoutKind: [Date: Double]] = [:]
|
||||
for workout in workouts {
|
||||
let kind = Self.workoutKind(for: workout.workoutActivityType)
|
||||
let start = max(workout.startDate, lower)
|
||||
let end = min(workout.endDate, upper)
|
||||
guard start < end else { continue }
|
||||
for segment in math.splitByDay(start: start, end: end)
|
||||
where segment.range.lowerBound < segment.range.upperBound {
|
||||
byKindDays[kind, default: [:]][segment.dayKey, default: 0]
|
||||
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
|
||||
}
|
||||
}
|
||||
for kind in keys.workoutKinds {
|
||||
merged["workout.\(kind.rawValue)"] = byKindDays[kind] ?? [:]
|
||||
// ⑤ 운동 — 범위 1회 조회 후 종목×하루 버킷팅 (하루 합계는 다짐 종목만 캐시,
|
||||
// 실구간은 전 종목 — 일기 타임테이블은 다짐 여부와 무관하게 그날 운동을 그린다)
|
||||
let workouts = await workoutSamples(in: lower..<upper)
|
||||
var byKindDays: [HealthWorkoutKind: [Date: Double]] = [:]
|
||||
for workout in workouts {
|
||||
let kind = Self.workoutKind(for: workout.workoutActivityType)
|
||||
let start = max(workout.startDate, lower)
|
||||
let end = min(workout.endDate, upper)
|
||||
guard start < end else { continue }
|
||||
for segment in math.splitByDay(start: start, end: end)
|
||||
where segment.range.lowerBound < segment.range.upperBound {
|
||||
byKindDays[kind, default: [:]][segment.dayKey, default: 0]
|
||||
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
|
||||
intervalMerged[HealthIntervals.workoutKey(kind), default: [:]][segment.dayKey, default: []]
|
||||
.append([segment.range.lowerBound.timeIntervalSince1970,
|
||||
segment.range.upperBound.timeIntervalSince1970])
|
||||
}
|
||||
}
|
||||
for kind in keys.workoutKinds {
|
||||
merged["workout.\(kind.rawValue)"] = byKindDays[kind] ?? [:]
|
||||
}
|
||||
|
||||
HealthCache.merge(merged)
|
||||
HealthIntervals.merge(intervalMerged)
|
||||
AppGroup.defaults.set(signature, forKey: "health.backfillSignature")
|
||||
}
|
||||
|
||||
|
||||
@ -347,6 +347,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ ~ 다음 날 %@": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%1$@ – %2$@ next day"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%1$@〜翌日%2$@"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "new",
|
||||
"value": "%1$@ ~ 다음 날 %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ · %@": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -691,6 +713,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@에 끝나는 24시간": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "24 hours ending at %@"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@に終わる24時間"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@요일": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -2797,6 +2835,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"구간 끝": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Window ends"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "終了時刻"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"구간 시작": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Window starts"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "開始時刻"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"구간 지정": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -3278,6 +3348,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"기록 없음": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "No data"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "記録なし"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"기록 직접 추가": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -6070,6 +6156,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"맥에서는 애플 건강 데이터를 지원하지 않아 수면·운동을 타임테이블에 표시할 수 없어요. 아이폰·아이패드에서 확인해 주세요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Health data isn't available on the Mac, so sleep and workouts can't be shown on the timetable. Check them on your iPhone or iPad."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "MacではAppleヘルスケアのデータを利用できないため、睡眠・運動をタイムテーブルに表示できません。iPhone・iPadでご確認ください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"먼저 공유로 파일을 저장했는지 확인해 주세요. 삭제는 되돌릴 수 없어요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -6390,22 +6492,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"모음 탭 건강 데이터": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Health Data on Home"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ホームのヘルスデータ"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"모음 탭 목표 진행 현황": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -6470,18 +6556,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"모음 탭의 '건강 데이터' 줄에서 '애플 건강 연결하기'를 누르면 걸음 수·운동 시간·수면 같은 오늘 데이터를 타일로 볼 수 있어요. 어떤 지표를 어떤 순서로 보여줄지는 줄 오른쪽의 지표 버튼에서 고르고, 설정 → '모음 탭 건강 데이터'에서 줄 자체를 끄거나 즐겨찾기·나머지 행동과의 순서를 바꿀 수 있어요.\n값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요 — 하루 다님이 서버로 보내거나 iCloud에 저장하는 일은 없어요. 걸음·운동 같은 값은 설정의 '하루 시작 시간' 기준으로 오늘 하루를 더한 것이고, 수면만은 전날 밤부터 오늘 아침까지의 수면 구간을 통째로 '오늘 잔 것'으로 세요. 애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보이니 값이 안 맞아 보이면 잠시 뒤 다시 확인해 주세요. 값이 계속 0이라면 아이폰의 설정 → 개인정보 보호 및 보안 → 건강에서 하루 다님의 읽기 권한을 확인해 주세요.": {
|
||||
"모음 탭에서 애플 건강을 연결하면 이 날의 걸음·운동·수면 데이터가 여기 보여요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tap Connect Apple Health in the Health data row of the Home tab to see today's steps, exercise time, sleep and more as tiles. Choose which metrics to show and in what order via the metrics button on the right of the row, and under Settings → Health Data on Home you can turn the row off or reorder it against Favorites and Other actions.\nThe numbers are read from the Apple Health app — Haru Danim never sends them to a server or stores them in iCloud. Values like steps and exercise are today's totals based on your Day start time, while sleep alone counts the whole window from last night to this morning as \"today's sleep.\" Data collected on Apple Watch appears after it syncs with your iPhone, so if a number looks off, check again in a moment. If values stay at zero, check Haru Danim's read access on iPhone under Settings → Privacy & Security → Health."
|
||||
"value": "Connect Apple Health from the Home tab to see this day's steps, exercise and sleep here."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ホームタブの「ヘルスデータ」の行で「Appleヘルスケアと連携」を押すと、歩数・運動時間・睡眠など今日のデータをタイルで見られます。どの指標をどの順で見せるかは行の右にある指標ボタンで選び、設定 → 「ホームのヘルスデータ」で行そのものをオフにしたり、お気に入り・その他のアクションとの並び順を変えたりできます。\n値はAppleヘルスケアが持つデータを読み取ったものです — Haru Danimがサーバーに送ったりiCloudに保存したりすることはありません。歩数や運動などの値は設定の「1日の開始時刻」を基準にした今日1日の合計で、睡眠だけは前夜から今朝までの睡眠時間帯をまるごと「今日眠った分」として数えます。Apple WatchにたまったデータはウォッチとiPhoneが同期した後に表示されるので、値が合わないように見えたら少し待って確認してください。値がずっと0のままなら、iPhoneの設定 → プライバシーとセキュリティ → ヘルスケアでHaru Danimの読み取り許可を確認してください。"
|
||||
"value": "ホームタブでAppleヘルスケアを連携すると、この日の歩数・運動・睡眠がここに表示されます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"모음 탭의 '건강 데이터' 줄에서 '애플 건강 연결하기'를 누르면 걸음 수·운동 시간·수면 같은 오늘 데이터를 타일로 볼 수 있어요. 어떤 지표를 어떤 순서로 보여줄지는 줄 오른쪽의 지표 버튼에서 고르고, 설정 → '건강 데이터'에서 줄 자체를 끄거나 즐겨찾기·나머지 행동과의 순서를 바꿀 수 있어요.\n값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요 — 하루 다님이 서버로 보내거나 iCloud에 저장하는 일은 없어요. 걸음·운동 같은 값은 설정의 '하루 시작 시간' 기준으로 오늘 하루를 더한 것이고, 수면만은 정해 둔 수면 구간(기본 저녁 9시~다음 날 아침 9시)에서 잔 시간을 통째로 '그날 잔 것'으로 세요. 애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보이니 값이 안 맞아 보이면 잠시 뒤 다시 확인해 주세요. 값이 계속 0이라면 아이폰의 설정 → 개인정보 보호 및 보안 → 건강에서 하루 다님의 읽기 권한을 확인해 주세요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tap 'Connect Apple Health' in the Health data row on the Home tab to see today's data — steps, exercise time, sleep — as tiles. Choose which metrics appear, and in what order, with the metrics button on the right of the row; in Settings → Health data you can turn the row off or reorder it against Favorites and the other actions.\nThe values are read from the Apple Health app — Haru Danim never sends them to a server or stores them in iCloud. Steps, exercise and the like are summed for today based on the 'Day start time' setting, while sleep counts everything you slept within your sleep window (9 PM to 9 AM the next day by default) as that day's sleep. Data gathered on Apple Watch shows up after the watch syncs with your iPhone, so if a value looks off, check again in a moment. If a value stays at 0, check Haru Danim's read permission in iPhone Settings → Privacy & Security → Health."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ホームタブの「ヘルスデータ」行で「Appleヘルスケアと連携」をタップすると、歩数・運動時間・睡眠など今日のデータをタイルで見られます。どの指標をどの順番で表示するかは行の右にある指標ボタンで選び、設定 →「ヘルスデータ」で行そのものをオフにしたり、お気に入り・その他のアクションとの順番を変えたりできます。\n値はAppleヘルスケアAppが持つデータを読み込んだものです — Haru Danimがサーバーへ送信したりiCloudに保存したりすることはありません。歩数・運動などの値は設定の「1日の開始時刻」を基準に今日1日を合計したもので、睡眠だけは決めておいた睡眠時間帯(初期設定は夜9時〜翌朝9時)に眠った時間をまるごと「その日の睡眠」として数えます。Apple WatchにたまったデータはウォッチとiPhoneが同期した後に表示されるので、値が合わないように見えたら少し待ってから確認してください。値がずっと0の場合は、iPhoneの設定 → プライバシーとセキュリティ → ヘルスケアでHaru Danimの読み取り許可を確認してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7325,18 +7427,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"무료로는 행동 10개·목표 2개·목표당 다짐 3개까지 만들 수 있어요. 프리미엄에서는 개수 제한이 풀리고 위젯, 애플워치 앱, 시리 단축어, 기기 간 동기화, 아이패드·맥 일기, CSV 가져오기, 건강 다짐을 쓸 수 있어요. 월·년 구독이나 한 번만 결제하는 평생 이용권 중에 고르면 돼요. 구독이 끝나도 기록한 데이터는 그대로 남고, 이미 만든 건강 다짐도 계속 보이고, 쓴 일기도 계속 열람할 수 있어요.": {
|
||||
"무료로는 행동 10개·목표 2개·목표당 다짐 3개까지 만들 수 있어요. 프리미엄에서는 개수 제한이 풀리고 위젯, 애플워치 앱, 시리 단축어, 기기 간 동기화, 일기(아이폰·아이패드·맥), CSV 가져오기, 건강 다짐을 쓸 수 있어요. 월·년 구독이나 한 번만 결제하는 평생 이용권 중에 고르면 돼요. 구독이 끝나도 기록한 데이터는 그대로 남고, 이미 만든 건강 다짐도 계속 보이고, 쓴 일기도 계속 열람할 수 있어요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The free tier lets you create up to 10 actions, 2 goals and 3 quests per goal. Premium removes those limits and unlocks widgets, the Apple Watch app, Siri shortcuts, device sync, the iPad and Mac diary, CSV import and health quests. Choose a monthly or yearly subscription, or a one-time lifetime purchase. Even after a subscription ends, your data stays, health quests you've made keep showing, and diaries you've written stay readable."
|
||||
"value": "For free you can create up to 10 actions, 2 goals and 3 quests per goal. Premium removes the limits and unlocks widgets, the Apple Watch app, Siri Shortcuts, cross-device sync, the Diary (iPhone, iPad & Mac), CSV import and health quests. Choose a monthly or yearly subscription, or a one-time lifetime purchase. Even if your subscription ends, your data stays, health quests you've made remain visible, and you can still read the diary you've written."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "無料ではアクション10個・目標2個・目標ごとのクエスト3個まで作れます。プレミアムでは個数制限がなくなり、ウィジェット、Apple Watchアプリ、Siriショートカット、端末間同期、iPad・Macの日記、CSV読み込み、ヘルスクエストが使えます。月額・年額のサブスクリプションか、一度だけ支払う買い切りの生涯利用権から選べます。サブスクリプションが終わっても記録したデータはそのまま残り、作ったヘルスクエストも表示され続け、書いた日記も引き続き読めます。"
|
||||
"value": "無料ではアクション10個・目標2個・目標ごとのクエスト3個まで作れます。プレミアムでは個数の制限がなくなり、ウィジェット、Apple Watch App、Siriショートカット、デバイス間同期、日記(iPhone・iPad・Mac)、CSV読み込み、ヘルスクエストが使えます。月額・年額のサブスクリプションか、一度だけ支払う買い切りから選べます。サブスクリプションが終わっても記録したデータはそのまま残り、作成済みのヘルスクエストも表示され続け、書いた日記もずっと読み返せます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8912,6 +9014,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"수면 표시 구간": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sleep display window"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "睡眠の表示時間帯"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"수면 표시 구간 맞추기": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tuning the sleep display window"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "睡眠の表示時間帯を合わせる"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"수영": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -9618,22 +9752,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"아이패드·맥 일기": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Diary on iPad and Mac"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "iPad・Macの日記"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"아이폰·아이패드·맥을 함께 쓰신다면 모든 기기에서 하루 다님을 업데이트해 주세요. 버전이 섞이면 새 기능이 일부 기기에서 다르게 보일 수 있어요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -9650,6 +9768,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"아이폰에서도 일기 쓰기": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The Diary, now on iPhone"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "iPhoneでも日記を"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"아주 넓게": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -9763,6 +9897,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"애플 건강의 수면·운동 시간을 실제 구간 그대로 타임테이블에 보여줘요. 이 설정은 모든 날짜에 함께 적용돼요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Shows sleep and workout time from Apple Health on the timetable, at their actual times. This setting applies to all dates."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Appleヘルスケアの睡眠・運動時間を、実際の時間どおりタイムテーブルに表示します。この設定はすべての日付に適用されます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"애플 펜슬로 자유롭게 쓰고 그려 보세요": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -9923,6 +10073,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"야간 근무 등으로 특정 요일엔 낮에 잔다면, 그 요일만 구간을 따로 정할 수 있어요. 정하지 않은 요일은 전체 구간을 따라요.\n이 설정은 모음 탭 타일과 일기의 건강 카드에 적용돼요. 다짐의 수면 구간은 다짐 편집에서 따로 정해요 — 새로 만드는 수면 다짐은 여기의 전체 구간으로 시작해요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "If you sleep in the daytime on certain weekdays — night shifts, for example — you can set a separate window for just those days. Weekdays you don't set follow the default window.\nThis setting applies to the Home tab tiles and the Diary's health card. A quest's sleep window is set separately in the quest editor — new sleep quests start with the default window here."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "夜勤などで特定の曜日は昼間に眠るなら、その曜日だけ時間帯を別に決められます。決めていない曜日は基本の時間帯に従います。\nこの設定はホームタブのタイルと日記のヘルスカードに適用されます。クエストの睡眠時間帯はクエスト編集で別に決めます — 新しく作る睡眠クエストは、ここの基本の時間帯で始まります。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"야구": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -10701,6 +10867,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"요일마다 다르게": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Vary by weekday"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "曜日ごとに変える"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"운동": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -11070,6 +11252,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"이 구간 안에서 잔 시간이 '구간이 끝나는 날'의 수면으로 집계돼요. 기본은 저녁 9시부터 다음 날 아침 9시까지예요. 시작과 끝을 같은 시각으로 두면 하루 전체(24시간)를 봐요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sleep within this window counts toward the day the window ends on. The default is 9 PM to 9 AM the next day. Set the start and end to the same time to cover a full 24 hours."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "この時間帯の中で眠った時間が「時間帯が終わる日」の睡眠として集計されます。初期設定は夜9時から翌朝9時までです。開始と終了を同じ時刻にすると、丸1日(24時間)を見ます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"이 기록에 대한 메모 (선택)": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -11883,18 +12081,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"일기 (아이패드·맥 · 프리미엄)": {
|
||||
"일기 (아이폰·아이패드·맥)": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Diary (iPad & Mac · Premium)"
|
||||
"value": "Diary (iPhone, iPad & Mac)"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "日記 (iPad・Mac・プレミアム)"
|
||||
"value": "日記(iPhone・iPad・Mac)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"일기 (프리미엄)": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Diary (Premium)"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "日記(プレミアム)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11979,18 +12193,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"일기 탭 달력에서 날짜를 누르면 그날의 일기가 열려요. 첫 페이지에는 오늘 기분·요약·목표 진행 현황·할 일·24시간 타임테이블·기록이 모여 하루가 한눈에 정리돼요. 오른쪽 위 ⋯ 메뉴의 '첫 화면 구성'에서 섹션을 보일지 말지와 순서를 바꿀 수 있어요. 기분 사진을 남긴 날은 달력의 그 칸에 사진이 배경으로 깔려요. 일기는 아이패드와 맥에서 쓸 수 있고, iCloud 동기화를 켜면 두 기기에서 같은 일기가 이어져요.": {
|
||||
"일기 탭 달력에서 날짜를 누르면 그날의 일기가 열려요. 첫 페이지에는 오늘 기분·요약·목표 진행 현황·할 일·24시간 타임테이블·기록·건강 데이터가 모여 하루가 한눈에 정리돼요. 오른쪽 위 ⋯ 메뉴의 '첫 화면 구성'에서 섹션을 보일지 말지와 순서를 바꿀 수 있어요. 기분 사진을 남긴 날은 달력의 그 칸에 사진이 배경으로 깔려요. 일기는 아이폰·아이패드·맥에서 쓸 수 있고, iCloud 동기화를 켜면 어느 기기에서든 같은 일기가 이어져요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tap a date on the Diary tab's calendar to open that day. The first page gathers your mood, a summary, goal progress, to-dos, a 24-hour timetable and records — the whole day at a glance. Use First Page Layout in the ⋯ menu at the top right to show, hide and reorder sections. On days with a mood photo, that photo becomes the background of its calendar cell. The diary lives on iPad and Mac, and with iCloud sync on, the same diary continues across both."
|
||||
"value": "Tap a date on the Diary tab calendar to open that day's diary. The first page gathers your mood, summary, goal progress, to-dos, a 24-hour timetable, records and health data — your whole day at a glance. Use 'First Page Layout' in the ⋯ menu at the top right to choose which sections appear and in what order. On days you saved a mood photo, it becomes the background of that calendar cell. The Diary works on iPhone, iPad and Mac, and with iCloud sync on, the same diary continues on whichever device you pick up."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "日記タブのカレンダーで日付を押すと、その日の日記が開きます。最初のページには今日の気分・サマリー・目標の進捗・やること・24時間タイムテーブル・記録が集まり、1日がひと目で整理されます。右上の ⋯ メニューの「最初のページの構成」で、セクションの表示・非表示と順番を変えられます。気分の写真を残した日は、カレンダーのそのマスに写真が背景として敷かれます。日記はiPadとMacで使え、iCloud同期をオンにすれば2つの端末で同じ日記が続きます。"
|
||||
"value": "日記タブのカレンダーで日付をタップすると、その日の日記が開きます。最初のページには今日の気分・サマリー・目標の進み具合・やること・24時間タイムテーブル・記録・ヘルスデータが集まり、1日がひと目で整理されます。右上の⋯メニューの「最初のページの構成」でセクションの表示と順番を変えられます。気分写真を残した日は、カレンダーのそのマスに写真が背景として敷かれます。日記はiPhone・iPad・Macで使え、iCloud同期をオンにすればどの端末でも同じ日記が続きます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"일기 탭이 아이폰에도 생겼어요. 하루 정리를 확인하고, 노트에서 '그리기'를 켜면 손가락으로 쓰고 그릴 수 있어요. (프리미엄)": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The Diary tab is now on iPhone. Review your day, and turn on Draw in a note to write and sketch with your finger. (Premium)"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "日記タブがiPhoneにも登場しました。1日のまとめを確認し、ノートで「描く」をオンにすれば指で書いたり描いたりできます。(プレミアム)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12163,6 +12393,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"일기에서 건강 데이터 보기": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Health data in the Diary"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "日記でヘルスデータを見る"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"일기의 필기·사진·할 일이 함께 삭제돼요. 시간·횟수 기록과 행동·목표, 일기 양식은 남아요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -12179,6 +12425,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"일기의 하루 정리 첫 페이지에 '건강 데이터' 카드가 있어요 — 그날의 걸음·운동·수면 같은 값을 일기와 함께 봐요. 카드 오른쪽 위 버튼으로 보여줄 지표를 고르면 모든 날짜에 함께 적용돼요. 타임테이블에는 수면·운동이 실제 시간 그대로 옅은 블록으로 깔려요(수면 남보라·운동 주황) — 타임테이블 필터의 '건강 데이터' 토글로 켜고 끌 수 있어요.\n건강 값은 기기 안에만 있어서 카드의 값도 기기마다 다를 수 있어요 — 아이패드는 시스템의 건강 iCloud 동기화를 켠 경우에만 아이폰과 같은 값이 보이고, 맥에서는 카드와 블록이 보이지 않아요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The Diary's daily summary page has a 'Health data' card — see that day's steps, exercise, sleep and more alongside your diary. Pick which metrics to show with the button at the card's top right; the choice applies to all dates. The timetable shows sleep and workouts as light blocks at their actual times (indigo for sleep, orange for workouts) — toggle them with 'Health data' in the timetable filter.\nHealth values stay on each device, so the card's values can differ between devices — an iPad shows the same values as your iPhone only when the system's Health iCloud sync is on, and on the Mac the card and blocks don't appear."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "日記の1日のまとめページに「ヘルスデータ」カードがあります — その日の歩数・運動・睡眠などの値を日記と一緒に見られます。カード右上のボタンで表示する指標を選ぶと、すべての日付に適用されます。タイムテーブルには睡眠・運動が実際の時間どおり薄いブロックで敷かれます(睡眠は藍色・運動はオレンジ)— タイムテーブルフィルターの「ヘルスデータ」トグルでオン・オフできます。\nヘルスの値は端末の中だけにあるため、カードの値も端末ごとに異なることがあります — iPadはシステムのヘルスケアiCloud同期をオンにした場合のみiPhoneと同じ値になり、Macではカードとブロックは表示されません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"일어서기": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -12568,6 +12830,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"전체 구간": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Default window"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "基本の時間帯"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"전체 다짐 합산": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -13998,18 +14276,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"첫 페이지 타임테이블 카드의 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 보여줄 수 있어요 — 일정은 점선 테두리라 행동과 바로 구분돼요. 필터 버튼으로는 그날 기록된 행동 중 타임테이블에 올릴 것만 고르고, '이 날의 목표' 카드의 필터로는 보고 싶은 목표만 남겨요. 이 선택들은 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 담겨요.\n매일 같은 선택을 되풀이하기 싫다면 달력 화면 오른쪽 위의 '기본 표시 설정'을 써 보세요. 캘린더 일정을 기본으로 넣을지(모든 캘린더/선택한 캘린더만), 어떤 목표·행동을 기본으로 숨길지 한 번만 정하면 모든 날짜에 적용돼요. 날짜별 필터로 다르게 고른 날은 그 선택이 우선하고, '기본값으로 되돌리기'로 언제든 돌아갈 수 있어요.": {
|
||||
"첫 페이지 타임테이블 카드의 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 보여줄 수 있어요 — 일정은 점선 테두리라 행동과 바로 구분돼요. 필터 버튼으로는 그날 기록된 행동 중 타임테이블에 올릴 것만 고르고, '이 날의 목표' 카드의 필터로는 보고 싶은 목표만 남겨요. 이 선택들은 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 담겨요.\n애플 건강의 수면·운동 시간도 실제 잔 시간·운동한 시간 그대로 옅은 블록으로 깔려요 — 수면은 남보라, 운동은 주황이에요. 필터의 '건강 데이터' 토글로 켜고 끌 수 있고(모든 날짜 공통), 건강 데이터가 없는 맥에서는 보이지 않아요.\n매일 같은 선택을 되풀이하기 싫다면 달력 화면 오른쪽 위의 '기본 표시 설정'을 써 보세요. 캘린더 일정을 기본으로 넣을지(모든 캘린더/선택한 캘린더만), 어떤 목표·행동을 기본으로 숨길지 한 번만 정하면 모든 날짜에 적용돼요. 날짜별 필터로 다르게 고른 날은 그 선택이 우선하고, '기본값으로 되돌리기'로 언제든 돌아갈 수 있어요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Use the calendar button at the top right of the first page's timetable card to pick that day's calendar events and show them alongside — events have dashed borders, so they're easy to tell from actions. The filter button chooses which of the day's recorded actions appear on the timetable, and the filter on the This Day's Goals card keeps just the goals you want. These choices are saved per date, and timetable choices carry into diary exports as is.\nTired of repeating the same choices daily? Try Default Display at the top right of the calendar. Decide once whether calendar events are included by default (all calendars or selected ones) and which goals and actions start hidden, and it applies to every date. Dates where you filtered differently keep their own choice, and Reset to Defaults takes you back anytime."
|
||||
"value": "Use the calendar button at the top right of the first page's timetable card to pick that day's calendar events to show alongside — events have dashed borders, so they're easy to tell from actions. The filter button picks which of that day's recorded actions go on the timetable, and the filter on the This Day's Goals card keeps only the goals you want. These choices are saved per date, and the timetable selection carries into diary exports as is.\nSleep and workout time from Apple Health also appear as light blocks, at the times you actually slept and worked out — indigo for sleep, orange for workouts. Toggle them with 'Health data' in the filter (applies to all dates); they don't appear on the Mac, which has no health data.\nTired of repeating the same choices every day? Try 'Default Display' at the top right of the calendar screen. Decide once whether to include calendar events by default (all calendars, or selected ones only) and which goals and actions to hide by default, and it applies to every date. Days where you picked differently keep their own choice, and 'Reset to Default' takes you back anytime."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "最初のページのタイムテーブルカード右上のカレンダーボタンで、その日のカレンダー予定を選んで一緒に表示できます — 予定は点線の枠なので、アクションとすぐ見分けられます。フィルタボタンでは、その日記録されたアクションのうちタイムテーブルに載せるものだけを選び、「この日の目標」カードのフィルタでは見たい目標だけを残します。これらの選択は日付ごとに別々に保存され、タイムテーブルの選択は日記を書き出すときにもそのまま反映されます。\n毎日同じ選択を繰り返したくなければ、カレンダー画面右上の「デフォルト表示設定」を使ってみてください。カレンダー予定をデフォルトで入れるか(すべてのカレンダー/選んだカレンダーのみ)、どの目標・アクションをデフォルトで隠すかを一度だけ決めれば、すべての日付に適用されます。日付別フィルタで違う選択をした日はその選択が優先され、「デフォルトに戻す」でいつでも戻れます。"
|
||||
"value": "最初のページのタイムテーブルカード右上のカレンダーボタンで、その日のカレンダー予定を選んで一緒に表示できます — 予定は破線の枠なのでアクションとすぐ区別できます。フィルターボタンではその日に記録したアクションのうちタイムテーブルに載せるものを選び、「この日の目標」カードのフィルターでは見たい目標だけを残します。これらの選択は日付ごとに保存され、タイムテーブルの選択は日記の書き出しにもそのまま反映されます。\nAppleヘルスケアの睡眠・運動時間も、実際に眠った・運動した時間どおり薄いブロックで敷かれます — 睡眠は藍色、運動はオレンジです。フィルターの「ヘルスデータ」トグルでオン・オフでき(すべての日付に共通)、ヘルスデータのないMacでは表示されません。\n毎日同じ選択を繰り返したくないなら、カレンダー画面右上の「デフォルト表示設定」を使ってみてください。カレンダー予定をデフォルトで入れるか(すべてのカレンダー/選んだカレンダーのみ)、どの目標・アクションをデフォルトで隠すかを一度決めれば、すべての日付に適用されます。日付別フィルターで別の選択をした日はその選択が優先され、「デフォルトに戻す」でいつでも戻れます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14532,18 +14810,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"켜면 애플 건강 앱의 오늘 데이터(걸음·운동·수면 등)를 모음 탭에서 타일로 볼 수 있어요. 섹션 표시 순서에서 즐겨찾기·나머지 행동·건강 데이터의 배치를 정할 수 있어요.": {
|
||||
"켜면 애플 건강 앱의 오늘 데이터(걸음·운동·수면 등)를 모음 탭에서 타일로 볼 수 있어요. 섹션 표시 순서에서 즐겨찾기·나머지 행동·건강 데이터의 배치를 정할 수 있어요. 수면 표시 구간은 어느 시간대의 잠을 '그날 잔 것'으로 볼지 정해요 — 밤낮이 바뀌는 날이 있어도 맞출 수 있어요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Turn this on to see today's Apple Health data (steps, exercise, sleep and more) as tiles on the Home tab. Use Section order to arrange Favorites, Other actions and Health data."
|
||||
"value": "Turn it on to see today's data from the Apple Health app (steps, exercise, sleep and more) as tiles on the Home tab. Use Section order to arrange Favorites, the other actions and Health data. The sleep display window decides which hours of sleep count as 'that day's sleep' — so it fits even when your days and nights swap."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "オンにすると、Appleヘルスケアの今日のデータ(歩数・運動・睡眠など)をホームタブのタイルで見られます。「セクションの表示順」でお気に入り・その他の行動・ヘルスデータの並びを決められます。"
|
||||
"value": "オンにすると、AppleヘルスケアAppの今日のデータ(歩数・運動・睡眠など)をホームタブのタイルで見られます。セクションの表示順でお気に入り・その他のアクション・ヘルスデータの配置を決められます。睡眠の表示時間帯は、どの時間帯の眠りを「その日の睡眠」と見なすかを決めます — 昼夜が入れ替わる日があっても合わせられます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14676,6 +14954,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"타일의 수면이 실제와 다르게 0으로 보인다면, 그 잠이 수면 구간 밖에 있었을 가능성이 커요 — 기본 구간(저녁 9시~다음 날 아침 9시)은 아침 9시 이후에 잔 잠을 세지 않거든요. 설정 → 건강 데이터 → '수면 표시 구간'에서 구간을 바꿔 보세요. 시작과 끝을 같은 시각으로 두면 그 시각에 끝나는 하루 전체(24시간)를 보고, '요일마다 다르게'를 켜면 야간 근무처럼 특정 요일만 낮에 자는 패턴도 요일별 구간으로 맞출 수 있어요.\n이 설정은 모음 탭 타일과 일기의 건강 카드에 적용돼요. 다짐의 수면 구간은 다짐 편집에서 따로 정하고, 새로 만드는 수면 다짐은 여기의 전체 구간으로 시작해요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "If the sleep tile shows 0 when you actually slept, that sleep probably fell outside the sleep window — the default window (9 PM to 9 AM the next day) doesn't count sleep after 9 AM. Try changing it in Settings → Health data → 'Sleep display window'. Set the start and end to the same time to cover the full 24 hours ending then, or turn on 'Vary by weekday' to match patterns like night shifts, where you sleep in the daytime only on certain days.\nThis setting applies to the Home tab tiles and the Diary's health card. A quest's sleep window is set separately in the quest editor, and new sleep quests start with the default window here."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "実際に眠ったのに睡眠タイルが0に見えるなら、その眠りが睡眠時間帯の外にあった可能性が高いです — 基本の時間帯(夜9時〜翌朝9時)は朝9時以降の眠りを数えません。設定 → ヘルスデータ →「睡眠の表示時間帯」で時間帯を変えてみてください。開始と終了を同じ時刻にすればその時刻に終わる丸1日(24時間)を見ますし、「曜日ごとに変える」をオンにすれば、夜勤のように特定の曜日だけ昼間に眠るパターンも曜日別の時間帯で合わせられます。\nこの設定はホームタブのタイルと日記のヘルスカードに適用されます。クエストの睡眠時間帯はクエスト編集で別に決め、新しく作る睡眠クエストはここの基本の時間帯で始まります。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"타임테이블": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -15288,18 +15582,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 마음껏 쓰고 그리세요 — 손가락은 페이지 넘기기와 확대·축소를 맡아요. 두 손가락으로 벌려 확대하면 작은 글씨도 또박또박 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 차례를 바꿔요. 맥에서는 키보드 입력이 기본이고, 도구 줄의 '그리기'를 켠 동안에만 마우스·트랙패드로 그려져요 — 평소의 클릭과 스크롤은 이동과 버튼에 쓰여요.": {
|
||||
"페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 마음껏 쓰고 그리세요 — 손가락은 페이지 넘기기와 확대·축소를 맡아요. 두 손가락으로 벌려 확대하면 작은 글씨도 또박또박 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 차례를 바꿔요.\n아이폰에서는 도구 줄의 '그리기'를 켠 동안 손가락으로 그려지고, 끄면 손가락이 다시 페이지 넘기기·확대에 쓰여요. 맥에서는 키보드 입력이 기본이고, 마찬가지로 '그리기'를 켠 동안에만 마우스·트랙패드로 그려져요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Turn the page and free-form notes continue. Write and draw to your heart's content with Apple Pencil — fingers handle page turning and zoom. Spread two fingers to zoom in and even tiny letters come out neat, and with lined paper on you can pick the line spacing too. With multiple pages, reorder them via Change Page Order in the ⋯ menu. On the Mac, typing is the default; the mouse and trackpad draw only while Draw is on in the toolbar — otherwise clicks and scrolls do their usual moving and button work."
|
||||
"value": "Turn the page and free-form notes continue. Write and draw all you like with Apple Pencil — fingers handle page turns and pinch zoom. Spread two fingers to zoom in for neat small writing, and turn on lined paper to pick the line spacing. With multiple pages, use 'Reorder Pages' in the ⋯ menu at the top right.\nOn iPhone, your finger draws while 'Draw' in the toolbar is on — turn it off and your finger goes back to turning and zooming pages. On the Mac, keyboard input is the default, and likewise the mouse and trackpad draw only while 'Draw' is on."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ページをめくると自由なノートが続きます。Apple Pencilで思いのままに書いて描いてください — 指はページめくりと拡大・縮小を担当します。2本指で広げて拡大すれば小さな字もくっきり書けて、罫線ノートをオンにすれば行間も選べます。ページが複数あるときは、右上の ⋯ メニューの「ページの並べ替え」で順番を変えます。Macではキーボード入力が基本で、ツールバーの「描画」をオンにしている間だけマウス・トラックパッドで描けます — ふだんのクリックとスクロールは移動やボタンに使われます。"
|
||||
"value": "ページをめくると自由ノートが続きます。Apple Pencilで思いのままに書いて描いてください — 指はページめくりと拡大・縮小を担当します。2本の指で広げて拡大すれば小さな字もきれいに書け、罫線ノートをオンにすれば行間も選べます。ページが複数あるときは右上の⋯メニューの「ページの順序を変更」で並べ替えます。\niPhoneではツールバーの「描く」をオンにしている間だけ指で描け、オフにすると指はページめくり・拡大に戻ります。Macではキーボード入力が基本で、同じく「描く」をオンにしている間だけマウス・トラックパッドで描けます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15993,18 +16287,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"하루 정리와 애플 펜슬 다이어리 — 맥에서는 마우스·키보드로 써요.": {
|
||||
"하루 정리에 그날의 건강 값을 담는 카드가 생기고, 타임테이블에는 수면·운동이 실제 시간 그대로 깔려요. 낮에 자는 날이 있다면 설정의 '수면 표시 구간'을 요일별로 맞춰 보세요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Daily summary pages and an Apple Pencil diary — on Mac, write with mouse and keyboard."
|
||||
"value": "The daily summary gains a card with that day's health values, and sleep and workouts now appear on the timetable at their actual times. If you sleep in the daytime some days, tune the 'Sleep display window' per weekday in Settings."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "一日のまとめとApple Pencil日記 — Macではマウス・キーボードで書けます。"
|
||||
"value": "1日のまとめにその日のヘルス値をまとめるカードが加わり、タイムテーブルには睡眠・運動が実際の時間どおりに表示されます。昼間に眠る日があるなら、設定の「睡眠の表示時間帯」を曜日ごとに合わせてみてください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"하루 정리와 다이어리 — 아이패드는 애플 펜슬, 아이폰은 손가락 그리기, 맥은 마우스·키보드로 써요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "A daily summary and a free-form diary — Apple Pencil on iPad, finger drawing on iPhone, mouse and keyboard on Mac."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "1日のまとめと自由なダイアリー — iPadはApple Pencil、iPhoneは指描き、Macはマウス・キーボードで。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -16105,18 +16415,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.": {
|
||||
"하루의 기록·통계·건강 데이터와 함께\n나만의 다이어리를 만들 수 있어요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Create your own diary with Apple Pencil,\nalongside your daily records and stats."
|
||||
"value": "Build your own diary alongside\nyour day's records, stats and health data."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "1日の記録・統計とともに、Apple Pencilで書く\n自分だけのダイアリーが作れます。"
|
||||
"value": "1日の記録・統計・ヘルスデータと一緒に\n自分だけのダイアリーを作れます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -331,8 +331,9 @@ struct DiaryExportRenderer {
|
||||
))
|
||||
}
|
||||
}
|
||||
// 화면(하루 정리)과 동일하게 24시간 전체 + 선택한 캘린더 일정을 담는다
|
||||
// 화면(하루 정리)과 동일하게 24시간 전체 + 선택한 캘린더 일정 + 건강 블록(1.5)을 담는다
|
||||
snapshot = snapshot.injectingCalendarEvents(DiaryCalendarEvents.timetableBlocks(for: entry, math: math))
|
||||
snapshot = snapshot.injectingHealthBlocks(DiaryHealthTimetable.blocks(dayKey: entry.dayKey, math: math))
|
||||
let summary = DiaryPrintSummaryView(entry: entry, snapshot: snapshot)
|
||||
let summaryRenderer = ImageRenderer(content: summary)
|
||||
summaryRenderer.scale = 2
|
||||
@ -482,6 +483,7 @@ struct DiaryPrintSummaryView: View {
|
||||
if !snapshot.hero.isEmpty {
|
||||
ExportHeroRow(stats: snapshot.hero)
|
||||
}
|
||||
DiaryPrintHealthRow(dayKey: entry.dayKey)
|
||||
ForEach(Array(snapshot.sections.enumerated()), id: \.offset) { _, section in
|
||||
ExportSectionView(section: section, innerWidth: innerWidth)
|
||||
}
|
||||
@ -493,6 +495,61 @@ struct DiaryPrintSummaryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 인쇄 요약의 건강 값 줄 (1.5) — 화면 카드(DiaryHealthCard)와 같은 지표 선택·수면 구간 규칙.
|
||||
/// 값이 하나도 없거나(맥·데이터 없는 기기) 첫 화면 구성에서 건강 카드를 숨겼으면 그리지 않는다
|
||||
struct DiaryPrintHealthRow: View {
|
||||
let dayKey: Date
|
||||
|
||||
private var entries: [(metric: HealthMetric, value: Double)] {
|
||||
guard HealthDataStore.isAvailable else { return [] }
|
||||
let hiddenRaw = UserDefaults.standard.string(forKey: DiarySectionConfig.hiddenKey) ?? ""
|
||||
guard !DiarySectionConfig.hidden(from: hiddenRaw).contains(.health) else { return [] }
|
||||
let diaryRaw = UserDefaults.standard.string(forKey: "diary.healthMetrics") ?? ""
|
||||
let tileRaw = AppGroup.defaults.string(forKey: LocalPrefsKeys.healthMetrics) ?? ""
|
||||
let metrics = HealthMetric.selectedList(raw: diaryRaw.isEmpty ? tileRaw : diaryRaw)
|
||||
return metrics.compactMap { metric in
|
||||
let key = metric == .sleep
|
||||
? "sleep@\(SleepWindowPrefs.spec(forDayKey: dayKey))"
|
||||
: metric.rawValue
|
||||
guard let value = HealthCache.value(key, dayKey: dayKey) else { return nil }
|
||||
return (metric, value)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let list = entries
|
||||
if !list.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.pink)
|
||||
Text("건강 데이터")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
ForEach(list, id: \.metric) { item in
|
||||
HStack(spacing: 5) {
|
||||
Image(safeSymbol: item.metric.symbolName)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.pink)
|
||||
Text(item.metric.name)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(item.metric.valueLabel(item.value))
|
||||
.font(.footnote.weight(.semibold).monospacedDigit())
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 인쇄용 노트 페이지 (768×1024 고정)
|
||||
|
||||
struct DiaryPrintNotePageView: View {
|
||||
|
||||
@ -50,15 +50,15 @@ struct DiaryNotePageView: View {
|
||||
@State private var debugShapeEditDone = false
|
||||
@State private var debugTextEditDone = false
|
||||
#endif
|
||||
/// 필기 입력 게이트. 맥(Designed for iPad)은 마우스가 이동·버튼 조작과 겸용이라
|
||||
/// 도구 줄의 '그리기' 토글을 켠 동안만 캔버스가 입력을 받는다 (텍스트 우선 UX, §6.7).
|
||||
/// 필기 입력 게이트. 맥(마우스)·아이폰(손가락, 1.5 추가분)은 그 입력이 이동·버튼 조작과
|
||||
/// 겸용이라 도구 줄의 '그리기' 토글을 켠 동안만 캔버스가 입력을 받는다 (§6.7).
|
||||
/// 아이패드는 항상 true(토글 미노출) — 펜슬 필기 동작이 기존과 완전히 동일하다.
|
||||
@State private var drawMode: Bool = {
|
||||
#if DEBUG
|
||||
// 검증·촬영용: -diaryDrawMode YES → 맥 분기에서도 그리기 모드로 시작
|
||||
// 검증·촬영용: -diaryDrawMode YES → 맥·아이폰 분기에서도 그리기 모드로 시작
|
||||
if UserDefaults.standard.bool(forKey: "diaryDrawMode") { return true }
|
||||
#endif
|
||||
return !DeviceLayout.isMac
|
||||
return !(DeviceLayout.isMac || DeviceLayout.isPhone)
|
||||
}()
|
||||
|
||||
/// 이 페이지의 배경 양식 (삭제됐거나 아직 동기화 전이면 nil → 빈 캔버스 폴백)
|
||||
@ -153,10 +153,11 @@ struct DiaryNotePageView: View {
|
||||
/// 맥: 텍스트 입력이 메인 — '텍스트'를 맨 앞으로, 필기는 '그리기' 토글로만 켠다.
|
||||
/// 맥은 전역 xLarge 보정 + 사이드바로 폭이 좁아질 수 있어 가로 스크롤 도구 줄로
|
||||
/// 라벨을 온전히 유지한다 (트랙패드 스크롤 — 창이 넓으면 스크롤 없이 다 보임).
|
||||
/// 아이폰(1.5 추가분)도 같은 가로 스크롤 줄 — 화면 폭이 좁고 '그리기' 토글이 필요해서
|
||||
@ViewBuilder
|
||||
private var pageToolbar: some View {
|
||||
Group {
|
||||
if DeviceLayout.isMac {
|
||||
if DeviceLayout.isMac || DeviceLayout.isPhone {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
textAddButton
|
||||
@ -241,7 +242,7 @@ struct DiaryNotePageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 맥 전용 필기 토글 — 켜면 배치 모드를 끄고 캔버스가 마우스 입력을 받는다
|
||||
/// 맥·아이폰 필기 토글 — 켜면 배치 모드를 끄고 캔버스가 마우스/손가락 입력을 받는다
|
||||
private var drawModeToggle: some View {
|
||||
Toggle(isOn: $drawMode.animation()) {
|
||||
Label("그리기", systemImage: "pencil.tip")
|
||||
@ -288,8 +289,8 @@ struct DiaryNotePageView: View {
|
||||
selectedItemID = nil
|
||||
endInlineTextEditing()
|
||||
}
|
||||
// 맥: 배치와 그리기는 상호 배타 — 마우스 입력이 어디로 가는지 항상 명확하게
|
||||
if arranging && DeviceLayout.isMac { drawMode = false }
|
||||
// 맥·아이폰: 배치와 그리기는 상호 배타 — 마우스/손가락 입력이 어디로 가는지 항상 명확하게
|
||||
if arranging && (DeviceLayout.isMac || DeviceLayout.isPhone) { drawMode = false }
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,7 +337,7 @@ struct DiaryNotePageView: View {
|
||||
|
||||
// 필기 캔버스 — 항상 최상단. 배치 모드에서는 터치를 요소들에게 넘기고,
|
||||
// 열람 전용(프리미엄 만료)에서는 필기를 받지 않는다.
|
||||
// drawMode는 맥에서만 꺼질 수 있다(아이패드 항상 true — 기존 동작 불변, §6.7)
|
||||
// drawMode는 맥·아이폰에서만 꺼질 수 있다(아이패드 항상 true — 기존 동작 불변, §6.7)
|
||||
DiaryPencilCanvas(
|
||||
drawingData: page.drawingData,
|
||||
isActive: isActive && !arranging && !readOnly && drawMode,
|
||||
@ -1277,9 +1278,9 @@ struct DiaryPencilCanvas: UIViewRepresentable {
|
||||
canvas.drawingPolicy = .anyInput
|
||||
#else
|
||||
// 실기기: 아이패드는 펜슬 전용 — 손가락은 페이지 이동/확대에만 쓰인다.
|
||||
// 맥(Designed for iPad)은 펜슬이 없어 마우스·트랙패드 입력 허용 —
|
||||
// 맥(마우스·트랙패드)·아이폰(손가락, 1.5 추가분)은 펜슬이 없어 anyInput 허용 —
|
||||
// 입력 게이트는 노트 도구 줄의 '그리기' 토글(drawMode)이 담당한다 (§6.7)
|
||||
canvas.drawingPolicy = DeviceLayout.isMac ? .anyInput : .pencilOnly
|
||||
canvas.drawingPolicy = (DeviceLayout.isMac || DeviceLayout.isPhone) ? .anyInput : .pencilOnly
|
||||
#endif
|
||||
canvas.delegate = context.coordinator
|
||||
if let drawing = try? PKDrawing(data: drawingData), !drawingData.isEmpty {
|
||||
|
||||
@ -499,7 +499,7 @@ struct DiaryLockedView: View {
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
Text("일기는 프리미엄 기능이에요")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.")
|
||||
Text("하루의 기록·통계·건강 데이터와 함께\n나만의 다이어리를 만들 수 있어요.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@ -947,7 +947,9 @@ private struct DiaryPageOrderSheet: View {
|
||||
|
||||
/// 일기 첫 페이지를 구성하는 섹션들. rawValue가 저장 키로 쓰인다.
|
||||
enum DiarySection: String, CaseIterable, Identifiable {
|
||||
case mood, hero, goals, todos, timetable, records, bars
|
||||
// health는 1.5 추가분 — 케이스 순서상 마지막이라 기존 사용자·신규 설치 모두
|
||||
// 목록 끝에 붙는다 (기존 첫 화면 배치를 흔들지 않는 의도된 위치)
|
||||
case mood, hero, goals, todos, timetable, records, bars, health
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@ -960,6 +962,7 @@ enum DiarySection: String, CaseIterable, Identifiable {
|
||||
case .timetable: return "타임테이블"
|
||||
case .records: return "기록 상세"
|
||||
case .bars: return "통계 막대"
|
||||
case .health: return "건강 데이터"
|
||||
}
|
||||
}
|
||||
|
||||
@ -972,6 +975,7 @@ enum DiarySection: String, CaseIterable, Identifiable {
|
||||
case .timetable: return "calendar.day.timeline.left"
|
||||
case .records: return "list.bullet.rectangle"
|
||||
case .bars: return "chart.bar.fill"
|
||||
case .health: return "heart.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1017,7 +1021,11 @@ struct DiarySectionConfigSheet: View {
|
||||
@AppStorage(DiarySectionConfig.orderKey) private var orderRaw = ""
|
||||
@AppStorage(DiarySectionConfig.hiddenKey) private var hiddenRaw = ""
|
||||
|
||||
private var sections: [DiarySection] { DiarySectionConfig.order(from: orderRaw) }
|
||||
/// 건강 카드는 건강 데이터가 없는 기기(맥)에선 목록 자체에서 뺀다 — 켜도 보일 수 없어서
|
||||
private var sections: [DiarySection] {
|
||||
DiarySectionConfig.order(from: orderRaw)
|
||||
.filter { $0 != .health || HealthDataStore.isAvailable }
|
||||
}
|
||||
private var hiddenSections: Set<DiarySection> { DiarySectionConfig.hidden(from: hiddenRaw) }
|
||||
|
||||
var body: some View {
|
||||
@ -1110,6 +1118,8 @@ private struct DiarySummaryPage: View {
|
||||
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
|
||||
@AppStorage(DiarySectionConfig.orderKey) private var sectionOrderRaw = ""
|
||||
@AppStorage(DiarySectionConfig.hiddenKey) private var hiddenSectionsRaw = ""
|
||||
/// 타임테이블 건강 블록 숨김(수면/운동) — 필터 시트 토글이 이 값을 쓰므로 변경 즉시 재렌더 (1.5)
|
||||
@AppStorage(DiaryHealthTimetable.hiddenKey) private var healthHiddenRaw = ""
|
||||
|
||||
/// 이 날짜에 선택된 캘린더 일정 → 타임테이블 블록 (선택 변경 시 .task로 재조회)
|
||||
@State private var calendarBlocks: [ExportTimetableData.Block] = []
|
||||
@ -1131,10 +1141,13 @@ private struct DiarySummaryPage: View {
|
||||
|
||||
private var math: DayMath { DayMath() }
|
||||
|
||||
/// 표시할 섹션 (구성 설정의 순서, 숨김 제외)
|
||||
/// 표시할 섹션 (구성 설정의 순서, 숨김 제외).
|
||||
/// 건강 카드는 건강 데이터가 없는 기기(맥)에선 제외 — 빈 카드 자리를 남기지 않는다
|
||||
private var visibleSections: [DiarySection] {
|
||||
let hidden = DiarySectionConfig.hidden(from: hiddenSectionsRaw)
|
||||
return DiarySectionConfig.order(from: sectionOrderRaw).filter { !hidden.contains($0) }
|
||||
return DiarySectionConfig.order(from: sectionOrderRaw).filter {
|
||||
!hidden.contains($0) && ($0 != .health || HealthDataStore.isAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
private var orderedActions: [Action] {
|
||||
@ -1288,6 +1301,7 @@ private struct DiarySummaryPage: View {
|
||||
tags: allTags,
|
||||
actions: dayRecordedActions,
|
||||
excludedActionIDs: $filterExcluded,
|
||||
showsHealthSection: true,
|
||||
onResetToDefault: {
|
||||
// 날짜별 재정의 삭제 → 이후 기기 기본값(기본 표시 설정)을 따른다.
|
||||
// filterExcluded는 건드리지 않아 onChange가 다시 재정의를 쓰지 않는다 (재열람 시 재초기화)
|
||||
@ -1371,6 +1385,8 @@ private struct DiarySummaryPage: View {
|
||||
recordSections(innerWidth: innerWidth)
|
||||
case .bars:
|
||||
barSections(innerWidth: innerWidth)
|
||||
case .health:
|
||||
DiaryHealthCard(dayKey: entry.dayKey)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1395,15 +1411,23 @@ private struct DiarySummaryPage: View {
|
||||
/// - 캘린더 버튼: 그날 넣을 캘린더 일정을 고르는 팝업
|
||||
@ViewBuilder
|
||||
private func timetableSections(innerWidth: CGFloat) -> some View {
|
||||
let injected = timetableSnapshot.injectingCalendarEvents(calendarBlocks)
|
||||
// 건강 블록(수면·운동 실구간)은 캘린더 블록과 같은 주입 문법 — healthHiddenRaw를
|
||||
// 명시적으로 넘겨 필터 시트 토글이 즉시 화면에 반영되게 한다 (1.5 추가분)
|
||||
let injected = timetableSnapshot
|
||||
.injectingCalendarEvents(calendarBlocks)
|
||||
.injectingHealthBlocks(DiaryHealthTimetable.blocks(
|
||||
dayKey: entry.dayKey, math: math,
|
||||
hidden: Set(healthHiddenRaw.split(separator: ",").map(String.init))
|
||||
))
|
||||
let hiddenCount = timetableExcludedIDs.count
|
||||
ForEach(Array(injected.sections.enumerated()), id: \.offset) { _, section in
|
||||
if case .timetable = section {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ExportSectionView(section: section, innerWidth: innerWidth)
|
||||
// 열람 전용에서는 날짜별 선택(필터·캘린더)을 편집할 수 없다 — 저장된 표시만 유지
|
||||
// 열람 전용에서는 날짜별 선택(필터·캘린더)을 편집할 수 없다 — 저장된 표시만 유지.
|
||||
// 필터 버튼은 항상 노출 (1.5 — 기록 없는 날도 건강 토글·맥 안내가 이 시트에 있다)
|
||||
HStack(spacing: 6) {
|
||||
if !readOnly, !dayRecordedActions.isEmpty || hiddenCount > 0 {
|
||||
if !readOnly {
|
||||
Button {
|
||||
filterExcluded = timetableExcludedIDs
|
||||
showingActionFilter = true
|
||||
@ -1727,6 +1751,11 @@ private struct DiaryGoalsCard: View {
|
||||
}
|
||||
|
||||
private func valueLabel(_ value: Double, quest: Quest) -> String {
|
||||
// 건강 다짐은 지표 고유 단위(보·kcal·km·L 등)로 — targetValueLabel과 짝.
|
||||
// ("8,432회 / 8,000보"로 단위가 어긋나던 실측 수정, 1.5(4))
|
||||
if let health = quest.healthTarget {
|
||||
return health.valueLabel(value)
|
||||
}
|
||||
switch quest.measure {
|
||||
case .time: return Format.durationShort(value)
|
||||
case .count: return String(localized: "\(Int(value))회")
|
||||
|
||||
@ -49,6 +49,20 @@ struct ExportSnapshot {
|
||||
return copy
|
||||
}
|
||||
|
||||
/// 하루짜리 타임테이블 섹션에 건강(수면·운동) 실구간 블록을 끼워 넣는다 (1.5 추가분 —
|
||||
/// 일기 화면·내보내기 공용. 캘린더 주입과 같은 문법, 렌더는 별도 스타일의 최하층)
|
||||
func injectingHealthBlocks(_ blocks: [ExportTimetableData.Block]) -> ExportSnapshot {
|
||||
guard !blocks.isEmpty else { return self }
|
||||
var copy = self
|
||||
copy.sections = sections.map { section in
|
||||
guard case .timetable(let title, let subtitle, var data) = section,
|
||||
data.days.count == 1 else { return section }
|
||||
data.days[0].healthBlocks = blocks
|
||||
return .timetable(title: title, subtitle: subtitle, data)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
/// 타임테이블 섹션만 다른 스냅숏의 것으로 교체.
|
||||
/// 일기의 날짜별 타임테이블 필터용 — 기록·통계 섹션은 전체(자신)를 유지한다.
|
||||
func replacingTimetableSections(with other: ExportSnapshot) -> ExportSnapshot {
|
||||
@ -140,6 +154,8 @@ struct ExportTimetableData {
|
||||
var markers: [Marker]
|
||||
/// 캘린더(EventKit) 일정 — 행동 블록 아래에 외곽선 스타일로 깔린다 (일기 전용)
|
||||
var eventBlocks: [Block] = []
|
||||
/// 건강(수면·운동) 실구간 — 반투명 채움 스타일로 맨 아래층에 깔린다 (일기 전용, 1.5)
|
||||
var healthBlocks: [Block] = []
|
||||
}
|
||||
|
||||
/// 하루 시작 시각(시). 축 라벨은 (startHour + 오프셋) % 24
|
||||
@ -1175,6 +1191,9 @@ private struct ExportTimetableView: View {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
ForEach(day.healthBlocks) { block in
|
||||
healthBlockView(block, columnWidth: columnWidth)
|
||||
}
|
||||
ForEach(day.eventBlocks) { block in
|
||||
eventBlockView(block, columnWidth: columnWidth)
|
||||
}
|
||||
@ -1239,6 +1258,36 @@ private struct ExportTimetableView: View {
|
||||
.offset(x: 2, y: y)
|
||||
}
|
||||
|
||||
/// 건강(수면·운동) 블록: 세 번째 시각 문법 — 반투명 채움 + 가는 실선 테두리.
|
||||
/// 행동 블록(불투명 채움)·캘린더 블록(점선 테두리)과 구분되고, 잠처럼 긴 구간이
|
||||
/// 배경을 덮어도 위층 블록이 잘 보이도록 농도를 가장 옅게 유지한다 (1.5 추가분)
|
||||
private func healthBlockView(_ block: ExportTimetableData.Block, columnWidth: CGFloat) -> some View {
|
||||
let y = CGFloat(block.startFrac - Double(data.hourLo)) * hourHeight
|
||||
let height = max(CGFloat(block.endFrac - block.startFrac) * hourHeight, 5)
|
||||
return RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(block.color.opacity(0.16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.strokeBorder(block.color.opacity(0.55), lineWidth: 1)
|
||||
)
|
||||
.overlay(alignment: .topLeading) {
|
||||
if !weekly && height >= 18 {
|
||||
Label(block.name, systemImage: block.symbol)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(block.color)
|
||||
.lineLimit(1)
|
||||
.padding(4)
|
||||
} else if height >= 12 {
|
||||
Image(safeSymbol: block.symbol)
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundStyle(block.color)
|
||||
.padding(2)
|
||||
}
|
||||
}
|
||||
.frame(width: max(columnWidth - 4, 8), height: height)
|
||||
.offset(x: 2, y: y)
|
||||
}
|
||||
|
||||
private func markerView(_ marker: ExportTimetableData.Marker, index: Int, columnWidth: CGFloat) -> some View {
|
||||
let size: CGFloat = weekly ? 8 : 14
|
||||
let x = 4 + CGFloat(index % 5) * (size + 3)
|
||||
|
||||
@ -33,7 +33,7 @@ struct HealthSectionView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingMetricPicker) {
|
||||
HealthMetricPickerSheet()
|
||||
HealthMetricPickerSheet(metricsRaw: $metricsRaw)
|
||||
}
|
||||
.sheet(isPresented: $showingGuide) {
|
||||
HealthGuideSheet()
|
||||
@ -161,12 +161,14 @@ struct HealthSectionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 지표 선택 시트 (체크 + 드래그 순서)
|
||||
// MARK: - 지표 선택 시트 (체크 + 드래그 순서 — 모음 탭 타일·일기 건강 카드 공용)
|
||||
|
||||
struct HealthMetricPickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults)
|
||||
private var metricsRaw = ""
|
||||
/// 저장 대상 raw 문자열 (모음 탭=AppGroup healthMetrics, 일기 카드=standard diary.healthMetrics)
|
||||
@Binding var metricsRaw: String
|
||||
/// metricsRaw가 비어 있을 때의 초기 선택 폴백 (일기 카드가 타일 선택을 물려받는 용도)
|
||||
var fallbackRaw: String = ""
|
||||
|
||||
/// 편집 목록: 선택된 지표(순서 유지) + 나머지 지표
|
||||
@State private var selected: [HealthMetric] = []
|
||||
@ -209,7 +211,7 @@ struct HealthMetricPickerSheet: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
selected = HealthMetric.selectedList(raw: metricsRaw)
|
||||
selected = HealthMetric.selectedList(raw: metricsRaw.isEmpty ? fallbackRaw : metricsRaw)
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
@ -352,3 +354,290 @@ struct HealthGuideSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 일기 타임테이블 건강 블록 (1.5 추가분 — 수면·운동 실구간, HealthIntervals 소비)
|
||||
|
||||
/// 일기 하루 정리·내보내기의 타임테이블에 얹는 수면·운동 블록의 단일 공급 지점.
|
||||
/// 색은 꼬리표 팔레트·'측정 중' 노랑과 겹치지 않는 지표 고정색 — 수면 인디고, 운동 주황.
|
||||
enum DiaryHealthTimetable {
|
||||
/// 숨김 저장 키 (standard defaults — 기기 로컬, 모든 날짜 공통. "sleep"/"workout" 쉼표 연결)
|
||||
static let hiddenKey = "diary.timetableHealthHidden"
|
||||
static let sleepColor = Color.indigo
|
||||
static let workoutColor = Color.orange
|
||||
|
||||
static var hidden: Set<String> {
|
||||
Set((UserDefaults.standard.string(forKey: hiddenKey) ?? "")
|
||||
.split(separator: ",").map(String.init))
|
||||
}
|
||||
|
||||
static func setHidden(_ set: Set<String>) {
|
||||
UserDefaults.standard.set(set.sorted().joined(separator: ","), forKey: hiddenKey)
|
||||
}
|
||||
|
||||
/// 그날 타임테이블에 얹을 건강 블록 — 화면(DiarySummaryPage)과 내보내기(DiaryExportRenderer)가
|
||||
/// 같은 호출로 항상 같은 결과를 얻는다. 데이터 없는 기기(맥·미동기화 아이패드)는 자연히 빈 배열.
|
||||
/// hidden은 화면이 @AppStorage 값을 넘겨 토글 즉시 재렌더되게 하는 용도 (기본=저장값)
|
||||
static func blocks(dayKey: Date, math: DayMath,
|
||||
hidden: Set<String>? = nil) -> [ExportTimetableData.Block] {
|
||||
guard HealthDataStore.isAvailable else { return [] }
|
||||
let hiddenSet = hidden ?? Self.hidden
|
||||
let dayStart = math.dayRange(forKey: dayKey).lowerBound
|
||||
func frac(_ date: Date) -> Double { date.timeIntervalSince(dayStart) / 3600 }
|
||||
var blocks: [ExportTimetableData.Block] = []
|
||||
if !hiddenSet.contains("sleep") {
|
||||
for interval in HealthIntervals.intervals(HealthIntervals.sleepKey, dayKey: dayKey) {
|
||||
blocks.append(ExportTimetableData.Block(
|
||||
startFrac: frac(interval.start), endFrac: frac(interval.end),
|
||||
color: sleepColor, symbol: HealthMetric.sleep.symbolName,
|
||||
name: HealthMetric.sleep.name
|
||||
))
|
||||
}
|
||||
}
|
||||
if !hiddenSet.contains("workout") {
|
||||
for key in HealthIntervals.workoutKeys(dayKey: dayKey).sorted() {
|
||||
guard let kind = HealthWorkoutKind(rawValue: String(key.dropFirst("workout.".count)))
|
||||
else { continue }
|
||||
for interval in HealthIntervals.intervals(key, dayKey: dayKey) {
|
||||
blocks.append(ExportTimetableData.Block(
|
||||
startFrac: frac(interval.start), endFrac: frac(interval.end),
|
||||
color: workoutColor, symbol: kind.symbolName, name: kind.name
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 일기 '건강 데이터' 카드 (1.5 추가분 — 하루 정리 섹션, 아이폰·아이패드 전용)
|
||||
|
||||
/// 그 날짜의 건강 지표 값 카드. 값은 HealthCache(기기 로컬)라 기기마다 다를 수 있고(§안내 시트),
|
||||
/// 수면은 설정의 수면 표시 구간(SleepWindowPrefs — 요일별 포함)을 따른다.
|
||||
struct DiaryHealthCard: View {
|
||||
let dayKey: Date
|
||||
|
||||
@Environment(\.diaryReadOnly) private var readOnly
|
||||
/// 카드 표시 지표 — 기기 로컬·전 날짜 공통. 비어 있으면 모음 탭 타일 선택을 물려받는다
|
||||
@AppStorage("diary.healthMetrics") private var diaryMetricsRaw = ""
|
||||
@AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults) private var tileMetricsRaw = ""
|
||||
@State private var store = HealthDataStore.shared
|
||||
@State private var showingPicker = false
|
||||
|
||||
private var metrics: [HealthMetric] {
|
||||
HealthMetric.selectedList(raw: diaryMetricsRaw.isEmpty ? tileMetricsRaw : diaryMetricsRaw)
|
||||
}
|
||||
|
||||
private func value(_ metric: HealthMetric) -> Double? {
|
||||
let key = metric == .sleep
|
||||
? "sleep@\(SleepWindowPrefs.spec(forDayKey: dayKey))"
|
||||
: metric.rawValue
|
||||
return HealthCache.value(key, dayKey: dayKey)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.pink)
|
||||
Text("건강 데이터")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer(minLength: 0)
|
||||
if !readOnly, store.hasRequestedAuth {
|
||||
Button {
|
||||
showingPicker = true
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(AppTheme.green.opacity(0.12), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("표시할 지표 선택"))
|
||||
}
|
||||
}
|
||||
if store.hasRequestedAuth {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8) {
|
||||
ForEach(metrics) { metric in
|
||||
chip(metric)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("모음 탭에서 애플 건강을 연결하면 이 날의 걸음·운동·수면 데이터가 여기 보여요.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.sheet(isPresented: $showingPicker) {
|
||||
HealthMetricPickerSheet(metricsRaw: $diaryMetricsRaw, fallbackRaw: tileMetricsRaw)
|
||||
}
|
||||
.task { await store.refreshToday() }
|
||||
}
|
||||
|
||||
private func chip(_ metric: HealthMetric) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 5) {
|
||||
Image(safeSymbol: metric.symbolName)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.pink)
|
||||
Text(metric.name)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let value = value(metric) {
|
||||
// ja "7時間28分"처럼 긴 값이 칩 폭에서 말줄임되지 않게 살짝 축소 허용 (1.5(4) 실측)
|
||||
Text(metric.valueLabel(value))
|
||||
.font(.system(.subheadline, design: .rounded).weight(.bold).monospacedDigit())
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
} else {
|
||||
Text(verbatim: "–")
|
||||
.font(.system(.subheadline, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text(verbatim: "\(metric.name) \(value(metric).map(metric.valueLabel) ?? String(localized: "기록 없음"))"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 수면 표시 구간 설정 (1.5 추가분 — 설정 탭, SleepWindowPrefs)
|
||||
|
||||
/// 모음 탭 타일·일기 건강 카드의 수면 창 설정. 교대·야간 근무처럼 낮에 자는 패턴은
|
||||
/// 전체 구간을 넓히거나(시작=끝이면 24시간) 요일별 구간으로 맞춘다. 다짐의 수면 구간은
|
||||
/// 다짐별 저장이라 그대로 두되, 새 수면 다짐의 기본값은 여기 전체 구간을 따른다.
|
||||
struct SleepWindowSettingsView: View {
|
||||
@AppStorage(LocalPrefsKeys.sleepWindowGlobal, store: AppGroup.defaults)
|
||||
private var globalRaw = ""
|
||||
@AppStorage(LocalPrefsKeys.sleepWindowWeekdayEnabled, store: AppGroup.defaults)
|
||||
private var weekdayEnabled = false
|
||||
/// 요일별 스펙 사본 — 변경 즉시 defaults에 반영
|
||||
@State private var weekdaySpecs: [String: String] = SleepWindowPrefs.weekdaySpecs
|
||||
|
||||
/// 표시 순서: 월~일 (한국 관습 — 주 시작 요일 설정과 무관한 목록 순서일 뿐)
|
||||
private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
SleepWindowEditorRows(spec: Binding(
|
||||
get: { globalRaw.isEmpty ? SleepWindowPrefs.defaultSpec : globalRaw },
|
||||
set: { globalRaw = $0 }
|
||||
))
|
||||
} header: {
|
||||
Text("전체 구간")
|
||||
} footer: {
|
||||
Text("이 구간 안에서 잔 시간이 '구간이 끝나는 날'의 수면으로 집계돼요. 기본은 저녁 9시부터 다음 날 아침 9시까지예요. 시작과 끝을 같은 시각으로 두면 하루 전체(24시간)를 봐요.")
|
||||
}
|
||||
Section {
|
||||
Toggle(isOn: $weekdayEnabled.animation()) {
|
||||
Text("요일마다 다르게")
|
||||
}
|
||||
.tint(AppTheme.green)
|
||||
if weekdayEnabled {
|
||||
ForEach(weekdayOrder, id: \.self) { weekday in
|
||||
weekdayGroup(weekday)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("야간 근무 등으로 특정 요일엔 낮에 잔다면, 그 요일만 구간을 따로 정할 수 있어요. 정하지 않은 요일은 전체 구간을 따라요.\n이 설정은 모음 탭 타일과 일기의 건강 카드에 적용돼요. 다짐의 수면 구간은 다짐 편집에서 따로 정해요 — 새로 만드는 수면 다짐은 여기의 전체 구간으로 시작해요.")
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppTheme.background)
|
||||
.navigationTitle("수면 표시 구간")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func weekdayGroup(_ weekday: Int) -> some View {
|
||||
let binding = Binding<String>(
|
||||
get: {
|
||||
let raw = weekdaySpecs["\(weekday)"] ?? ""
|
||||
return raw.isEmpty ? (globalRaw.isEmpty ? SleepWindowPrefs.defaultSpec : globalRaw) : raw
|
||||
},
|
||||
set: { newValue in
|
||||
weekdaySpecs["\(weekday)"] = newValue
|
||||
AppGroup.defaults.set(weekdaySpecs, forKey: LocalPrefsKeys.sleepWindowByWeekday)
|
||||
}
|
||||
)
|
||||
DisclosureGroup {
|
||||
SleepWindowEditorRows(spec: binding)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("\(Format.weekdayShort(weekday))요일")
|
||||
Spacer()
|
||||
Text(Self.summary(binding.wrappedValue))
|
||||
.font(.caption)
|
||||
.foregroundStyle(weekdaySpecs["\(weekday)"].map { $0.isEmpty } ?? true ? .secondary : AppTheme.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "1260-540" → "21:00 ~ 다음 날 09:00" 요약
|
||||
static func summary(_ spec: String) -> String {
|
||||
let parts = spec.split(separator: "-").compactMap { Int(String($0)) }
|
||||
guard parts.count == 2 else { return "" }
|
||||
func hhmm(_ minutes: Int) -> String {
|
||||
String(format: "%02d:%02d", minutes / 60, minutes % 60)
|
||||
}
|
||||
if parts[0] == parts[1] {
|
||||
return String(localized: "\(hhmm(parts[1]))에 끝나는 24시간")
|
||||
}
|
||||
return parts[0] > parts[1]
|
||||
? String(localized: "\(hhmm(parts[0])) ~ 다음 날 \(hhmm(parts[1]))")
|
||||
: String(localized: "\(hhmm(parts[0])) ~ \(hhmm(parts[1]))")
|
||||
}
|
||||
}
|
||||
|
||||
/// 수면 구간 스펙("시작분-끝분") 하나를 편집하는 시작·끝 휠 두 줄
|
||||
private struct SleepWindowEditorRows: View {
|
||||
@Binding var spec: String
|
||||
|
||||
private func minutes(_ index: Int) -> Int {
|
||||
let parts = spec.split(separator: "-").compactMap { Int(String($0)) }
|
||||
guard parts.count == 2 else { return index == 0 ? 1260 : 540 }
|
||||
return parts[index]
|
||||
}
|
||||
|
||||
private func dateBinding(_ index: Int) -> Binding<Date> {
|
||||
Binding(
|
||||
get: {
|
||||
Calendar.current.date(
|
||||
byAdding: .minute, value: minutes(index),
|
||||
to: Calendar.current.startOfDay(for: .now)
|
||||
) ?? .now
|
||||
},
|
||||
set: { newValue in
|
||||
let comps = Calendar.current.dateComponents([.hour, .minute], from: newValue)
|
||||
let value = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
||||
let start = index == 0 ? value : minutes(0)
|
||||
let end = index == 1 ? value : minutes(1)
|
||||
spec = "\(start)-\(end)"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
CollapsibleTimeWheel(
|
||||
label: String(localized: "구간 시작"),
|
||||
selection: dateBinding(0),
|
||||
components: .hourAndMinute
|
||||
)
|
||||
CollapsibleTimeWheel(
|
||||
label: String(localized: "구간 끝"),
|
||||
selection: dateBinding(1),
|
||||
components: .hourAndMinute
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,9 +58,9 @@ struct HelpView: View {
|
||||
result.append(health)
|
||||
}
|
||||
result.append(sync)
|
||||
if DeviceLayout.isPad {
|
||||
result.append(diary)
|
||||
} else {
|
||||
// 일기는 1.5 추가분부터 전 기기 (아이폰 포함 — §6.7)
|
||||
result.append(diary)
|
||||
if !DeviceLayout.isPad {
|
||||
result.append(iphoneUI)
|
||||
result.append(watch)
|
||||
}
|
||||
@ -302,7 +302,17 @@ struct HelpView: View {
|
||||
HelpTopic(
|
||||
symbol: "heart.fill",
|
||||
title: "모음 탭에서 건강 데이터 보기",
|
||||
body: "모음 탭의 '건강 데이터' 줄에서 '애플 건강 연결하기'를 누르면 걸음 수·운동 시간·수면 같은 오늘 데이터를 타일로 볼 수 있어요. 어떤 지표를 어떤 순서로 보여줄지는 줄 오른쪽의 지표 버튼에서 고르고, 설정 → '모음 탭 건강 데이터'에서 줄 자체를 끄거나 즐겨찾기·나머지 행동과의 순서를 바꿀 수 있어요.\n값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요 — 하루 다님이 서버로 보내거나 iCloud에 저장하는 일은 없어요. 걸음·운동 같은 값은 설정의 '하루 시작 시간' 기준으로 오늘 하루를 더한 것이고, 수면만은 전날 밤부터 오늘 아침까지의 수면 구간을 통째로 '오늘 잔 것'으로 세요. 애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보이니 값이 안 맞아 보이면 잠시 뒤 다시 확인해 주세요. 값이 계속 0이라면 아이폰의 설정 → 개인정보 보호 및 보안 → 건강에서 하루 다님의 읽기 권한을 확인해 주세요."
|
||||
body: "모음 탭의 '건강 데이터' 줄에서 '애플 건강 연결하기'를 누르면 걸음 수·운동 시간·수면 같은 오늘 데이터를 타일로 볼 수 있어요. 어떤 지표를 어떤 순서로 보여줄지는 줄 오른쪽의 지표 버튼에서 고르고, 설정 → '건강 데이터'에서 줄 자체를 끄거나 즐겨찾기·나머지 행동과의 순서를 바꿀 수 있어요.\n값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요 — 하루 다님이 서버로 보내거나 iCloud에 저장하는 일은 없어요. 걸음·운동 같은 값은 설정의 '하루 시작 시간' 기준으로 오늘 하루를 더한 것이고, 수면만은 정해 둔 수면 구간(기본 저녁 9시~다음 날 아침 9시)에서 잔 시간을 통째로 '그날 잔 것'으로 세요. 애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보이니 값이 안 맞아 보이면 잠시 뒤 다시 확인해 주세요. 값이 계속 0이라면 아이폰의 설정 → 개인정보 보호 및 보안 → 건강에서 하루 다님의 읽기 권한을 확인해 주세요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "bed.double.fill",
|
||||
title: "수면 표시 구간 맞추기",
|
||||
body: "타일의 수면이 실제와 다르게 0으로 보인다면, 그 잠이 수면 구간 밖에 있었을 가능성이 커요 — 기본 구간(저녁 9시~다음 날 아침 9시)은 아침 9시 이후에 잔 잠을 세지 않거든요. 설정 → 건강 데이터 → '수면 표시 구간'에서 구간을 바꿔 보세요. 시작과 끝을 같은 시각으로 두면 그 시각에 끝나는 하루 전체(24시간)를 보고, '요일마다 다르게'를 켜면 야간 근무처럼 특정 요일만 낮에 자는 패턴도 요일별 구간으로 맞출 수 있어요.\n이 설정은 모음 탭 타일과 일기의 건강 카드에 적용돼요. 다짐의 수면 구간은 다짐 편집에서 따로 정하고, 새로 만드는 수면 다짐은 여기의 전체 구간으로 시작해요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "heart.text.square.fill",
|
||||
title: "일기에서 건강 데이터 보기",
|
||||
body: "일기의 하루 정리 첫 페이지에 '건강 데이터' 카드가 있어요 — 그날의 걸음·운동·수면 같은 값을 일기와 함께 봐요. 카드 오른쪽 위 버튼으로 보여줄 지표를 고르면 모든 날짜에 함께 적용돼요. 타임테이블에는 수면·운동이 실제 시간 그대로 옅은 블록으로 깔려요(수면 남보라·운동 주황) — 타임테이블 필터의 '건강 데이터' 토글로 켜고 끌 수 있어요.\n건강 값은 기기 안에만 있어서 카드의 값도 기기마다 다를 수 있어요 — 아이패드는 시스템의 건강 iCloud 동기화를 켠 경우에만 아이폰과 같은 값이 보이고, 맥에서는 카드와 블록이 보이지 않아요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "figure.run",
|
||||
@ -329,25 +339,25 @@ struct HelpView: View {
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: iPad 전용 — 일기
|
||||
// MARK: 공통 — 일기 (1.5 추가분부터 아이폰 포함 전 기기)
|
||||
|
||||
private var diary: HelpGroup {
|
||||
HelpGroup(title: "일기 (아이패드·맥 · 프리미엄)", topics: [
|
||||
HelpGroup(title: "일기 (프리미엄)", topics: [
|
||||
HelpTopic(
|
||||
symbol: "book.closed.fill",
|
||||
title: "하루 정리 첫 페이지",
|
||||
body: "일기 탭 달력에서 날짜를 누르면 그날의 일기가 열려요. 첫 페이지에는 오늘 기분·요약·목표 진행 현황·할 일·24시간 타임테이블·기록이 모여 하루가 한눈에 정리돼요. 오른쪽 위 ⋯ 메뉴의 '첫 화면 구성'에서 섹션을 보일지 말지와 순서를 바꿀 수 있어요. 기분 사진을 남긴 날은 달력의 그 칸에 사진이 배경으로 깔려요. 일기는 아이패드와 맥에서 쓸 수 있고, iCloud 동기화를 켜면 두 기기에서 같은 일기가 이어져요.",
|
||||
body: "일기 탭 달력에서 날짜를 누르면 그날의 일기가 열려요. 첫 페이지에는 오늘 기분·요약·목표 진행 현황·할 일·24시간 타임테이블·기록·건강 데이터가 모여 하루가 한눈에 정리돼요. 오른쪽 위 ⋯ 메뉴의 '첫 화면 구성'에서 섹션을 보일지 말지와 순서를 바꿀 수 있어요. 기분 사진을 남긴 날은 달력의 그 칸에 사진이 배경으로 깔려요. 일기는 아이폰·아이패드·맥에서 쓸 수 있고, iCloud 동기화를 켜면 어느 기기에서든 같은 일기가 이어져요.",
|
||||
imageName: "help-diary"
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "calendar.badge.plus",
|
||||
title: "타임테이블에 일정·필터",
|
||||
body: "첫 페이지 타임테이블 카드의 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 보여줄 수 있어요 — 일정은 점선 테두리라 행동과 바로 구분돼요. 필터 버튼으로는 그날 기록된 행동 중 타임테이블에 올릴 것만 고르고, '이 날의 목표' 카드의 필터로는 보고 싶은 목표만 남겨요. 이 선택들은 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 담겨요.\n매일 같은 선택을 되풀이하기 싫다면 달력 화면 오른쪽 위의 '기본 표시 설정'을 써 보세요. 캘린더 일정을 기본으로 넣을지(모든 캘린더/선택한 캘린더만), 어떤 목표·행동을 기본으로 숨길지 한 번만 정하면 모든 날짜에 적용돼요. 날짜별 필터로 다르게 고른 날은 그 선택이 우선하고, '기본값으로 되돌리기'로 언제든 돌아갈 수 있어요."
|
||||
body: "첫 페이지 타임테이블 카드의 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 보여줄 수 있어요 — 일정은 점선 테두리라 행동과 바로 구분돼요. 필터 버튼으로는 그날 기록된 행동 중 타임테이블에 올릴 것만 고르고, '이 날의 목표' 카드의 필터로는 보고 싶은 목표만 남겨요. 이 선택들은 날짜마다 따로 저장되고, 타임테이블 선택은 일기를 내보낼 때도 그대로 담겨요.\n애플 건강의 수면·운동 시간도 실제 잔 시간·운동한 시간 그대로 옅은 블록으로 깔려요 — 수면은 남보라, 운동은 주황이에요. 필터의 '건강 데이터' 토글로 켜고 끌 수 있고(모든 날짜 공통), 건강 데이터가 없는 맥에서는 보이지 않아요.\n매일 같은 선택을 되풀이하기 싫다면 달력 화면 오른쪽 위의 '기본 표시 설정'을 써 보세요. 캘린더 일정을 기본으로 넣을지(모든 캘린더/선택한 캘린더만), 어떤 목표·행동을 기본으로 숨길지 한 번만 정하면 모든 날짜에 적용돼요. 날짜별 필터로 다르게 고른 날은 그 선택이 우선하고, '기본값으로 되돌리기'로 언제든 돌아갈 수 있어요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "applepencil",
|
||||
title: "펜슬 노트 페이지",
|
||||
body: "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 마음껏 쓰고 그리세요 — 손가락은 페이지 넘기기와 확대·축소를 맡아요. 두 손가락으로 벌려 확대하면 작은 글씨도 또박또박 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 차례를 바꿔요. 맥에서는 키보드 입력이 기본이고, 도구 줄의 '그리기'를 켠 동안에만 마우스·트랙패드로 그려져요 — 평소의 클릭과 스크롤은 이동과 버튼에 쓰여요."
|
||||
body: "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 마음껏 쓰고 그리세요 — 손가락은 페이지 넘기기와 확대·축소를 맡아요. 두 손가락으로 벌려 확대하면 작은 글씨도 또박또박 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 차례를 바꿔요.\n아이폰에서는 도구 줄의 '그리기'를 켠 동안 손가락으로 그려지고, 끄면 손가락이 다시 페이지 넘기기·확대에 쓰여요. 맥에서는 키보드 입력이 기본이고, 마찬가지로 '그리기'를 켠 동안에만 마우스·트랙패드로 그려져요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "square.on.circle",
|
||||
@ -440,7 +450,7 @@ struct HelpView: View {
|
||||
HelpTopic(
|
||||
symbol: "crown.fill",
|
||||
title: "무료와 프리미엄의 차이",
|
||||
body: "무료로는 행동 10개·목표 2개·목표당 다짐 3개까지 만들 수 있어요. 프리미엄에서는 개수 제한이 풀리고 위젯, 애플워치 앱, 시리 단축어, 기기 간 동기화, 아이패드·맥 일기, CSV 가져오기, 건강 다짐을 쓸 수 있어요. 월·년 구독이나 한 번만 결제하는 평생 이용권 중에 고르면 돼요. 구독이 끝나도 기록한 데이터는 그대로 남고, 이미 만든 건강 다짐도 계속 보이고, 쓴 일기도 계속 열람할 수 있어요."
|
||||
body: "무료로는 행동 10개·목표 2개·목표당 다짐 3개까지 만들 수 있어요. 프리미엄에서는 개수 제한이 풀리고 위젯, 애플워치 앱, 시리 단축어, 기기 간 동기화, 일기(아이폰·아이패드·맥), CSV 가져오기, 건강 다짐을 쓸 수 있어요. 월·년 구독이나 한 번만 결제하는 평생 이용권 중에 고르면 돼요. 구독이 끝나도 기록한 데이터는 그대로 남고, 이미 만든 건강 다짐도 계속 보이고, 쓴 일기도 계속 열람할 수 있어요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "mic.fill",
|
||||
|
||||
@ -46,13 +46,23 @@ struct QuestEditorView: View {
|
||||
/// 지표 선택 raw — HealthMetric raw 또는 "workout"(종목별 운동)
|
||||
@State private var healthChoiceRaw: String = HealthMetric.steps.rawValue
|
||||
@State private var healthWorkoutKind: HealthWorkoutKind = .running
|
||||
/// 수면 구간 (수면 지표 전용 — 구간 끝이 속한 논리적 하루에 통귀속)
|
||||
@State private var sleepStart: Date = Calendar.current.date(
|
||||
bySettingHour: 21, minute: 0, second: 0, of: .now
|
||||
) ?? .now
|
||||
@State private var sleepEnd: Date = Calendar.current.date(
|
||||
bySettingHour: 9, minute: 0, second: 0, of: .now
|
||||
) ?? .now
|
||||
/// 수면 구간 (수면 지표 전용 — 구간 끝이 속한 논리적 하루에 통귀속).
|
||||
/// 새 다짐의 기본값은 설정의 '수면 표시 구간' 전체 값을 따른다 (1.5 추가분 —
|
||||
/// 기존 다짐은 load()가 저장값으로 덮어써 무영향)
|
||||
@State private var sleepStart: Date = {
|
||||
let minutes = SleepWindowPrefs.globalSpec.split(separator: "-").compactMap { Int(String($0)) }
|
||||
return Calendar.current.date(
|
||||
byAdding: .minute, value: minutes.first ?? 1260,
|
||||
to: Calendar.current.startOfDay(for: .now)
|
||||
) ?? .now
|
||||
}()
|
||||
@State private var sleepEnd: Date = {
|
||||
let minutes = SleepWindowPrefs.globalSpec.split(separator: "-").compactMap { Int(String($0)) }
|
||||
return Calendar.current.date(
|
||||
byAdding: .minute, value: minutes.count == 2 ? minutes[1] : 540,
|
||||
to: Calendar.current.startOfDay(for: .now)
|
||||
) ?? .now
|
||||
}()
|
||||
/// 수량형 건강 목표 입력값 (지표별 기본 단위 — 걸음 보 / 에너지 kcal / 스탠드 시간)
|
||||
@State private var healthIntTarget = 8000
|
||||
/// 거리 목표 (km — 저장은 m)
|
||||
|
||||
@ -17,10 +17,27 @@ struct RecordFilterSheet: View {
|
||||
let tags: [Tag]
|
||||
let actions: [Action]
|
||||
@Binding var excludedActionIDs: Set<PersistentIdentifier>
|
||||
/// 일기 타임테이블 필터 전용(1.5): 수면·운동 블록 표시 토글 섹션.
|
||||
/// 맥(건강 미지원)에서는 토글 대신 안내 문구가 나온다. 기록·통계 탭은 false — 기존 화면 무영향
|
||||
var showsHealthSection: Bool = false
|
||||
/// 일기 타임테이블 필터 전용: 날짜별 재정의를 지우고 기본 표시 설정으로 되돌린다.
|
||||
/// nil이면(기록·통계 탭) 해당 섹션이 아예 그려지지 않는다 — 기존 화면 무영향
|
||||
var onResetToDefault: (() -> Void)? = nil
|
||||
|
||||
/// 건강 블록 숨김("sleep"/"workout" 쉼표 연결) — 기기 로컬, 모든 날짜 공통
|
||||
@AppStorage(DiaryHealthTimetable.hiddenKey) private var healthHiddenRaw = ""
|
||||
|
||||
private func healthVisibleBinding(_ key: String) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { !healthHiddenRaw.split(separator: ",").map(String.init).contains(key) },
|
||||
set: { visible in
|
||||
var hidden = Set(healthHiddenRaw.split(separator: ",").map(String.init))
|
||||
if visible { hidden.remove(key) } else { hidden.insert(key) }
|
||||
healthHiddenRaw = hidden.sorted().joined(separator: ",")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var allActionIDs: Set<PersistentIdentifier> {
|
||||
Set(actions.map(\.persistentModelID))
|
||||
}
|
||||
@ -60,6 +77,45 @@ struct RecordFilterSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if showsHealthSection {
|
||||
Section {
|
||||
if HealthDataStore.isAvailable {
|
||||
Toggle(isOn: healthVisibleBinding("sleep")) {
|
||||
Label {
|
||||
Text("수면")
|
||||
} icon: {
|
||||
Image(systemName: HealthMetric.sleep.symbolName)
|
||||
.foregroundStyle(DiaryHealthTimetable.sleepColor)
|
||||
}
|
||||
}
|
||||
.tint(AppTheme.green)
|
||||
Toggle(isOn: healthVisibleBinding("workout")) {
|
||||
Label {
|
||||
Text("운동")
|
||||
} icon: {
|
||||
Image(systemName: "figure.run")
|
||||
.foregroundStyle(DiaryHealthTimetable.workoutColor)
|
||||
}
|
||||
}
|
||||
.tint(AppTheme.green)
|
||||
} else {
|
||||
Label {
|
||||
Text("맥에서는 애플 건강 데이터를 지원하지 않아 수면·운동을 타임테이블에 표시할 수 없어요. 아이폰·아이패드에서 확인해 주세요.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} icon: {
|
||||
Image(systemName: "heart.slash")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("건강 데이터")
|
||||
} footer: {
|
||||
if HealthDataStore.isAvailable {
|
||||
Text("애플 건강의 수면·운동 시간을 실제 구간 그대로 타임테이블에 보여줘요. 이 설정은 모든 날짜에 함께 적용돼요.")
|
||||
}
|
||||
}
|
||||
}
|
||||
if let onResetToDefault {
|
||||
Section {
|
||||
Button("기본값으로 되돌리기") {
|
||||
|
||||
@ -199,10 +199,23 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1.5 추가분: 수면 표시 구간 (타일·일기 건강 카드 공용 — 교대·야간 근무 대응)
|
||||
NavigationLink {
|
||||
SleepWindowSettingsView()
|
||||
} label: {
|
||||
HStack {
|
||||
Text("수면 표시 구간")
|
||||
Spacer()
|
||||
Text(SleepWindowSettingsView.summary(SleepWindowPrefs.globalSpec))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("모음 탭 건강 데이터")
|
||||
Text("건강 데이터")
|
||||
} footer: {
|
||||
Text("켜면 애플 건강 앱의 오늘 데이터(걸음·운동·수면 등)를 모음 탭에서 타일로 볼 수 있어요. 섹션 표시 순서에서 즐겨찾기·나머지 행동·건강 데이터의 배치를 정할 수 있어요.")
|
||||
Text("켜면 애플 건강 앱의 오늘 데이터(걸음·운동·수면 등)를 모음 탭에서 타일로 볼 수 있어요. 섹션 표시 순서에서 즐겨찾기·나머지 행동·건강 데이터의 배치를 정할 수 있어요. 수면 표시 구간은 어느 시간대의 잠을 '그날 잔 것'으로 볼지 정해요 — 밤낮이 바뀌는 날이 있어도 맞출 수 있어요.")
|
||||
}
|
||||
.id("healthSection")
|
||||
}
|
||||
@ -261,22 +274,20 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("여러 행동을 동시에 추적 중일 때 다이나믹 아일랜드·잠금화면과 워치 컴플리케이션에 대표로 표시할 행동이에요.")
|
||||
}
|
||||
// 일기는 아이패드·맥에서 사용(§6.7) — 잠금 설정도 그 기기에서만 의미가 있다
|
||||
if DeviceLayout.isPad {
|
||||
Section {
|
||||
Toggle(isOn: diaryLockBinding) {
|
||||
Label {
|
||||
Text("일기 잠금")
|
||||
} icon: {
|
||||
Image(systemName: "lock.fill")
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
// 일기는 1.5 추가분부터 전 기기 사용(§6.7) — 잠금 설정도 전 기기에 노출
|
||||
Section {
|
||||
Toggle(isOn: diaryLockBinding) {
|
||||
Label {
|
||||
Text("일기 잠금")
|
||||
} icon: {
|
||||
Image(systemName: "lock.fill")
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
} header: {
|
||||
Text("일기")
|
||||
} footer: {
|
||||
Text("켜면 일기 탭을 열 때 Face ID·Touch ID 또는 기기 암호로 잠금을 해제해야 해요. 기기가 지원하는 방식이 자동으로 쓰이고, 앱을 나갔다 오면 다시 잠겨요. 이 설정은 이 기기에만 적용돼요.")
|
||||
}
|
||||
} header: {
|
||||
Text("일기")
|
||||
} footer: {
|
||||
Text("켜면 일기 탭을 열 때 Face ID·Touch ID 또는 기기 암호로 잠금을 해제해야 해요. 기기가 지원하는 방식이 자동으로 쓰이고, 앱을 나갔다 오면 다시 잠겨요. 이 설정은 이 기기에만 적용돼요.")
|
||||
}
|
||||
Section("프리미엄") {
|
||||
NavigationLink {
|
||||
@ -521,7 +532,7 @@ struct PremiumView: View {
|
||||
featureRow("switch.2", "제어 센터에서 실행", "제어 센터·잠금화면 버튼·액션 버튼에서 행동을 바로 실행해요.")
|
||||
featureRow("icloud.fill", "기기 간 동기화", "iCloud로 아이폰·아이패드·맥의 데이터가 동기화돼요.")
|
||||
featureRow("applewatch", "애플워치 앱", "워치에서 추적하고 컴플리케이션으로 확인해요.")
|
||||
featureRow("book.closed.fill", "아이패드·맥 일기", "하루 정리와 애플 펜슬 다이어리 — 맥에서는 마우스·키보드로 써요.")
|
||||
featureRow("book.closed.fill", "일기 (아이폰·아이패드·맥)", "하루 정리와 다이어리 — 아이패드는 애플 펜슬, 아이폰은 손가락 그리기, 맥은 마우스·키보드로 써요.")
|
||||
featureRow("square.and.arrow.down", "CSV 가져오기", "내보냈던 CSV에서 행동과 기록을 복원해요.")
|
||||
featureRow("mic.fill", "시리 단축어", "\"시리야, 하루 다님에서 측정 시작\" — 말과 단축어 자동화로 기록해요.")
|
||||
} header: {
|
||||
|
||||
@ -15,6 +15,8 @@ import UIKit
|
||||
/// 크기 클래스 대신 기기 종류로 판정해 iPhone(가로 모드 포함)은 항상 기존 UI를 유지한다.
|
||||
enum DeviceLayout {
|
||||
static let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
/// 아이폰 여부 (1.5 추가분 — 일기 노트의 '그리기' 토글·손가락 필기 분기용)
|
||||
static let isPhone = UIDevice.current.userInterfaceIdiom == .phone
|
||||
/// 맥에서 실행 중인 아이패드 앱(Designed for iPad) 여부.
|
||||
/// 아이패드 포인트가 맥 화면에 축소 매핑돼 전반적으로 작게 보이므로,
|
||||
/// 맥에서만 글자 크기·모음 탭 카드/버튼 폭을 키운다 (아이폰·아이패드 무영향)
|
||||
|
||||
@ -76,6 +76,18 @@ struct WhatsNewView: View {
|
||||
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,
|
||||
|
||||
@ -29,7 +29,7 @@ A habit and time tracker: log time and counts with a single tap, and shape your
|
||||
• Apple Watch app and complications — track from your wrist, with an Action Log complication showing totals on your watch face
|
||||
• Siri Shortcuts — "Hey Siri, start tracking Reading in Haru Danim"
|
||||
• iCloud sync across iPhone, iPad and Mac
|
||||
• Diary on iPad and Mac — a daily summary page plus free-form Apple Pencil notes (mouse and keyboard on Mac), with your own PDF page templates
|
||||
• Diary on iPhone, iPad and Mac — a daily summary page plus free-form notes (Apple Pencil on iPad, finger drawing on iPhone, mouse and keyboard on Mac), with your own PDF page templates. The summary now includes a health-data card and a timetable with your sleep and workouts laid in
|
||||
• CSV import — bring actions and records back from your exported CSVs
|
||||
• Health quests — goals from Apple Health data like steps, sleep and workout types ("8,000 steps a day," "8 hours of sleep")
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ Haru Danim — あなたの一日に立ち寄る
|
||||
• Apple Watchアプリとコンプリケーション — 手首で記録し、「アクションの記録」でフェイスに累計まで
|
||||
• Siriショートカット — 「Hey Siri、Haru Danimで読書の計測を開始」
|
||||
• iCloudでiPhone・iPad・Macのデータを同期
|
||||
• iPad・Macの日記 — 一日のまとめページとApple Pencilの自由ノート(Macではマウス・キーボード)、自分のPDF用紙(テンプレート)
|
||||
• 日記(iPhone・iPad・Mac)— 一日のまとめページと自由ノート(iPadはApple Pencil、iPhoneは指で描く、Macはマウス・キーボード)、自分のPDF用紙(テンプレート)。まとめにはその日のヘルス値カードと、睡眠・運動が敷かれたタイムテーブルも
|
||||
• CSV読み込み — 書き出したCSVからアクションと記録を復元
|
||||
• ヘルスクエスト — 歩数・睡眠・運動種目などAppleヘルスケアのデータを目標に(「毎日8,000歩」「睡眠8時間」)
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
• 애플워치 앱과 컴플리케이션 — 손목에서 기록하고, '행동 기록' 컴플리케이션으로 페이스에서 누적까지
|
||||
• 시리 단축어 — "시리야, 하루 다님에서 독서 측정 시작"
|
||||
• iCloud로 아이폰·아이패드·맥 데이터 동기화
|
||||
• 아이패드·맥 일기 — 하루 정리 요약과 애플 펜슬 자유 노트(맥은 마우스·키보드), 나만의 PDF 양식(속지)
|
||||
• 일기 (아이폰·아이패드·맥) — 하루 정리 요약과 자유 노트(아이패드는 애플 펜슬, 아이폰은 손가락 그리기, 맥은 마우스·키보드), 나만의 PDF 양식(속지). 하루 정리에는 그날의 건강 값 카드와 수면·운동이 깔린 타임테이블까지
|
||||
• CSV 가져오기 — 내보냈던 CSV에서 행동과 기록을 다시 불러와 복원
|
||||
• 건강 다짐 — 걸음·수면·운동 종목 같은 애플 건강 데이터를 목표로 ('매일 8,000보', '수면 8시간')
|
||||
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
# 버전 1.5 '새로운 기능' (로케일별)
|
||||
|
||||
[ko]
|
||||
이번 업데이트는 애플 건강, 그리고 애플워치와 더 가까워졌어요.
|
||||
이번 업데이트는 애플 건강, 그리고 애플워치와 더 가까워졌어요. 일기는 이제 아이폰에서도 열려요.
|
||||
|
||||
● 애플 건강과 연결
|
||||
• 모음 탭에서 걸음·운동·수면 같은 오늘의 건강 데이터를 타일로 볼 수 있어요 — 값은 기기 밖으로 나가지 않아요
|
||||
• 건강 데이터로 다짐을 만들 수 있어요(프리미엄) — '매일 8,000보', '수면 8시간', '달리기 30분'처럼요. 운동은 종목별로도 돼요
|
||||
• 수면은 '수면 구간'을 정해 두면 자정을 넘겨 자도 하루가 쪼개지지 않아요
|
||||
• 수면은 '수면 구간'을 정해 두면 자정을 넘겨 자도 하루가 쪼개지지 않아요. 낮에 자는 날이 있다면 '수면 표시 구간'을 요일별로 맞출 수도 있어요
|
||||
|
||||
● 일기가 더 넓어졌어요 (프리미엄)
|
||||
• 일기 탭이 아이폰에도 생겼어요 — 하루 정리를 확인하고, '그리기'를 켜면 손가락으로 노트에 쓸 수 있어요
|
||||
• 하루 정리에 그날의 건강 값을 담는 '건강 데이터' 카드가 생겼어요
|
||||
• 타임테이블에 수면·운동이 실제 시간 그대로 깔려요 — 하루가 정말 한눈에 정리돼요
|
||||
|
||||
● 애플워치 '행동 기록' 컴플리케이션
|
||||
• 행동의 누적 기록(하루·이번 주·이번 달·지난 7일·지난 30일)을 워치 페이스에서 바로 봐요
|
||||
@ -17,12 +22,17 @@
|
||||
• 여러 기기를 쓰신다면 모든 기기에서 함께 업데이트해 주세요
|
||||
|
||||
[en]
|
||||
This update brings Haru Danim closer to Apple Health — and your Apple Watch.
|
||||
This update brings Haru Danim closer to Apple Health — and your Apple Watch. And the Diary now opens on iPhone, too.
|
||||
|
||||
● Apple Health, connected
|
||||
• See today's health data — steps, exercise, sleep — as tiles on the Home tab. Values never leave your device
|
||||
• Build quests from health data (Premium): "8,000 steps a day," "8 hours of sleep," "30 minutes of running" — workouts by type, too
|
||||
• Set a sleep window and sleeping past midnight won't split your night
|
||||
• Set a sleep window and sleeping past midnight won't split your night. If some days you sleep in the daytime, tune the display window per weekday
|
||||
|
||||
● A bigger Diary (Premium)
|
||||
• The Diary tab is now on iPhone — review your day, and turn on Draw to write on note pages with your finger
|
||||
• The daily summary gains a Health Data card with that day's values
|
||||
• Sleep and workouts now appear on the timetable at their actual times — your whole day, truly at a glance
|
||||
|
||||
● Action Log complication for Apple Watch
|
||||
• See an action's running total (day, this week, this month, last 7/30 days) right on your watch face
|
||||
@ -33,12 +43,17 @@ This update brings Haru Danim closer to Apple Health — and your Apple Watch.
|
||||
• Using several devices? Please update Haru Danim on all of them
|
||||
|
||||
[ja]
|
||||
今回のアップデートで、Appleヘルスケア、そしてApple Watchともっと近くなりました。
|
||||
今回のアップデートで、Appleヘルスケア、そしてApple Watchともっと近くなりました。日記はiPhoneでも開けるようになりました。
|
||||
|
||||
● Appleヘルスケアと連携
|
||||
• ホームタブで歩数・運動・睡眠など今日のヘルスデータをタイルで見られます — 値が端末の外に出ることはありません
|
||||
• ヘルスデータでクエストを作れます(プレミアム)—「毎日8,000歩」「睡眠8時間」「ランニング30分」など。運動は種目別でもOK
|
||||
• 「睡眠時間帯」を決めておけば、日付をまたいで眠っても1日が分割されません
|
||||
• 「睡眠時間帯」を決めておけば、日付をまたいで眠っても1日が分割されません。昼間に眠る日があるなら、表示の時間帯を曜日ごとに調整できます
|
||||
|
||||
● 日記がもっと広く(プレミアム)
|
||||
• 日記タブがiPhoneにも登場 — 1日のまとめを確認し、「描く」をオンにすれば指でノートに書けます
|
||||
• 1日のまとめに、その日のヘルス値をまとめる「ヘルスデータ」カードが加わりました
|
||||
• タイムテーブルに睡眠・運動が実際の時間どおりに表示されます — 1日が本当にひと目でわかります
|
||||
|
||||
● Apple Watch「アクションの記録」コンプリケーション
|
||||
• アクションの累計(1日・今週・今月・過去7日・過去30日)をウォッチフェイスで直接確認
|
||||
|
||||
@ -75,7 +75,7 @@ FormatStyle에 반영되지 않는다. 로케일마다 첫 시드 실행에 `-ma
|
||||
# 06-bubble : -navStyle radial -radialExpanded YES
|
||||
# 08-dark : 기본 실행 후 `simctl ui <sim> appearance dark` → 촬영 → light 복귀
|
||||
# 09-records : -startTab history -historyDaysAgo 1 (어제 목록 — 메모 노랑 표시)
|
||||
# 10-week : (벌크 시드 후) -startTab history -historyMode timetable -historyWeekly YES -historyDaysAgo 7
|
||||
# 10-diary : 1.5(4)부터 — 시드에 -diarySeed 추가 후 -startTab diary -diaryOpenToday YES -seedHealthCache YES -diary.sectionOrder "mood,health,timetable" (구 10-week[주간 타임테이블]는 폐기)
|
||||
# 아이패드: 01/10-goals/09-dark 동일 요령. 일기는 첫 실행에
|
||||
# -startTab diary -diarySeed YES -diarySeedPhoto YES -diarySeedTemplate YES 로 시드 후:
|
||||
# 02: -startTab diary 03: +-diaryOpenToday 04: +-diaryPage 1 08: +-diaryApplyTemplate
|
||||
@ -103,7 +103,9 @@ MW=0A2CB37C-A755-4784-9D14-C7AB8FD72C3E # 'MarketingWatch' — 비페어링 Se
|
||||
> ✅ **2026-07-28: 아래 절차 전부 완료 — 버전 1.0 App Store 출시됨.** 이 체크리스트는 절차 기록으로 유지.
|
||||
> 📸 **2026-08-22: 1.5용 스크린샷 전면 재촬영 — 기존 세트 폐기, 아이폰 10·아이패드 10·워치 1 × ko/en/ja (원본 57장+합성 63장).**
|
||||
> 구성 변경: 아이폰 09-records → **09-health**(건강 다짐 목표 탭 — `-seedHealthQuest`+`-seedHealthCache`, 데모 시드 없이 단독. ⚠️행동이 없어 온보딩이 뜨므로 촬영 전 `defaults write com.yechan.HaruDanim onboarding.done -bool YES` 필요). 01-main은 건강 타일이 보이게 `-mainSectionOrder "favorites,health,others"` 표시 오버라이드 사용.
|
||||
> 시뮬레이터 리셋(2026-08-22)으로 마케팅 심 재생성: iPhone 17 Pro Max(26.5) `0255B56E-A9B8-44DE-9C0B-8570BB0C6315`, iPad Pro 13"(26.5) `9A8DE7A7-D7B1-4C95-92FE-0AA4B5D3034E`, MarketingWatch(26.2, 비페어링) `071FD7ED-9047-498D-B6BA-73A898FDE03A`. 시간대는 Pacific/Auckland 부팅 주입(오전 9시대).
|
||||
> 시뮬레이터 리셋(2026-08-22)으로 마케팅 심 재생성: iPhone 17 Pro Max(26.5) `0255B56E-A9B8-44DE-9C0B-8570BB0C6315`, iPad Pro 13"(26.5) `9A8DE7A7-D7B1-4C95-92FE-0AA4B5D3034E`, MarketingWatch(26.2, 비페어링) `071FD7ED-9047-498D-B6BA-73A898FDE03A`. 시간대는 Pacific/Auckland 부팅 주입(오전 9시대 — **KST 저녁 촬영이면 America/Argentina/Buenos_Aires(UTC-3)가 오전 9시대**, 1.5(4) 실측).
|
||||
>
|
||||
> 📸 **2026-08-22 저녁, 1.5(4) 추가분 3장 갱신**: ①아이폰 `06-bubble` 재촬영(일기 개방으로 버블 8개) ②아이폰 `10-week` → **`10-diary` 교체**(아이폰 일기 — 인자: 시드 `-seedDemo -diarySeed -seedHealthCache` 후 `-startTab diary -diaryOpenToday -seedHealthCache -diary.sectionOrder "mood,health,timetable"`) ③아이패드 `03-diary-summary` 재촬영(건강 카드+타임테이블 수면·운동 블록 — 같은 요령, sectionOrder `"mood,health,timetable,hero,goals"`). 합성 문구는 frame_screenshots.swift에 반영(10-diary 신설·03/02 서브라인 갱신).
|
||||
> ⚠️촬영 실측 주의 3가지: ①`simctl io screenshot`은 **기존 파일을 덮어쓰지 않음** — 재촬영 전 rm 필수 ②워치 절차의 그룹 컨테이너 경로는 **부팅 중에만 조회 가능**하고 아이폰 앱 재설치마다 페어링 워치 쪽 경로가 바뀜 — 로케일마다 재조회(셧다운 후 조회하면 빈 값→엉뚱한 cp) ③아이폰 언인스톨→워치 앱 삭제 전파가 늦어 직후 설치가 지워질 수 있음 — 언인스톨 후 8초 대기.
|
||||
>
|
||||
> 📸 **2026-07-29: 1.1용 스크린샷 전면 개편 — 아이폰 10장·아이패드 10장·워치 합성 1장 × ko/en/ja (전부 재촬영·재합성).**
|
||||
|
||||
|
Before Width: | Height: | Size: 625 KiB After Width: | Height: | Size: 631 KiB |
|
Before Width: | Height: | Size: 999 KiB After Width: | Height: | Size: 1007 KiB |
|
Before Width: | Height: | Size: 625 KiB After Width: | Height: | Size: 629 KiB |
|
Before Width: | Height: | Size: 993 KiB After Width: | Height: | Size: 1007 KiB |
|
Before Width: | Height: | Size: 605 KiB After Width: | Height: | Size: 609 KiB |
|
Before Width: | Height: | Size: 942 KiB After Width: | Height: | Size: 962 KiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 817 KiB |
|
Before Width: | Height: | Size: 852 KiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 819 KiB |
|
Before Width: | Height: | Size: 856 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 785 KiB |
|
Before Width: | Height: | Size: 835 KiB |
|
Before Width: | Height: | Size: 559 KiB After Width: | Height: | Size: 568 KiB |
|
Before Width: | Height: | Size: 581 KiB After Width: | Height: | Size: 595 KiB |
|
Before Width: | Height: | Size: 512 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
BIN
myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/en/10-diary.png
Normal file
|
After Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
BIN
myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ja/10-diary.png
Normal file
|
After Width: | Height: | Size: 439 KiB |
|
Before Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
BIN
myApp/HaruDanim/Marketing/Screenshots/iphone-6.9/ko/10-diary.png
Normal file
|
After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 339 KiB |
@ -53,9 +53,10 @@ let iphoneCopies: [String: Copy] = [
|
||||
"08-dark": Copy(
|
||||
headline: ["ko": "밤에는 다크 모드로 편안하게", "en": "Easy on the eyes in Dark Mode", "ja": "夜はダークモードで心地よく"],
|
||||
subline: ["ko": "라이트·다크·시스템 설정 따름 중 선택", "en": "Light, dark, or match the system", "ja": "ライト・ダーク・システム連動から選択"]),
|
||||
"10-week": Copy(
|
||||
headline: ["ko": "일주일의 흐름이 한눈에", "en": "Your week at a glance", "ja": "一週間の流れをひと目で"],
|
||||
subline: ["ko": "24시간 축 위에 그려지는 주간 타임테이블", "en": "Seven days drawn on a 24-hour timetable", "ja": "24時間軸に描かれる週間タイムテーブル"]),
|
||||
// 1.5(4): 10-week → 10-diary 교체 — 아이폰 일기 개방이 헤드라인 기능이라 주간 타임테이블 슬롯을 양보
|
||||
"10-diary": Copy(
|
||||
headline: ["ko": "일기가 이제 아이폰에도", "en": "The Diary, now on iPhone", "ja": "日記がiPhoneにも"],
|
||||
subline: ["ko": "하루 정리와 건강 데이터, 손가락으로 쓰는 노트", "en": "Daily summary, health data and finger-drawn notes", "ja": "1日のまとめとヘルスデータ、指で書くノート"]),
|
||||
]
|
||||
|
||||
let ipadCopies: [String: Copy] = [
|
||||
@ -64,10 +65,10 @@ let ipadCopies: [String: Copy] = [
|
||||
subline: ["ko": "사이드바와 넓은 그리드로 한 화면에", "en": "Sidebar navigation and a wide grid", "ja": "サイドバーと広いグリッドで一画面に"]),
|
||||
"02-diary-calendar": Copy(
|
||||
headline: ["ko": "하루를 남기는 일기", "en": "A diary for every day", "ja": "毎日を残す日記"],
|
||||
subline: ["ko": "기분 이모지와 사진으로 채워지는 달력 — 맥에서도", "en": "A calendar of moods and photos — on Mac too", "ja": "気分の絵文字と写真で埋まるカレンダー — Macでも"]),
|
||||
subline: ["ko": "기분 이모지와 사진으로 채워지는 달력 — 아이폰·맥에서도", "en": "A calendar of moods and photos — on iPhone and Mac too", "ja": "気分の絵文字と写真で埋まるカレンダー — iPhone・Macでも"]),
|
||||
"03-diary-summary": Copy(
|
||||
headline: ["ko": "하루 정리를 한 페이지에", "en": "Your whole day on one page", "ja": "一日のまとめを1ページに"],
|
||||
subline: ["ko": "기록·목표·할 일·타임테이블까지", "en": "Records, goals, to-dos and a timetable", "ja": "記録・目標・やること・タイムテーブル"]),
|
||||
subline: ["ko": "기록·목표·건강 데이터·타임테이블까지", "en": "Records, goals, health data and a timetable", "ja": "記録・目標・ヘルスデータ・タイムテーブル"]),
|
||||
"04-diary-note": Copy(
|
||||
headline: ["ko": "애플 펜슬로 자유롭게", "en": "Free-form with Apple Pencil", "ja": "Apple Pencilで自由に"],
|
||||
subline: ["ko": "필기·사진·도형·텍스트 — 맥에서는 마우스와 키보드로", "en": "Ink, photos, shapes and text — mouse and keyboard on Mac", "ja": "手書き・写真・図形・テキスト — Macではマウスとキーボードで"]),
|
||||
|
||||
@ -265,6 +265,69 @@ nonisolated enum HealthCache {
|
||||
}
|
||||
|
||||
/// 전체 초기화(데이터 정리)용 — 값은 어차피 건강 앱 소유라 캐시만 비운다
|
||||
static func clearAll() {
|
||||
AppGroup.defaults.removeObject(forKey: storageKey)
|
||||
HealthIntervals.clearAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 시간 구간 캐시 (1.5 추가분 — 일기 타임테이블의 수면·운동 블록)
|
||||
|
||||
/// 하루 '합계'만 담는 HealthCache와 달리, 타임테이블 표시용 실제 시간 구간을 담는다.
|
||||
/// 키: "sleep" 또는 "workout.<kind>", 값: dayToken → [[시작 epoch, 끝 epoch], ...].
|
||||
/// 구간은 그 논리적 하루와 겹치는 부분으로 클리핑해 저장 — 자정을 걸친 잠은 이틀에 나뉜다
|
||||
/// (다짐의 '깨어난 날 통귀속'과 다른, 사실 그대로의 표시 규칙 — 의도된 차이).
|
||||
/// ⚠️ HealthCache와 동일하게 기기 로컬 defaults 전용 — CloudKit 저장 금지 (5.1.3)
|
||||
nonisolated enum HealthIntervals {
|
||||
static let storageKey = "health.intervals.v1"
|
||||
static let retentionDays = HealthCache.retentionDays
|
||||
|
||||
static let sleepKey = "sleep"
|
||||
static func workoutKey(_ kind: HealthWorkoutKind) -> String { "workout.\(kind.rawValue)" }
|
||||
|
||||
private static func load() -> [String: [String: [[Double]]]] {
|
||||
(AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: [[Double]]]]) ?? [:]
|
||||
}
|
||||
|
||||
/// 한 종류·한 날짜의 구간 목록 (시작 오름차순)
|
||||
static func intervals(_ key: String, dayKey: Date) -> [(start: Date, end: Date)] {
|
||||
let stored = load()[key]?[HealthCache.dayToken(dayKey)] ?? []
|
||||
return stored.compactMap { pair in
|
||||
guard pair.count == 2, pair[1] > pair[0] else { return nil }
|
||||
return (Date(timeIntervalSince1970: pair[0]), Date(timeIntervalSince1970: pair[1]))
|
||||
}.sorted { $0.start < $1.start }
|
||||
}
|
||||
|
||||
/// 그날 저장된 운동 종류 키 목록 (표시 순서는 호출부가 정함)
|
||||
static func workoutKeys(dayKey: Date) -> [String] {
|
||||
let token = HealthCache.dayToken(dayKey)
|
||||
return load().keys.filter { $0.hasPrefix("workout.") && !(load()[$0]?[token] ?? []).isEmpty }
|
||||
}
|
||||
|
||||
/// 여러 날짜 × 여러 종류 일괄 기록 (백필·최근 갱신 공용).
|
||||
/// values: [종류 키: [dayKey: [[시작 epoch, 끝 epoch]]]] — 빈 배열은 "그날 없음" 기록으로 유효
|
||||
static func merge(_ values: [String: [Date: [[Double]]]]) {
|
||||
guard !values.isEmpty else { return }
|
||||
var all = load()
|
||||
for (key, days) in values {
|
||||
var stored = all[key] ?? [:]
|
||||
for (dayKey, list) in days {
|
||||
let token = HealthCache.dayToken(dayKey)
|
||||
if list.isEmpty {
|
||||
stored.removeValue(forKey: token)
|
||||
} else {
|
||||
stored[token] = list
|
||||
}
|
||||
}
|
||||
if stored.count > retentionDays {
|
||||
let sorted = stored.keys.sorted(by: >)
|
||||
for old in sorted.dropFirst(retentionDays) { stored.removeValue(forKey: old) }
|
||||
}
|
||||
all[key] = stored
|
||||
}
|
||||
AppGroup.defaults.set(all, forKey: storageKey)
|
||||
}
|
||||
|
||||
static func clearAll() {
|
||||
AppGroup.defaults.removeObject(forKey: storageKey)
|
||||
}
|
||||
|
||||
@ -46,6 +46,61 @@ nonisolated enum LocalPrefsKeys {
|
||||
/// 모음 탭 건강 타일에 표시할 지표 — HealthMetric raw 쉼표 연결(순서 포함, 1.5).
|
||||
/// 기본: 걸음·운동·수면
|
||||
static let healthMetrics = "local.healthMetrics"
|
||||
/// 수면 표시 구간(타일·일기 건강 카드 공용) — "시작분-끝분" 스펙, 빈 값=기본(1260-540) (1.5)
|
||||
static let sleepWindowGlobal = "local.sleepWindowGlobal"
|
||||
/// 요일별 수면 구간 사용 여부 — Bool (1.5)
|
||||
static let sleepWindowWeekdayEnabled = "local.sleepWindowWeekdayEnabled"
|
||||
/// 요일별 수면 구간 — ["1"(일)~"7"(토): 스펙] 딕셔너리. 값 없는 요일은 전체 구간 (1.5)
|
||||
static let sleepWindowByWeekday = "local.sleepWindowByWeekday"
|
||||
}
|
||||
|
||||
// MARK: - 수면 표시 구간 설정 (1.5 추가분 — 설정 탭, 교대·야간 근무 수면 패턴 대응)
|
||||
|
||||
/// 모음 탭 타일과 일기 건강 카드가 쓰는 '수면 구간'(어느 시각에 끝나는 창의 잠을 그날 것으로
|
||||
/// 볼지)의 설정 계층. 다짐의 수면 구간은 다짐별 저장이라 별개 — 새 다짐의 기본값만 여기서 시드.
|
||||
/// 값은 기기 로컬(App Group defaults) — 건강 값과 같은 계층이라 CloudKit 비동기화가 자연스럽다.
|
||||
nonisolated enum SleepWindowPrefs {
|
||||
static let defaultSpec = "1260-540"
|
||||
|
||||
/// 전체(요일 미지정) 구간 스펙
|
||||
static var globalSpec: String {
|
||||
let raw = AppGroup.defaults.string(forKey: LocalPrefsKeys.sleepWindowGlobal) ?? ""
|
||||
return raw.isEmpty ? defaultSpec : raw
|
||||
}
|
||||
|
||||
static var weekdayEnabled: Bool {
|
||||
AppGroup.defaults.bool(forKey: LocalPrefsKeys.sleepWindowWeekdayEnabled)
|
||||
}
|
||||
|
||||
static var weekdaySpecs: [String: String] {
|
||||
(AppGroup.defaults.dictionary(forKey: LocalPrefsKeys.sleepWindowByWeekday) as? [String: String]) ?? [:]
|
||||
}
|
||||
|
||||
/// 특정 요일(1=일 ~ 7=토)의 유효 스펙
|
||||
static func spec(forWeekday weekday: Int) -> String {
|
||||
guard weekdayEnabled, let raw = weekdaySpecs["\(weekday)"], !raw.isEmpty else { return globalSpec }
|
||||
return raw
|
||||
}
|
||||
|
||||
/// 특정 논리적 하루 키의 유효 스펙 (키는 달력일 자정 — 요일은 달력일 그대로)
|
||||
static func spec(forDayKey dayKey: Date) -> String {
|
||||
spec(forWeekday: Calendar.current.component(.weekday, from: dayKey))
|
||||
}
|
||||
|
||||
/// 설정된 모든 스펙 (캐시 계산 대상 — 전체 + 켜져 있으면 요일별 전부)
|
||||
static var allSpecs: Set<String> {
|
||||
var specs: Set<String> = [globalSpec]
|
||||
if weekdayEnabled {
|
||||
for raw in weekdaySpecs.values where !raw.isEmpty { specs.insert(raw) }
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
/// 요일→스펙 매핑 서명 — 백필 서명에 포함해, 스펙 집합이 같아도 요일 배정이
|
||||
/// 바뀌면(예: 주말만 다른 구간으로 변경) 과거 날짜의 "sleep" 평문 키가 재계산되게 한다
|
||||
static var mappingSignature: String {
|
||||
(1...7).map { spec(forWeekday: $0) }.joined(separator: "/")
|
||||
}
|
||||
}
|
||||
|
||||
/// 모음 탭의 순서 지정 가능한 3섹션 (1.5 — 목표 카드·진행 중 영역은 상단 고정, 대상 아님)
|
||||
|
||||