// // 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 } /// 타임테이블 섹션만 다른 스냅숏의 것으로 교체. /// 일기의 날짜별 타임테이블 필터용 — 기록·통계 섹션은 전체(자신)를 유지한다. 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] = [] } /// 하루 시작 시각(시). 축 라벨은 (startHour + 오프셋) % 24 var startHour: Int /// 기록이 없는 위·아래 구간을 잘라낸 표시 범위 (오프셋 시간) 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 var duration: TimeInterval { range.upperBound.timeIntervalSince(range.lowerBound) } } private static func collectSegments( _ sessions: [TimeSession], allowed: Set, range: Range, 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.., range: Range ) -> [(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, 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, 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 startHour = math.calendar.component(.hour, from: math.dayRange(forKey: firstKey).lowerBound) // 일기 요약처럼 하루 전체를 고정으로 보여줄 때: 기록이 없어도 24시간 그리드를 그대로 반환 if !trimHours { return (ExportTimetableData(startHour: startHour, 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 subtitle: String? = (hourLo > 0 || hourHi < 24) ? String(localized: "기록이 있는 \(String(format: "%02d", (startHour + hourLo) % 24)):00 ~ \(String(format: "%02d", (startHour + hourHi) % 24)):00 구간만 표시") : nil return ( ExportTimetableData(startHour: startHour, hourLo: hourLo, hourHi: hourHi, days: days), subtitle ) } // MARK: 통계 탭 (하루 / 주간 / 월간) static func stats( span: StatSpan, anchorDayKey: Date, sessions: [TimeSession], entries: [CountEntry], orderedActions: [Action], excludedActionIDs: Set, 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) let anchor = math.dayRange(forKey: anchorDayKey).lowerBound let range: Range switch span { case .day: range = math.dayRange(containing: anchor) case .week: range = math.weekRange(containing: anchor) case .month: range = math.monthRange(containing: anchor) } 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)개")), ] // 월간 주 단위 범위 (주 시작 요일 설정 기준, 월 경계에서 잘림) var weekRanges: [Range] = [] if span == .month { var cursor = range.lowerBound while cursor < range.upperBound { let week = math.weekRange(containing: cursor) weekRanges.append(max(week.lowerBound, range.lowerBound).. ExportSectionData? { let actions = type == .time ? timeActions : countActions let items = actions .map { action -> 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: action.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 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 in actions { 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: action.name, value: value )) } } return .lines(ExportLineChartData( title: title, yLabel: type == .time ? String(localized: "시간(h)") : String(localized: "횟수"), seriesNames: actions.map(\.name), 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 } var points: [ExportLineChartData.Point] = [] var categories: [String] = [] for (index, weekRange) in weekRanges.enumerated() { let label = String(localized: "\(index + 1)주차") categories.append(label) for action in actions { 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: action.name, value: value )) } } return .lines(ExportLineChartData( title: title, yLabel: type == .time ? String(localized: "시간(h)") : String(localized: "횟수"), seriesNames: actions.map(\.name), seriesColors: actions.map(\.color), points: points, xAxis: .category(order: categories) )) } // 꼬리표별 집계 (통계 탭 tagStats와 동일 규칙: 꼬리표 없는 행동은 "꼬리표 없음") struct TagTotal { var color: Color var seconds: TimeInterval = 0 var count: Int = 0 } var tagTotals: [String: TagTotal] = [:] func tagNames(for action: Action) -> [(String, Color)] { if action.tags.isEmpty { return [(String(localized: "꼬리표 없음"), Color.gray)] } return action.sortedTags.map { ($0.name, $0.color) } } for item in segments { for (name, color) in tagNames(for: item.action) { tagTotals[name, default: TagTotal(color: color)].seconds += item.duration tagTotals[name]?.color = color } } for item in counts { for (name, color) in tagNames(for: item.action) { tagTotals[name, default: TagTotal(color: color)].count += item.entry.amount tagTotals[name]?.color = color } } func tagBars(_ type: TrackingType, title: String) -> ExportSectionData? { let items: [ExportBarItem] switch type { case .time: items = tagTotals .filter { $0.value.seconds > 0 } .sorted { $0.value.seconds > $1.value.seconds } .map { name, total in ExportBarItem(name: name, value: total.seconds / 3600, valueLabel: Format.durationShort(total.seconds), color: total.color) } case .count: items = tagTotals .filter { $0.value.count > 0 } .sorted { $0.value.count > $1.value.count } .map { name, total in ExportBarItem(name: 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 { 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 = tagTotals .filter { $0.value.seconds > 0 } .sorted { $0.value.seconds > $1.value.seconds } .map { tableRow(name: $0.key, color: $0.value.color, symbol: "tag.fill", total: $0.value.seconds, type: .time) } countRows = tagTotals .filter { $0.value.count > 0 } .sorted { $0.value.count > $1.value.count } .map { tableRow(name: $0.key, color: $0.value.color, symbol: "tag.fill", total: Double($0.value.count), type: .count) } } else { timeRows = timeActions .map { ($0, agg.seconds(for: $0, in: range, now: now)) } .sorted { $0.1 > $1.1 } .map { tableRow(name: $0.0.name, color: $0.0.color, symbol: $0.0.symbolName, total: $0.1, type: .time) } countRows = countActions .map { ($0, Double(agg.count(for: $0, in: range))) } .sorted { $0.1 > $1.1 } .map { tableRow(name: $0.0.name, color: $0.0.color, symbol: $0.0.symbolName, total: $0.1, type: .count) } } guard !timeRows.isEmpty || !countRows.isEmpty else { return nil } var columns = [String(localized: "합계"), String(localized: "하루 평균")] if span == .month { 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: String(localized: "주간 시간 합계")), totalsBars(.count, title: 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: String(localized: "월간 시간 합계")), totalsBars(.count, title: 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 = String(localized: "주간 통계") let keys = math.dayKeys(in: range) periodLabel = keys.isEmpty ? "" : "\(Format.shortDate(keys.first!)) ~ \(Format.shortDate(keys.last!))" case .month: title = String(localized: "월간 통계") periodLabel = anchorDayKey.formatted(.dateTime.year().month()) } return ExportSnapshot( title: title, periodLabel: periodLabel, filterNote: note, hero: hero, sections: sections, fileSlug: String(localized: "통계-\(span.rawValue)-\(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(systemName: 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(systemName: 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 let labelWidth: CGFloat = 24 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.. some View { ZStack(alignment: .topLeading) { VStack(spacing: 0) { ForEach(0.. 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(systemName: 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) } 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 } }