mycode/myApp/HaruDanim/IOS/Views/ExportImageView.swift
songyc macbook 8b1bc44f67 fix+feat(1.5-p7): 소킹 실측 2건 수정 + 추가 2건 — 빌드 5
- 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
2026-08-22 23:24:13 +09:00

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
}
}