- fix: 더보기 탭 일기 달력 날짜 탭 무반응 — morePath [AppTab]→NavigationPath(Date 푸시 가능), 회귀 인자 -morePushDiaryToday(착지 실측) - fix: 수면 블록이 수면 단계별로 칸칸이 갈라짐 — 10분 이하 깸 병합(sleepDisplayMergeGap, 표시 전용·값 무영향) - feat: 기록 탭 타임테이블(하루·주간)에 수면·운동 블록 — 단일 공급 지점·숨김 토글 공유, 내보내기 injectingHealthBlocks(forDayKeys:)+trim 확장 - feat: 타임테이블 표시 색 설정(설정→건강 데이터 — 기본 인디고·주황, 전 타임테이블·내보내기 공통), -healthColorPreview - QA: 주간 6일 블록 렌더·커스텀 색 픽셀 검증(#FF2D55→(255,221,228))·색 설정 화면·Date 푸시 착지, l10n 0/0, Debug/Store 그린 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
1495 lines
64 KiB
Swift
1495 lines
64 KiB
Swift
//
|
|
// ExportImageView.swift
|
|
// Haru_Danim
|
|
//
|
|
// 기록·통계 이미지 내보내기.
|
|
// 현재 보고 있는 날짜/기간과 필터를 그대로 반영한 "리포트 포스터"를 고해상도 PNG로 렌더링해
|
|
// 공유하거나 사진 앨범에 저장한다. 포스터는 앱 화면보다 상세한 구성(요약 지표 + 타임테이블 +
|
|
// 기록 목록 + 차트 + 합계 표)으로, 이미지 한 장만 봐도 내용이 파악되게 한다.
|
|
//
|
|
// 구조:
|
|
// - ExportSnapshot: SwiftData에 의존하지 않는 값 타입 스냅숏. 시트를 띄우는 시점에
|
|
// HistoryView/StatsTabView의 데이터로 만들어 두므로 ImageRenderer가 언제 그려도 안전하다.
|
|
// - ExportPosterView: 스냅숏만으로 그리는 고정 폭(430pt) 포스터. ImageRenderer(scale 3)로
|
|
// 1290px 폭 PNG가 된다. 테마는 .environment(\.colorScheme) 강제 주입(위젯 테마와 같은 방식).
|
|
// - ExportImageSheet: 미리보기 + 라이트/다크 선택 + 공유(ShareLink) / 사진 저장.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import Charts
|
|
import UIKit
|
|
|
|
// MARK: - 스냅숏 값 타입
|
|
|
|
struct ExportSnapshot {
|
|
var title: String
|
|
var periodLabel: String
|
|
/// 필터가 걸려 있을 때만 표시하는 안내 (예: "행동 5/8개만 포함")
|
|
var filterNote: String?
|
|
var hero: [ExportHeroStat]
|
|
var sections: [ExportSectionData]
|
|
/// 파일명용 (예: "기록-2026-07-13")
|
|
var fileSlug: String
|
|
|
|
// 파일명은 언어별로 현지화 — 수신 환경(PC·클라우드)이 한글을 지원하지 않을 수 있어
|
|
// en/ja는 라틴 접두 "HaruDanim-"과 그 언어의 종류 단어를 쓴다 (fileSlug도 현지화됨)
|
|
var fileName: String { String(localized: "하루다님-\(fileSlug).png") }
|
|
|
|
/// 하루짜리 타임테이블 섹션에 캘린더 일정 블록을 끼워 넣는다 (일기 화면·내보내기 공용)
|
|
func injectingCalendarEvents(_ events: [ExportTimetableData.Block]) -> ExportSnapshot {
|
|
guard !events.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].eventBlocks = events
|
|
return .timetable(title: title, subtitle: subtitle, data)
|
|
}
|
|
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
|
|
}
|
|
|
|
/// 타임테이블 섹션(하루·주간)에 건강(수면·운동) 블록을 날짜별로 끼워 넣는다 (1.5(4) —
|
|
/// 기록 탭 화면과 내보내기 일치용). 기록이 없는 시간대를 잘라낸 표시 범위(trimHours)는
|
|
/// 블록이 걸치는 만큼 확장한다 — 새벽 수면이 잘린 그리드 밖으로 사라지지 않게
|
|
func injectingHealthBlocks(forDayKeys dayKeys: [Date], math: DayMath) -> ExportSnapshot {
|
|
var copy = self
|
|
copy.sections = sections.map { section in
|
|
guard case .timetable(let title, let subtitle, var data) = section,
|
|
data.days.count == dayKeys.count else { return section }
|
|
var changed = false
|
|
for (index, key) in dayKeys.enumerated() {
|
|
let blocks = DiaryHealthTimetable.blocks(dayKey: key, math: math)
|
|
guard !blocks.isEmpty else { continue }
|
|
data.days[index].healthBlocks = blocks
|
|
changed = true
|
|
if let lo = blocks.map(\.startFrac).min() {
|
|
data.hourLo = min(data.hourLo, max(0, Int(lo.rounded(.down))))
|
|
}
|
|
if let hi = blocks.map(\.endFrac).max() {
|
|
data.hourHi = max(data.hourHi, min(24, Int(hi.rounded(.up))))
|
|
}
|
|
}
|
|
guard changed else { return section }
|
|
return .timetable(title: title, subtitle: subtitle, data)
|
|
}
|
|
return copy
|
|
}
|
|
|
|
/// 타임테이블 섹션만 다른 스냅숏의 것으로 교체.
|
|
/// 일기의 날짜별 타임테이블 필터용 — 기록·통계 섹션은 전체(자신)를 유지한다.
|
|
func replacingTimetableSections(with other: ExportSnapshot) -> ExportSnapshot {
|
|
var replacements = other.sections.filter {
|
|
if case .timetable = $0 { return true }
|
|
return false
|
|
}
|
|
var copy = self
|
|
copy.sections = sections.compactMap { section in
|
|
guard case .timetable = section else { return section }
|
|
return replacements.isEmpty ? nil : replacements.removeFirst()
|
|
}
|
|
return copy
|
|
}
|
|
}
|
|
|
|
struct ExportHeroStat: Identifiable {
|
|
let id = UUID()
|
|
var title: String
|
|
var value: String
|
|
}
|
|
|
|
struct ExportRecordRow: Identifiable {
|
|
let id = UUID()
|
|
var symbol: String
|
|
var color: Color
|
|
var name: String
|
|
var detail: String
|
|
var value: String
|
|
var note: String
|
|
}
|
|
|
|
struct ExportBarItem: Identifiable {
|
|
let id = UUID()
|
|
var name: String
|
|
/// 차트에 그릴 값 (시간형은 시간(h) 단위로 환산해 둠)
|
|
var value: Double
|
|
var valueLabel: String
|
|
var color: Color
|
|
}
|
|
|
|
struct ExportLineChartData {
|
|
enum XAxis {
|
|
/// 요일·주차처럼 순서가 정해진 카테고리 축
|
|
case category(order: [String])
|
|
/// 날짜 축 (일 단위)
|
|
case days
|
|
}
|
|
|
|
struct Point: Identifiable {
|
|
let id = UUID()
|
|
var date: Date?
|
|
var category: String?
|
|
var series: String
|
|
var value: Double
|
|
}
|
|
|
|
var title: String
|
|
var yLabel: String
|
|
var seriesNames: [String]
|
|
var seriesColors: [Color]
|
|
var points: [Point]
|
|
var xAxis: XAxis
|
|
}
|
|
|
|
struct ExportTimetableData {
|
|
struct Block: Identifiable {
|
|
let id = UUID()
|
|
/// 하루 시작 기준 오프셋(시간 단위 소수)
|
|
var startFrac: Double
|
|
var endFrac: Double
|
|
var color: Color
|
|
var symbol: String
|
|
var name: String
|
|
}
|
|
|
|
struct Marker: Identifiable {
|
|
let id = UUID()
|
|
var frac: Double
|
|
var color: Color
|
|
}
|
|
|
|
struct Day: Identifiable {
|
|
let id = UUID()
|
|
/// 주간 보기에서만 표시하는 요일 라벨
|
|
var label: String?
|
|
var emphasized: Bool
|
|
var blocks: [Block]
|
|
var markers: [Marker]
|
|
/// 캘린더(EventKit) 일정 — 행동 블록 아래에 외곽선 스타일로 깔린다 (일기 전용)
|
|
var eventBlocks: [Block] = []
|
|
/// 건강(수면·운동) 실구간 — 반투명 채움 스타일로 맨 아래층에 깔린다 (일기 전용, 1.5)
|
|
var healthBlocks: [Block] = []
|
|
}
|
|
|
|
/// 하루 시작 시각(시). 축 라벨은 (startHour + 오프셋) % 24
|
|
var startHour: Int
|
|
/// 하루 시작 시각의 분 성분 — 0이 아니면(정시 아닌 하루 시작) 라벨을 "HH:mm"으로
|
|
var startMinute: Int = 0
|
|
/// 기록이 없는 위·아래 구간을 잘라낸 표시 범위 (오프셋 시간)
|
|
var hourLo: Int
|
|
var hourHi: Int
|
|
var days: [Day]
|
|
}
|
|
|
|
struct ExportTableData {
|
|
struct Row: Identifiable {
|
|
let id = UUID()
|
|
var symbol: String
|
|
var color: Color
|
|
var name: String
|
|
var values: [String]
|
|
}
|
|
|
|
struct Group {
|
|
var label: String
|
|
var rows: [Row]
|
|
}
|
|
|
|
var columns: [String]
|
|
var groups: [Group]
|
|
}
|
|
|
|
enum ExportSectionData {
|
|
case timetable(title: String, subtitle: String?, ExportTimetableData)
|
|
case records(title: String, rows: [ExportRecordRow])
|
|
case bars(title: String, xLabel: String, items: [ExportBarItem])
|
|
case lines(ExportLineChartData)
|
|
case table(title: String, ExportTableData)
|
|
}
|
|
|
|
// MARK: - 스냅숏 빌더
|
|
|
|
/// HistoryView/StatsTabView의 집계 규칙(구간 겹침으로 하루 경계 분할 반영)을 그대로 따라
|
|
/// 화면과 같은 값이 이미지에 찍히게 한다.
|
|
enum ExportBuilder {
|
|
// MARK: 공통 수집
|
|
|
|
private struct SegmentItem {
|
|
let action: Action
|
|
let session: TimeSession
|
|
let range: Range<Date>
|
|
|
|
var duration: TimeInterval { range.upperBound.timeIntervalSince(range.lowerBound) }
|
|
}
|
|
|
|
private static func collectSegments(
|
|
_ sessions: [TimeSession], allowed: Set<PersistentIdentifier>, range: Range<Date>, now: Date
|
|
) -> [SegmentItem] {
|
|
var items: [SegmentItem] = []
|
|
for session in sessions {
|
|
guard let action = session.action, allowed.contains(action.persistentModelID) else { continue }
|
|
let end = session.endAt ?? now
|
|
let lower = max(session.startAt, range.lowerBound)
|
|
let upper = min(end, range.upperBound)
|
|
guard lower < upper else { continue }
|
|
items.append(SegmentItem(action: action, session: session, range: lower..<upper))
|
|
}
|
|
return items.sorted { $0.range.lowerBound < $1.range.lowerBound }
|
|
}
|
|
|
|
private static func collectCounts(
|
|
_ entries: [CountEntry], allowed: Set<PersistentIdentifier>, range: Range<Date>
|
|
) -> [(action: Action, entry: CountEntry)] {
|
|
entries
|
|
.filter { range.contains($0.timestamp) }
|
|
.compactMap { entry in entry.action.map { ($0, entry) } }
|
|
.filter { allowed.contains($0.0.persistentModelID) }
|
|
.sorted { $0.1.timestamp < $1.1.timestamp }
|
|
}
|
|
|
|
private static func filterNote(included: Int, total: Int, filtering: Bool) -> String? {
|
|
filtering ? String(localized: "행동 \(included)/\(total)개만 포함") : nil
|
|
}
|
|
|
|
private static func slugDate(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
// MARK: 기록 탭 (하루 / 주간 타임테이블)
|
|
|
|
static func history(
|
|
dayKey: Date,
|
|
weeklyTimetable: Bool,
|
|
sessions: [TimeSession],
|
|
entries: [CountEntry],
|
|
orderedActions: [Action],
|
|
excludedActionIDs: Set<PersistentIdentifier>,
|
|
math: DayMath,
|
|
trimHours: Bool = true
|
|
) -> ExportSnapshot {
|
|
let included = orderedActions.filter { !excludedActionIDs.contains($0.persistentModelID) }
|
|
let allowed = Set(included.map(\.persistentModelID))
|
|
let note = filterNote(included: included.count, total: orderedActions.count,
|
|
filtering: !excludedActionIDs.isEmpty)
|
|
let now = Date.now
|
|
|
|
if weeklyTimetable {
|
|
let weekRange = math.weekRange(containing: math.dayRange(forKey: dayKey).lowerBound)
|
|
let keys = math.dayKeys(in: weekRange)
|
|
let segments = collectSegments(sessions, allowed: allowed, range: weekRange, now: now)
|
|
let counts = collectCounts(entries, allowed: allowed, range: weekRange)
|
|
|
|
var sections: [ExportSectionData] = []
|
|
if let (table, subtitle) = timetable(dayKeys: keys, emphasizedKey: dayKey, showDayLabels: true,
|
|
sessions: sessions, entries: entries,
|
|
allowed: allowed, math: math, now: now,
|
|
trimHours: trimHours) {
|
|
sections.append(.timetable(title: String(localized: "주간 타임테이블"), subtitle: subtitle, table))
|
|
}
|
|
if let bars = actionTotalBars(segments: segments, counts: counts, type: .time) {
|
|
sections.append(bars)
|
|
}
|
|
if let bars = actionTotalBars(segments: segments, counts: counts, type: .count) {
|
|
sections.append(bars)
|
|
}
|
|
|
|
let periodLabel: String = {
|
|
guard let first = keys.first, let last = keys.last else { return "" }
|
|
return "\(Format.shortDate(first)) ~ \(Format.shortDate(last))"
|
|
}()
|
|
return ExportSnapshot(
|
|
title: String(localized: "주간 기록"),
|
|
periodLabel: periodLabel,
|
|
filterNote: note,
|
|
hero: recordHero(segments: segments, counts: counts),
|
|
sections: sections,
|
|
fileSlug: String(localized: "기록-주간-\(slugDate(keys.first ?? dayKey))")
|
|
)
|
|
}
|
|
|
|
let dayRange = math.dayRange(forKey: dayKey)
|
|
let segments = collectSegments(sessions, allowed: allowed, range: dayRange, now: now)
|
|
let counts = collectCounts(entries, allowed: allowed, range: dayRange)
|
|
|
|
var sections: [ExportSectionData] = []
|
|
if let (table, subtitle) = timetable(dayKeys: [dayKey], emphasizedKey: nil, showDayLabels: false,
|
|
sessions: sessions, entries: entries,
|
|
allowed: allowed, math: math, now: now,
|
|
trimHours: trimHours) {
|
|
sections.append(.timetable(title: String(localized: "타임테이블"), subtitle: subtitle, table))
|
|
}
|
|
if !segments.isEmpty {
|
|
let rows = segments.prefix(40).map { item in
|
|
ExportRecordRow(
|
|
symbol: item.action.symbolName,
|
|
color: item.action.color,
|
|
name: item.action.name,
|
|
detail: "\(Format.time(item.range.lowerBound)) ~ \(item.session.endAt == nil ? String(localized: "진행 중") : Format.time(item.range.upperBound))",
|
|
value: Format.durationShort(item.duration),
|
|
note: item.session.note
|
|
)
|
|
}
|
|
let title = segments.count > 40
|
|
? String(localized: "시간 기록 · \(segments.count)건 중 40건")
|
|
: String(localized: "시간 기록 · \(segments.count)건")
|
|
sections.append(.records(title: title, rows: Array(rows)))
|
|
}
|
|
if !counts.isEmpty {
|
|
let rows = counts.prefix(40).map { item in
|
|
ExportRecordRow(
|
|
symbol: item.action.symbolName,
|
|
color: item.action.color,
|
|
name: item.action.name,
|
|
detail: Format.time(item.entry.timestamp),
|
|
value: "+\(item.entry.amount)",
|
|
note: item.entry.note
|
|
)
|
|
}
|
|
let title = counts.count > 40
|
|
? String(localized: "횟수 기록 · \(counts.count)건 중 40건")
|
|
: String(localized: "횟수 기록 · \(counts.count)건")
|
|
sections.append(.records(title: title, rows: Array(rows)))
|
|
}
|
|
|
|
return ExportSnapshot(
|
|
title: String(localized: "하루 기록"),
|
|
periodLabel: Format.fullDate(dayKey),
|
|
filterNote: note,
|
|
hero: recordHero(segments: segments, counts: counts),
|
|
sections: sections,
|
|
fileSlug: String(localized: "기록-\(slugDate(dayKey))")
|
|
)
|
|
}
|
|
|
|
private static func recordHero(
|
|
segments: [SegmentItem], counts: [(action: Action, entry: CountEntry)]
|
|
) -> [ExportHeroStat] {
|
|
let totalSeconds = segments.reduce(0.0) { $0 + $1.duration }
|
|
let totalCount = counts.reduce(0) { $0 + $1.entry.amount }
|
|
return [
|
|
ExportHeroStat(title: String(localized: "총 측정 시간"), value: Format.durationShort(totalSeconds)),
|
|
ExportHeroStat(title: String(localized: "시간 기록"), value: String(localized: "\(segments.count)건")),
|
|
ExportHeroStat(title: String(localized: "횟수 합계"), value: String(localized: "\(totalCount)회")),
|
|
]
|
|
}
|
|
|
|
/// 기간 내 행동별 합계 가로 막대 (기록 탭 주간 내보내기용)
|
|
private static func actionTotalBars(
|
|
segments: [SegmentItem], counts: [(action: Action, entry: CountEntry)], type: TrackingType
|
|
) -> ExportSectionData? {
|
|
var totals: [PersistentIdentifier: (action: Action, value: Double)] = [:]
|
|
switch type {
|
|
case .time:
|
|
for item in segments {
|
|
totals[item.action.persistentModelID, default: (item.action, 0)].value += item.duration
|
|
}
|
|
case .count:
|
|
for item in counts {
|
|
totals[item.action.persistentModelID, default: (item.action, 0)].value += Double(item.entry.amount)
|
|
}
|
|
}
|
|
let items = totals.values
|
|
.filter { $0.value > 0 }
|
|
.sorted { $0.value > $1.value }
|
|
.map { entry in
|
|
ExportBarItem(
|
|
name: entry.action.name,
|
|
value: type == .time ? entry.value / 3600 : entry.value,
|
|
valueLabel: type == .time
|
|
? Format.durationShort(entry.value)
|
|
: String(localized: "\(Int(entry.value))회"),
|
|
color: entry.action.color
|
|
)
|
|
}
|
|
guard !items.isEmpty else { return nil }
|
|
return .bars(
|
|
title: type == .time
|
|
? String(localized: "행동별 시간 합계")
|
|
: String(localized: "행동별 횟수 합계"),
|
|
xLabel: type == .time ? String(localized: "시간(h)") : String(localized: "횟수"),
|
|
items: items
|
|
)
|
|
}
|
|
|
|
// MARK: 타임테이블 데이터
|
|
|
|
private static func timetable(
|
|
dayKeys: [Date], emphasizedKey: Date?, showDayLabels: Bool,
|
|
sessions: [TimeSession], entries: [CountEntry],
|
|
allowed: Set<PersistentIdentifier>, math: DayMath, now: Date,
|
|
trimHours: Bool = true
|
|
) -> (ExportTimetableData, subtitle: String?)? {
|
|
guard let firstKey = dayKeys.first else { return nil }
|
|
var days: [ExportTimetableData.Day] = []
|
|
var minFrac = Double.infinity
|
|
var maxFrac = -Double.infinity
|
|
|
|
for key in dayKeys {
|
|
let range = math.dayRange(forKey: key)
|
|
func frac(_ date: Date) -> Double {
|
|
date.timeIntervalSince(range.lowerBound) / 3600
|
|
}
|
|
let blocks = collectSegments(sessions, allowed: allowed, range: range, now: now).map { item in
|
|
ExportTimetableData.Block(
|
|
startFrac: frac(item.range.lowerBound),
|
|
endFrac: frac(item.range.upperBound),
|
|
color: item.action.color,
|
|
symbol: item.action.symbolName,
|
|
name: item.action.name
|
|
)
|
|
}
|
|
let markers = collectCounts(entries, allowed: allowed, range: range).map { item in
|
|
ExportTimetableData.Marker(frac: frac(item.entry.timestamp), color: item.action.color)
|
|
}
|
|
for block in blocks {
|
|
minFrac = min(minFrac, block.startFrac)
|
|
maxFrac = max(maxFrac, block.endFrac)
|
|
}
|
|
for marker in markers {
|
|
minFrac = min(minFrac, marker.frac)
|
|
maxFrac = max(maxFrac, marker.frac)
|
|
}
|
|
days.append(ExportTimetableData.Day(
|
|
label: showDayLabels
|
|
? Format.weekdayShort(math.calendar.component(.weekday, from: key))
|
|
: nil,
|
|
emphasized: key == emphasizedKey,
|
|
blocks: blocks,
|
|
markers: markers
|
|
))
|
|
}
|
|
|
|
let dayStart = math.dayRange(forKey: firstKey).lowerBound
|
|
let startHour = math.calendar.component(.hour, from: dayStart)
|
|
let startMinute = math.calendar.component(.minute, from: dayStart)
|
|
|
|
// 일기 요약처럼 하루 전체를 고정으로 보여줄 때: 기록이 없어도 24시간 그리드를 그대로 반환
|
|
if !trimHours {
|
|
return (ExportTimetableData(startHour: startHour, startMinute: startMinute,
|
|
hourLo: 0, hourHi: 24, days: days), nil)
|
|
}
|
|
|
|
guard minFrac.isFinite else { return nil }
|
|
|
|
// 기록이 없는 구간을 잘라 여백을 줄인다 (최소 6시간은 유지)
|
|
var hourLo = max(0, Int(minFrac.rounded(.down)) - 1)
|
|
var hourHi = min(24, Int(maxFrac.rounded(.up)) + 1)
|
|
if hourHi - hourLo < 6 {
|
|
hourHi = min(24, hourLo + 6)
|
|
hourLo = max(0, hourHi - 6)
|
|
}
|
|
// 정시 아닌 하루 시작도 라벨이 실제 경계 시각을 말하도록 분 성분 포함 (화면 축과 동일 규칙)
|
|
let mm = String(format: "%02d", startMinute)
|
|
let subtitle: String? = (hourLo > 0 || hourHi < 24)
|
|
? String(localized: "기록이 있는 \(String(format: "%02d", (startHour + hourLo) % 24)):\(mm) ~ \(String(format: "%02d", (startHour + hourHi) % 24)):\(mm) 구간만 표시")
|
|
: nil
|
|
return (
|
|
ExportTimetableData(startHour: startHour, startMinute: startMinute,
|
|
hourLo: hourLo, hourHi: hourHi, days: days),
|
|
subtitle
|
|
)
|
|
}
|
|
|
|
// MARK: 통계 탭 (하루 / 주간 / 월간)
|
|
|
|
static func stats(
|
|
span: StatSpan,
|
|
anchorDayKey: Date,
|
|
rolling: Bool = false,
|
|
sessions: [TimeSession],
|
|
entries: [CountEntry],
|
|
orderedActions: [Action],
|
|
excludedActionIDs: Set<PersistentIdentifier>,
|
|
math: DayMath
|
|
) -> ExportSnapshot {
|
|
let included = orderedActions.filter { !excludedActionIDs.contains($0.persistentModelID) }
|
|
let allowed = Set(included.map(\.persistentModelID))
|
|
let note = filterNote(included: included.count, total: orderedActions.count,
|
|
filtering: !excludedActionIDs.isEmpty)
|
|
let now = Date.now
|
|
let agg = Aggregator(math: math)
|
|
|
|
// 통계 탭과 동일한 기간 계산 (rolling = '오늘 기준' 지난 7일/30일 — statsRange 참고)
|
|
let range = statsRange(span: span, anchorDayKey: anchorDayKey, rolling: rolling, math: math)
|
|
|
|
let segments = collectSegments(sessions, allowed: allowed, range: range, now: now)
|
|
let counts = collectCounts(entries, allowed: allowed, range: range)
|
|
|
|
// 기간 내 값이 있는 행동 (통계 탭 activeActions와 동일)
|
|
func activeActions(_ type: TrackingType) -> [Action] {
|
|
included
|
|
.filter { $0.trackingType == type }
|
|
.filter { action in
|
|
switch type {
|
|
case .time: return agg.seconds(for: action, in: range, now: now) > 0
|
|
case .count: return agg.count(for: action, in: range) > 0
|
|
}
|
|
}
|
|
}
|
|
|
|
let timeActions = activeActions(.time)
|
|
let countActions = activeActions(.count)
|
|
|
|
// Hero
|
|
let totalSeconds = segments.reduce(0.0) { $0 + $1.duration }
|
|
let totalCount = counts.reduce(0) { $0 + $1.entry.amount }
|
|
let hero = [
|
|
ExportHeroStat(title: String(localized: "총 측정 시간"), value: Format.durationShort(totalSeconds)),
|
|
ExportHeroStat(title: String(localized: "총 횟수"), value: String(localized: "\(totalCount)회")),
|
|
ExportHeroStat(title: String(localized: "기록한 행동"),
|
|
value: String(localized: "\(timeActions.count + countActions.count)개")),
|
|
]
|
|
|
|
// 월간 주 단위 범위 (주 시작 요일 설정 기준, 월 경계에서 잘림).
|
|
// 롤링(지난 30일)은 창이 달력 월과 어긋나 "N주차"가 성립하지 않아 만들지 않는다
|
|
var weekRanges: [Range<Date>] = []
|
|
if span == .month && !rolling {
|
|
var cursor = range.lowerBound
|
|
while cursor < range.upperBound {
|
|
let week = math.weekRange(containing: cursor)
|
|
weekRanges.append(max(week.lowerBound, range.lowerBound)..<min(week.upperBound, range.upperBound))
|
|
cursor = week.upperBound
|
|
}
|
|
}
|
|
|
|
func totalsBars(_ type: TrackingType, title: String) -> ExportSectionData? {
|
|
let actions = type == .time ? timeActions : countActions
|
|
// 동명 행동 구분 표시 이름 (통계 탭·⑤ 위젯과 동일 규칙)
|
|
let names = Format.disambiguated(actions.map(\.name))
|
|
let items = zip(actions, names)
|
|
.map { action, name -> ExportBarItem in
|
|
let value: Double = type == .time
|
|
? agg.seconds(for: action, in: range, now: now)
|
|
: Double(agg.count(for: action, in: range))
|
|
return ExportBarItem(
|
|
name: name,
|
|
value: type == .time ? value / 3600 : value,
|
|
valueLabel: type == .time
|
|
? Format.durationShort(value)
|
|
: String(localized: "\(Int(value))회"),
|
|
color: action.color
|
|
)
|
|
}
|
|
.sorted { $0.value > $1.value }
|
|
guard !items.isEmpty else { return nil }
|
|
return .bars(
|
|
title: title,
|
|
xLabel: type == .time ? String(localized: "시간(h)") : String(localized: "횟수"),
|
|
items: items
|
|
)
|
|
}
|
|
|
|
func dailyLines(_ type: TrackingType, title: String, weekdayCategory: Bool) -> ExportSectionData? {
|
|
let actions = type == .time ? timeActions : countActions
|
|
guard !actions.isEmpty else { return nil }
|
|
let seriesNames = Format.disambiguated(actions.map(\.name))
|
|
let keys = math.dayKeys(in: range)
|
|
var points: [ExportLineChartData.Point] = []
|
|
var categories: [String] = []
|
|
for key in keys {
|
|
let dayRange = math.dayRange(forKey: key)
|
|
let category = Format.weekdayShort(math.calendar.component(.weekday, from: key))
|
|
if weekdayCategory { categories.append(category) }
|
|
for (action, seriesName) in zip(actions, seriesNames) {
|
|
let value: Double = type == .time
|
|
? agg.seconds(for: action, in: dayRange, now: now) / 3600
|
|
: Double(agg.count(for: action, in: dayRange))
|
|
points.append(ExportLineChartData.Point(
|
|
date: weekdayCategory ? nil : key,
|
|
category: weekdayCategory ? category : nil,
|
|
series: seriesName,
|
|
value: value
|
|
))
|
|
}
|
|
}
|
|
return .lines(ExportLineChartData(
|
|
title: title,
|
|
yLabel: type == .time ? String(localized: "시간(h)") : String(localized: "횟수"),
|
|
seriesNames: seriesNames,
|
|
seriesColors: actions.map(\.color),
|
|
points: points,
|
|
xAxis: weekdayCategory ? .category(order: categories) : .days
|
|
))
|
|
}
|
|
|
|
func weeklyLines(_ type: TrackingType, title: String) -> ExportSectionData? {
|
|
let actions = type == .time ? timeActions : countActions
|
|
guard !actions.isEmpty, !weekRanges.isEmpty else { return nil }
|
|
let seriesNames = Format.disambiguated(actions.map(\.name))
|
|
var points: [ExportLineChartData.Point] = []
|
|
var categories: [String] = []
|
|
for (index, weekRange) in weekRanges.enumerated() {
|
|
let label = String(localized: "\(index + 1)주차")
|
|
categories.append(label)
|
|
for (action, seriesName) in zip(actions, seriesNames) {
|
|
let value: Double = type == .time
|
|
? agg.seconds(for: action, in: weekRange, now: now) / 3600
|
|
: Double(agg.count(for: action, in: weekRange))
|
|
points.append(ExportLineChartData.Point(
|
|
date: nil, category: label, series: seriesName, value: value
|
|
))
|
|
}
|
|
}
|
|
return .lines(ExportLineChartData(
|
|
title: title,
|
|
yLabel: type == .time ? String(localized: "시간(h)") : String(localized: "횟수"),
|
|
seriesNames: seriesNames,
|
|
seriesColors: actions.map(\.color),
|
|
points: points,
|
|
xAxis: .category(order: categories)
|
|
))
|
|
}
|
|
|
|
// 꼬리표별 집계 (통계 탭 tagStats와 동일 규칙: 꼬리표 없는 행동은 "꼬리표 없음").
|
|
// 키는 꼬리표 identity(nil = '꼬리표 없음') — 동명 꼬리표가 이름 키로 합산되는 것 방지,
|
|
// 표시 이름은 마지막에 Format.disambiguated로 구분한다 (통계 탭과 동일 규칙)
|
|
struct TagTotal {
|
|
var name: String
|
|
var color: Color
|
|
var seconds: TimeInterval = 0
|
|
var count: Int = 0
|
|
}
|
|
var tagAccs: [PersistentIdentifier?: TagTotal] = [:]
|
|
var tagOrder: [PersistentIdentifier?] = []
|
|
func tagKeys(for action: Action) -> [(PersistentIdentifier?, String, Color)] {
|
|
if action.tags.isEmpty { return [(nil, String(localized: "꼬리표 없음"), Color.gray)] }
|
|
return action.sortedTags.map { ($0.persistentModelID, $0.name, $0.color) }
|
|
}
|
|
func accumulate(_ action: Action, seconds: TimeInterval, count: Int) {
|
|
for (key, name, color) in tagKeys(for: action) {
|
|
if tagAccs[key] == nil {
|
|
tagAccs[key] = TagTotal(name: name, color: color)
|
|
tagOrder.append(key)
|
|
}
|
|
tagAccs[key]?.seconds += seconds
|
|
tagAccs[key]?.count += count
|
|
}
|
|
}
|
|
for item in segments { accumulate(item.action, seconds: item.duration, count: 0) }
|
|
for item in counts { accumulate(item.action, seconds: 0, count: item.entry.amount) }
|
|
let orderedTagTotals: [TagTotal] = {
|
|
let ordered = tagOrder.compactMap { tagAccs[$0] }
|
|
let names = Format.disambiguated(ordered.map(\.name))
|
|
return zip(ordered, names).map { acc, name in
|
|
var copy = acc
|
|
copy.name = name
|
|
return copy
|
|
}
|
|
}()
|
|
|
|
func tagBars(_ type: TrackingType, title: String) -> ExportSectionData? {
|
|
let items: [ExportBarItem]
|
|
switch type {
|
|
case .time:
|
|
items = orderedTagTotals
|
|
.filter { $0.seconds > 0 }
|
|
.sorted { $0.seconds > $1.seconds }
|
|
.map { total in
|
|
ExportBarItem(name: total.name, value: total.seconds / 3600,
|
|
valueLabel: Format.durationShort(total.seconds), color: total.color)
|
|
}
|
|
case .count:
|
|
items = orderedTagTotals
|
|
.filter { $0.count > 0 }
|
|
.sorted { $0.count > $1.count }
|
|
.map { total in
|
|
ExportBarItem(name: total.name, value: Double(total.count),
|
|
valueLabel: String(localized: "\(total.count)회"), color: total.color)
|
|
}
|
|
}
|
|
guard !items.isEmpty else { return nil }
|
|
return .bars(
|
|
title: title,
|
|
xLabel: type == .time ? String(localized: "시간(h)") : String(localized: "횟수"),
|
|
items: items
|
|
)
|
|
}
|
|
|
|
// 합계·평균 표 (미래 날짜/주로 평균이 희석되지 않게 경과분만 분모로)
|
|
let elapsedDays = max(
|
|
math.dayKeys(in: range).filter { math.dayRange(forKey: $0).lowerBound <= now }.count, 1
|
|
)
|
|
let elapsedWeeks = max(weekRanges.filter { $0.lowerBound <= now }.count, 1)
|
|
|
|
func valueLabel(_ value: Double, type: TrackingType) -> String {
|
|
type == .time ? Format.durationShort(value) : String(localized: "\(Int(value.rounded()))회")
|
|
}
|
|
func averageLabel(_ value: Double, type: TrackingType) -> String {
|
|
type == .time ? Format.durationShort(value) : Format.countAverage(value)
|
|
}
|
|
func tableRow(name: String, color: Color, symbol: String, total: Double, type: TrackingType) -> ExportTableData.Row {
|
|
var values = [valueLabel(total, type: type), averageLabel(total / Double(elapsedDays), type: type)]
|
|
if span == .month && !rolling {
|
|
values.append(averageLabel(total / Double(elapsedWeeks), type: type))
|
|
}
|
|
return ExportTableData.Row(symbol: symbol, color: color, name: name, values: values)
|
|
}
|
|
|
|
func summaryTable(byTag: Bool, title: String) -> ExportSectionData? {
|
|
var timeRows: [ExportTableData.Row] = []
|
|
var countRows: [ExportTableData.Row] = []
|
|
if byTag {
|
|
timeRows = orderedTagTotals
|
|
.filter { $0.seconds > 0 }
|
|
.sorted { $0.seconds > $1.seconds }
|
|
.map { tableRow(name: $0.name, color: $0.color, symbol: "tag.fill",
|
|
total: $0.seconds, type: .time) }
|
|
countRows = orderedTagTotals
|
|
.filter { $0.count > 0 }
|
|
.sorted { $0.count > $1.count }
|
|
.map { tableRow(name: $0.name, color: $0.color, symbol: "tag.fill",
|
|
total: Double($0.count), type: .count) }
|
|
} else {
|
|
let timeNames = Format.disambiguated(timeActions.map(\.name))
|
|
timeRows = zip(timeActions, timeNames)
|
|
.map { ($0, $1, agg.seconds(for: $0, in: range, now: now)) }
|
|
.sorted { $0.2 > $1.2 }
|
|
.map { tableRow(name: $0.1, color: $0.0.color, symbol: $0.0.symbolName,
|
|
total: $0.2, type: .time) }
|
|
let countNames = Format.disambiguated(countActions.map(\.name))
|
|
countRows = zip(countActions, countNames)
|
|
.map { ($0, $1, Double(agg.count(for: $0, in: range))) }
|
|
.sorted { $0.2 > $1.2 }
|
|
.map { tableRow(name: $0.1, color: $0.0.color, symbol: $0.0.symbolName,
|
|
total: $0.2, type: .count) }
|
|
}
|
|
guard !timeRows.isEmpty || !countRows.isEmpty else { return nil }
|
|
var columns = [String(localized: "합계"), String(localized: "하루 평균")]
|
|
if span == .month && !rolling { columns.append(String(localized: "주 평균")) }
|
|
var groups: [ExportTableData.Group] = []
|
|
if !timeRows.isEmpty { groups.append(.init(label: String(localized: "시간"), rows: timeRows)) }
|
|
if !countRows.isEmpty { groups.append(.init(label: String(localized: "횟수"), rows: countRows)) }
|
|
return .table(title: title, ExportTableData(columns: columns, groups: groups))
|
|
}
|
|
|
|
var sections: [ExportSectionData] = []
|
|
switch span {
|
|
case .day:
|
|
sections += [
|
|
tagBars(.time, title: String(localized: "꼬리표별 시간 비교")),
|
|
tagBars(.count, title: String(localized: "꼬리표별 횟수 비교")),
|
|
totalsBars(.time, title: String(localized: "행동별 시간 합계")),
|
|
totalsBars(.count, title: String(localized: "행동별 횟수 합계")),
|
|
].compactMap(\.self)
|
|
case .week:
|
|
sections += [
|
|
dailyLines(.time, title: String(localized: "행동별 시간 (일별)"), weekdayCategory: true),
|
|
dailyLines(.count, title: String(localized: "행동별 횟수 (일별)"), weekdayCategory: true),
|
|
totalsBars(.time, title: rolling
|
|
? String(localized: "지난 7일 시간 합계") : String(localized: "주간 시간 합계")),
|
|
totalsBars(.count, title: rolling
|
|
? String(localized: "지난 7일 횟수 합계") : String(localized: "주간 횟수 합계")),
|
|
summaryTable(byTag: false, title: String(localized: "행동별 합계·평균")),
|
|
summaryTable(byTag: true, title: String(localized: "꼬리표별 합계·평균")),
|
|
].compactMap(\.self)
|
|
case .month:
|
|
sections += [
|
|
weeklyLines(.time, title: String(localized: "행동별 시간 (주차별)")),
|
|
weeklyLines(.count, title: String(localized: "행동별 횟수 (주차별)")),
|
|
dailyLines(.time, title: String(localized: "행동별 시간 (일별)"), weekdayCategory: false),
|
|
dailyLines(.count, title: String(localized: "행동별 횟수 (일별)"), weekdayCategory: false),
|
|
totalsBars(.time, title: rolling
|
|
? String(localized: "지난 30일 시간 합계") : String(localized: "월간 시간 합계")),
|
|
totalsBars(.count, title: rolling
|
|
? String(localized: "지난 30일 횟수 합계") : String(localized: "월간 횟수 합계")),
|
|
summaryTable(byTag: false, title: String(localized: "행동별 합계·평균")),
|
|
summaryTable(byTag: true, title: String(localized: "꼬리표별 합계·평균")),
|
|
].compactMap(\.self)
|
|
}
|
|
|
|
let title: String
|
|
let periodLabel: String
|
|
switch span {
|
|
case .day:
|
|
title = String(localized: "하루 통계")
|
|
periodLabel = Format.fullDate(anchorDayKey)
|
|
case .week:
|
|
title = rolling ? String(localized: "지난 7일 통계") : String(localized: "주간 통계")
|
|
let keys = math.dayKeys(in: range)
|
|
periodLabel = keys.isEmpty ? "" : "\(Format.shortDate(keys.first!)) ~ \(Format.shortDate(keys.last!))"
|
|
case .month:
|
|
title = rolling ? String(localized: "지난 30일 통계") : String(localized: "월간 통계")
|
|
if rolling {
|
|
let keys = math.dayKeys(in: range)
|
|
periodLabel = keys.isEmpty ? "" : "\(Format.shortDate(keys.first!)) ~ \(Format.shortDate(keys.last!))"
|
|
} else {
|
|
periodLabel = anchorDayKey.formatted(.dateTime.year().month())
|
|
}
|
|
}
|
|
|
|
// 파일명 구분: 롤링은 span 대신 last7/last30 슬러그 (달력 주기 내보내기와 헷갈리지 않게)
|
|
let slugKind = rolling ? (span == .week ? "last7" : "last30") : span.rawValue
|
|
return ExportSnapshot(
|
|
title: title,
|
|
periodLabel: periodLabel,
|
|
filterNote: note,
|
|
hero: hero,
|
|
sections: sections,
|
|
fileSlug: String(localized: "통계-\(slugKind)-\(slugDate(anchorDayKey))")
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - 포스터 뷰 (렌더링 전용)
|
|
|
|
struct ExportPosterView: View {
|
|
let snapshot: ExportSnapshot
|
|
|
|
/// 포스터 고정 폭 (3배율 렌더링 → 1290px)
|
|
static let width: CGFloat = 430
|
|
private let outerPadding: CGFloat = 22
|
|
private let cardPadding: CGFloat = 14
|
|
/// 카드 안쪽 콘텐츠 폭 (타임테이블처럼 고정 좌표 계산이 필요한 뷰에 전달)
|
|
private var innerWidth: CGFloat { Self.width - outerPadding * 2 - cardPadding * 2 }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
header
|
|
heroRow
|
|
if snapshot.sections.isEmpty {
|
|
Text("표시할 기록이 없어요")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 44)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
ForEach(Array(snapshot.sections.enumerated()), id: \.offset) { _, section in
|
|
ExportSectionView(section: section, innerWidth: innerWidth)
|
|
}
|
|
footer
|
|
}
|
|
.padding(outerPadding)
|
|
.frame(width: Self.width, alignment: .leading)
|
|
.background(AppTheme.background)
|
|
}
|
|
|
|
// MARK: 헤더 / 요약 / 푸터
|
|
|
|
private var header: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(snapshot.title)
|
|
.font(.system(size: 25, weight: .bold))
|
|
Text(snapshot.periodLabel)
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
if let note = snapshot.filterNote {
|
|
Label(note, systemImage: "line.3.horizontal.decrease")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(AppTheme.green)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(AppTheme.green.opacity(0.12), in: Capsule())
|
|
.padding(.top, 3)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
VStack(spacing: 5) {
|
|
Image(systemName: "shoeprints.fill")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 40, height: 40)
|
|
.background(AppTheme.green, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
Text("하루 다님")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var heroRow: some View {
|
|
if !snapshot.hero.isEmpty {
|
|
ExportHeroRow(stats: snapshot.hero)
|
|
}
|
|
}
|
|
|
|
private var footer: some View {
|
|
VStack(spacing: 2) {
|
|
Rectangle()
|
|
.fill(
|
|
LinearGradient(colors: [AppTheme.green, AppTheme.yellow],
|
|
startPoint: .leading, endPoint: .trailing)
|
|
)
|
|
.frame(height: 3)
|
|
.clipShape(Capsule())
|
|
.padding(.bottom, 8)
|
|
Text("하루 다님 — 하루 습관 및 시간 추적")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(AppTheme.green)
|
|
Text("\(Date.now.formatted(.dateTime.year().month().day().hour().minute())) 내보냄")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 6)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - 요약 지표 카드 줄 (포스터·일기 요약 공용)
|
|
|
|
struct ExportHeroRow: View {
|
|
let stats: [ExportHeroStat]
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
ForEach(stats) { stat in
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(stat.value)
|
|
.font(.system(size: 16, weight: .bold).monospacedDigit())
|
|
.foregroundStyle(AppTheme.green)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.6)
|
|
Text(stat.title)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 섹션 카드 렌더러 (포스터·일기 요약 공용)
|
|
|
|
struct ExportSectionView: View {
|
|
let section: ExportSectionData
|
|
/// 카드 안쪽 콘텐츠 폭 (타임테이블처럼 고정 좌표 계산이 필요한 뷰에 전달)
|
|
let innerWidth: CGFloat
|
|
|
|
private let cardPadding: CGFloat = 14
|
|
|
|
var body: some View {
|
|
switch section {
|
|
case .timetable(let title, let subtitle, let data):
|
|
card(title, subtitle: subtitle) {
|
|
ExportTimetableView(data: data, width: innerWidth)
|
|
}
|
|
case .records(let title, let rows):
|
|
card(title) {
|
|
VStack(spacing: 10) {
|
|
ForEach(rows) { row in
|
|
recordRow(row)
|
|
}
|
|
}
|
|
}
|
|
case .bars(let title, let xLabel, let items):
|
|
card(title) {
|
|
barsChart(xLabel: xLabel, items: items)
|
|
}
|
|
case .lines(let data):
|
|
card(data.title) {
|
|
linesChart(data)
|
|
}
|
|
case .table(let title, let data):
|
|
card(title) {
|
|
tableView(data)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func card(_ title: String, subtitle: String? = nil,
|
|
@ViewBuilder content: () -> some View) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(.subheadline.weight(.semibold))
|
|
if let subtitle {
|
|
Text(subtitle)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
content()
|
|
}
|
|
.padding(cardPadding)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
|
|
private func recordRow(_ row: ExportRecordRow) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(safeSymbol: row.symbol)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 28, height: 28)
|
|
.background(row.color, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(row.name)
|
|
.font(.footnote.weight(.medium))
|
|
.lineLimit(1)
|
|
Text(row.detail)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
if !row.note.isEmpty {
|
|
Label(row.note, systemImage: "note.text")
|
|
.font(.caption2)
|
|
.foregroundStyle(AppTheme.yellow)
|
|
.lineLimit(2)
|
|
.padding(.top, 1)
|
|
}
|
|
}
|
|
Spacer(minLength: 6)
|
|
Text(row.value)
|
|
.font(.footnote.weight(.semibold).monospacedDigit())
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
}
|
|
|
|
private func barsChart(xLabel: String, items: [ExportBarItem]) -> some View {
|
|
Chart(items) { item in
|
|
BarMark(
|
|
x: .value(xLabel, item.value),
|
|
y: .value("이름", item.name)
|
|
)
|
|
.foregroundStyle(item.color)
|
|
.cornerRadius(4)
|
|
.annotation(position: .trailing) {
|
|
Text(item.valueLabel)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.chartXAxisLabel(xLabel)
|
|
.frame(height: CGFloat(items.count) * 38 + 30)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func linesChart(_ data: ExportLineChartData) -> some View {
|
|
let chart = Chart(data.points) { point in
|
|
switch data.xAxis {
|
|
case .category:
|
|
LineMark(
|
|
x: .value("구간", point.category ?? ""),
|
|
y: .value(data.yLabel, point.value)
|
|
)
|
|
.foregroundStyle(by: .value("행동", point.series))
|
|
.symbol(by: .value("행동", point.series))
|
|
.interpolationMethod(.monotone)
|
|
case .days:
|
|
LineMark(
|
|
x: .value("날짜", point.date ?? .now, unit: .day),
|
|
y: .value(data.yLabel, point.value)
|
|
)
|
|
.foregroundStyle(by: .value("행동", point.series))
|
|
.symbol(by: .value("행동", point.series))
|
|
.interpolationMethod(.monotone)
|
|
}
|
|
}
|
|
.chartForegroundStyleScale(domain: data.seriesNames, range: data.seriesColors)
|
|
.chartYAxisLabel(data.yLabel)
|
|
.frame(height: 210)
|
|
|
|
switch data.xAxis {
|
|
case .category(let order):
|
|
chart.chartXScale(domain: order)
|
|
case .days:
|
|
chart.chartXAxis {
|
|
AxisMarks { _ in
|
|
AxisGridLine()
|
|
AxisValueLabel(format: .dateTime.day())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func tableView(_ data: ExportTableData) -> some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
ForEach(Array(data.groups.enumerated()), id: \.offset) { index, group in
|
|
if index > 0 {
|
|
Divider()
|
|
}
|
|
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
|
|
GridRow {
|
|
Text(group.label)
|
|
.fontWeight(.semibold)
|
|
ForEach(Array(data.columns.enumerated()), id: \.offset) { _, column in
|
|
Text(column)
|
|
.gridColumnAlignment(.trailing)
|
|
}
|
|
}
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
ForEach(group.rows) { row in
|
|
GridRow {
|
|
HStack(spacing: 8) {
|
|
Image(safeSymbol: row.symbol)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 24, height: 24)
|
|
.background(row.color, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
|
Text(row.name)
|
|
.font(.footnote)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
ForEach(Array(row.values.enumerated()), id: \.offset) { valueIndex, value in
|
|
Text(value)
|
|
.font(.footnote.monospacedDigit())
|
|
.fontWeight(valueIndex == 0 ? .semibold : .regular)
|
|
.foregroundStyle(valueIndex == 0 ? AnyShapeStyle(AppTheme.green) : AnyShapeStyle(.secondary))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 타임테이블 (포스터 전용, 비인터랙티브)
|
|
|
|
private struct ExportTimetableView: View {
|
|
let data: ExportTimetableData
|
|
/// 사용 가능한 콘텐츠 폭 (포스터가 고정 폭이라 GeometryReader 없이 직접 계산)
|
|
let width: CGFloat
|
|
|
|
private var weekly: Bool { data.days.count > 1 }
|
|
private var hourHeight: CGFloat { weekly ? 18 : 26 }
|
|
private var hours: Int { max(data.hourHi - data.hourLo, 1) }
|
|
private var labelWidth: CGFloat { data.startMinute == 0 ? 24 : 34 }
|
|
private let spacing: CGFloat = 4
|
|
|
|
var body: some View {
|
|
let columnWidth = (width - labelWidth - spacing * CGFloat(data.days.count)) / CGFloat(data.days.count)
|
|
HStack(alignment: .top, spacing: spacing) {
|
|
VStack(spacing: 3) {
|
|
if weekly {
|
|
// 요일 라벨 줄 높이만큼 시간축을 내려 컬럼과 정렬 (verbatim: 추출 방지)
|
|
Text(verbatim: " ")
|
|
.font(.caption2)
|
|
}
|
|
hourLabels
|
|
}
|
|
ForEach(data.days) { day in
|
|
VStack(spacing: 3) {
|
|
if let label = day.label {
|
|
Text(label)
|
|
.font(.caption2.weight(day.emphasized ? .bold : .regular))
|
|
.foregroundStyle(day.emphasized ? AppTheme.green : .secondary)
|
|
}
|
|
column(day, columnWidth: columnWidth)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var hourLabels: some View {
|
|
VStack(alignment: .trailing, spacing: 0) {
|
|
ForEach(0..<hours, id: \.self) { offset in
|
|
Text(data.startMinute == 0
|
|
? String(format: "%02d", (data.startHour + data.hourLo + offset) % 24)
|
|
: String(format: "%02d:%02d", (data.startHour + data.hourLo + offset) % 24, data.startMinute))
|
|
.font(.system(size: 9).monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
.frame(height: hourHeight, alignment: .top)
|
|
}
|
|
}
|
|
.frame(width: labelWidth)
|
|
}
|
|
|
|
private func column(_ day: ExportTimetableData.Day, columnWidth: CGFloat) -> some View {
|
|
ZStack(alignment: .topLeading) {
|
|
VStack(spacing: 0) {
|
|
ForEach(0..<hours, id: \.self) { _ in
|
|
Rectangle()
|
|
.fill(Color.primary.opacity(0.08))
|
|
.frame(height: 0.5)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
ForEach(day.healthBlocks) { block in
|
|
healthBlockView(block, columnWidth: columnWidth)
|
|
}
|
|
ForEach(day.eventBlocks) { block in
|
|
eventBlockView(block, columnWidth: columnWidth)
|
|
}
|
|
ForEach(day.blocks) { block in
|
|
blockView(block, columnWidth: columnWidth)
|
|
}
|
|
ForEach(Array(day.markers.enumerated()), id: \.offset) { index, marker in
|
|
markerView(marker, index: index, columnWidth: columnWidth)
|
|
}
|
|
}
|
|
.frame(width: columnWidth, height: CGFloat(hours) * hourHeight)
|
|
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 6, style: .continuous))
|
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
|
}
|
|
|
|
private func blockView(_ 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.85))
|
|
.overlay(alignment: .topLeading) {
|
|
if !weekly && height >= 18 {
|
|
Label(block.name, systemImage: block.symbol)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
.lineLimit(1)
|
|
.padding(4)
|
|
} else if height >= 12 {
|
|
Image(safeSymbol: block.symbol)
|
|
.font(.system(size: 7, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.padding(2)
|
|
}
|
|
}
|
|
.frame(width: max(columnWidth - 4, 8), height: height)
|
|
.offset(x: 2, y: y)
|
|
}
|
|
|
|
/// 캘린더 일정 블록: 행동 블록(불투명 채움)과 **점선 테두리**로 구분한다.
|
|
/// 예전의 옅은 채움(0.14)+실선은 배경과 겹쳐 가시성이 너무 떨어졌다 —
|
|
/// 채움을 중간 농도(0.3)로 올려 눈에 띄게 하고, 구분은 색이 아니라 점선 윤곽이 담당한다.
|
|
private func eventBlockView(_ 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.3))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
|
.strokeBorder(block.color.opacity(0.9),
|
|
style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
|
|
)
|
|
.overlay(alignment: .topLeading) {
|
|
if !weekly && height >= 18 {
|
|
Label(block.name, systemImage: block.symbol)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(block.color)
|
|
.lineLimit(1)
|
|
.padding(4)
|
|
}
|
|
}
|
|
.frame(width: max(columnWidth - 4, 8), height: height)
|
|
.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)
|
|
let y = CGFloat(marker.frac - Double(data.hourLo)) * hourHeight - size / 2
|
|
return Circle()
|
|
.fill(marker.color)
|
|
.overlay(Circle().strokeBorder(.white.opacity(0.7), lineWidth: 1))
|
|
.frame(width: size, height: size)
|
|
.offset(x: min(x, columnWidth - size), y: max(y, 0))
|
|
}
|
|
}
|
|
|
|
// MARK: - 내보내기 시트
|
|
|
|
struct ExportImageSheet: View {
|
|
let snapshot: ExportSnapshot
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.colorScheme) private var envScheme
|
|
|
|
/// nil = 앱의 현재 테마를 따름
|
|
@State private var darkOverride: Bool?
|
|
@State private var rendered: UIImage?
|
|
@State private var shareURL: URL?
|
|
@State private var saveMessage: String?
|
|
@State private var saver = PhotoSaver()
|
|
|
|
private var isDark: Bool { darkOverride ?? (envScheme == .dark) }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
preview
|
|
controls
|
|
}
|
|
.background(AppTheme.background)
|
|
.navigationTitle("이미지로 내보내기")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("닫기") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.task(id: isDark) {
|
|
// 시트 전환 애니메이션이 끝난 뒤에 렌더링 (포스터 전체를 한 번에 그리므로)
|
|
try? await Task.sleep(for: .milliseconds(80))
|
|
render()
|
|
}
|
|
.alert(
|
|
"사진에 저장",
|
|
isPresented: Binding(
|
|
get: { saveMessage != nil },
|
|
set: { if !$0 { saveMessage = nil } }
|
|
)
|
|
) {
|
|
Button("확인") {}
|
|
} message: {
|
|
Text(saveMessage ?? "")
|
|
}
|
|
}
|
|
|
|
private var preview: some View {
|
|
ScrollView {
|
|
Group {
|
|
if let rendered {
|
|
Image(uiImage: rendered)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.shadow(color: .black.opacity(0.15), radius: 10, y: 4)
|
|
} else {
|
|
VStack(spacing: 10) {
|
|
ProgressView()
|
|
Text("이미지 생성 중…")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 100)
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
|
|
private var controls: some View {
|
|
VStack(spacing: 12) {
|
|
Picker("테마", selection: Binding(get: { isDark }, set: { darkOverride = $0 })) {
|
|
Text("라이트").tag(false)
|
|
Text("다크").tag(true)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
HStack(spacing: 10) {
|
|
Group {
|
|
if let shareURL, rendered != nil {
|
|
ShareLink(item: shareURL) {
|
|
Label("공유", systemImage: "square.and.arrow.up")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
} else {
|
|
Button {} label: {
|
|
Label("공유", systemImage: "square.and.arrow.up")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.disabled(true)
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Button {
|
|
savePhoto()
|
|
} label: {
|
|
Label("사진에 저장", systemImage: "photo.badge.arrow.down")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(rendered == nil)
|
|
}
|
|
.tint(AppTheme.green)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 12)
|
|
.background(AppTheme.surface)
|
|
}
|
|
|
|
/// 포스터를 3배율 PNG로 렌더링하고 공유용 임시 파일을 만든다.
|
|
/// 위젯 테마와 같은 방식으로 .environment(\.colorScheme)를 강제해 라이트/다크를 고정한다.
|
|
private func render() {
|
|
let poster = ExportPosterView(snapshot: snapshot)
|
|
.environment(\.colorScheme, isDark ? .dark : .light)
|
|
let renderer = ImageRenderer(content: poster)
|
|
renderer.scale = 3
|
|
renderer.isOpaque = true
|
|
guard let image = renderer.uiImage else { return }
|
|
rendered = image
|
|
guard let data = image.pngData() else { return }
|
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(snapshot.fileName)
|
|
try? data.write(to: url, options: .atomic)
|
|
shareURL = url
|
|
#if DEBUG
|
|
// 검증용: -exportDump YES → Documents/export-dump.png로 원본 해상도 저장
|
|
if UserDefaults.standard.bool(forKey: "exportDump"),
|
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
try? data.write(to: docs.appendingPathComponent("export-dump.png"), options: .atomic)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func savePhoto() {
|
|
guard let rendered else { return }
|
|
saver.save(rendered) { success in
|
|
saveMessage = success
|
|
? String(localized: "사진 앨범에 저장했어요.")
|
|
: String(localized: "저장하지 못했어요. 설정에서 사진 접근 권한을 확인해 주세요.")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// UIImageWriteToSavedPhotosAlbum의 target-selector 콜백을 받아 주는 헬퍼
|
|
private final class PhotoSaver: NSObject {
|
|
private var completion: ((Bool) -> Void)?
|
|
|
|
func save(_ image: UIImage, completion: @escaping (Bool) -> Void) {
|
|
self.completion = completion
|
|
UIImageWriteToSavedPhotosAlbum(
|
|
image, self,
|
|
#selector(image(_:didFinishSavingWithError:contextInfo:)), nil
|
|
)
|
|
}
|
|
|
|
@objc private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) {
|
|
completion?(error == nil)
|
|
completion = nil
|
|
}
|
|
}
|