From cde4c4001ba3b986742441a48e67219d88704291 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Mon, 13 Jul 2026 02:05:16 +0900 Subject: [PATCH] feat(export): add high-res image export of records and stats as report posters Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- .../Haru_Danim.xcodeproj/project.pbxproj | 2 + .../HaruDanim/IOS/Views/ExportImageView.swift | 1293 +++++++++++++++++ myApp/HaruDanim/IOS/Views/HistoryView.swift | 32 +- myApp/HaruDanim/IOS/Views/StatsTabView.swift | 24 + 4 files changed, 1350 insertions(+), 1 deletion(-) create mode 100644 myApp/HaruDanim/IOS/Views/ExportImageView.swift diff --git a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj index 6a13c88..87acc82 100644 --- a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj +++ b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj @@ -600,6 +600,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "하루 다님"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "내보낸 리포트 이미지를 사진 앨범에 저장하기 위해 필요해요."; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -639,6 +640,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "하루 다님"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "내보낸 리포트 이미지를 사진 앨범에 저장하기 위해 필요해요."; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/myApp/HaruDanim/IOS/Views/ExportImageView.swift b/myApp/HaruDanim/IOS/Views/ExportImageView.swift new file mode 100644 index 0000000..bc0194f --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/ExportImageView.swift @@ -0,0 +1,1293 @@ +// +// 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 + + var fileName: String { "하루다님-\(fileSlug).png" } +} + +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] + } + + /// 하루 시작 시각(시). 축 라벨은 (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 + ) -> 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) { + 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: "기록-주간-\(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) { + 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: "기록-\(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 + ) -> (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 + )) + } + + 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 startHour = math.calendar.component(.hour, from: math.dayRange(forKey: firstKey).lowerBound) + 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) : String(format: "%.1f회", 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: "통계-\(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 { + card(String(localized: "기록")) { + Text("표시할 기록이 없어요") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 30) + } + } + ForEach(Array(snapshot.sections.enumerated()), id: \.offset) { _, section in + sectionView(section) + } + 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 { + HStack(spacing: 10) { + ForEach(snapshot.hero) { 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)) + } + } + } + } + + 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: 섹션 + + @ViewBuilder + private func sectionView(_ section: ExportSectionData) -> 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 { + // 요일 라벨 줄 높이만큼 시간축을 내려 컬럼과 정렬 + Text(" ") + .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) + } + + 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 + } +} diff --git a/myApp/HaruDanim/IOS/Views/HistoryView.swift b/myApp/HaruDanim/IOS/Views/HistoryView.swift index 50c9179..cab0950 100644 --- a/myApp/HaruDanim/IOS/Views/HistoryView.swift +++ b/myApp/HaruDanim/IOS/Views/HistoryView.swift @@ -69,11 +69,18 @@ struct HistoryView: View { #endif return .list }() - @State private var timetableWeekly = false + @State private var timetableWeekly: Bool = { + #if DEBUG + // 검증용: -historyWeekly YES → 타임테이블 일주일 보기로 시작 + if UserDefaults.standard.bool(forKey: "historyWeekly") { return true } + #endif + return false + }() @State private var editingSession: TimeSession? @State private var editingEntry: CountEntry? @State private var showingCalendar = false @State private var showingFilter = false + @State private var showingExport = false /// 필터는 "해제한 행동"만 저장 → 기본값이 전체 선택 (통계 탭과 동일한 모델) @State private var excludedActionIDs: Set = [] @@ -115,6 +122,13 @@ struct HistoryView: View { .navigationTitle("기록") .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingExport = true + } label: { + Image(systemName: "square.and.arrow.up") + } + } ToolbarItem(placement: .topBarTrailing) { Button { showingFilter = true @@ -139,6 +153,18 @@ struct HistoryView: View { excludedActionIDs: $excludedActionIDs ) } + .sheet(isPresented: $showingExport) { + // 지금 보고 있는 날짜/보기 모드/필터 그대로 스냅숏을 만들어 이미지로 내보낸다 + ExportImageSheet(snapshot: ExportBuilder.history( + dayKey: selectedDayKey, + weeklyTimetable: mode == .timetable && timetableWeekly, + sessions: sessions, + entries: entries, + orderedActions: allActions, + excludedActionIDs: excludedActionIDs, + math: math + )) + } .onAppear { consumePendingFilter() #if DEBUG @@ -152,6 +178,10 @@ struct HistoryView: View { if UserDefaults.standard.bool(forKey: "historyShowFilter") { showingFilter = true } + // 검증용: -showExport YES → 내보내기 시트 표시 + if UserDefaults.standard.bool(forKey: "showExport") { + showingExport = true + } #endif } .onChange(of: router.pendingHistoryActionID) { diff --git a/myApp/HaruDanim/IOS/Views/StatsTabView.swift b/myApp/HaruDanim/IOS/Views/StatsTabView.swift index 09ab371..5998960 100644 --- a/myApp/HaruDanim/IOS/Views/StatsTabView.swift +++ b/myApp/HaruDanim/IOS/Views/StatsTabView.swift @@ -37,6 +37,7 @@ struct StatsTabView: View { }() @State private var anchorDayKey: Date = DayMath().dayKey(for: .now) @State private var showingFilter = false + @State private var showingExport = false /// 필터는 "해제한 행동"만 저장 → 기본값이 전체 선택이고, 새로 만든 행동도 자동 포함됨. /// 꼬리표는 시트에서 소속 행동 일괄 토글용일 뿐, 판정은 행동 단위로만 한다. @State private var excludedActionIDs: Set = [] @@ -101,6 +102,13 @@ struct StatsTabView: View { .navigationTitle("통계") .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingExport = true + } label: { + Image(systemName: "square.and.arrow.up") + } + } ToolbarItem(placement: .topBarTrailing) { Button { showingFilter = true @@ -119,6 +127,18 @@ struct StatsTabView: View { excludedActionIDs: $excludedActionIDs ) } + .sheet(isPresented: $showingExport) { + // 지금 보고 있는 기간/필터 그대로 스냅숏을 만들어 이미지로 내보낸다 + ExportImageSheet(snapshot: ExportBuilder.stats( + span: span, + anchorDayKey: anchorDayKey, + sessions: sessions, + entries: entries, + orderedActions: allActions, + excludedActionIDs: excludedActionIDs, + math: math + )) + } .onAppear { consumePendingFilter() #if DEBUG @@ -132,6 +152,10 @@ struct StatsTabView: View { if UserDefaults.standard.bool(forKey: "statShowFilter") { showingFilter = true } + // 검증용: -showExport YES → 내보내기 시트 표시 + if UserDefaults.standard.bool(forKey: "showExport") { + showingExport = true + } #endif } .onChange(of: router.pendingStatsActionID) {