// // DataExportView.swift // Haru_Danim // // 원시 데이터 내보내기 (설정 → 데이터). // 모든 기록을 CSV 파일로 만들어 공유한다 — 백업·이동·스프레드시트 분석용. // 리포트 이미지(ExportImageView)와 달리 가공 없는 원본 값을 담는다. // 무료 사용자도 사용 가능 (자기 데이터에 대한 접근권). // 1.4: 구간 지정 옵션 — 기록(시간·횟수)만 구간으로 거르고 행동·목표·다짐 목록은 // 항상 전체를 담는다. CSV 생성 코어는 CSVExport(초기화 백업과 공유)로 추출. // import SwiftUI import SwiftData struct DataExportView: View { @Query(sort: \Action.createdAt) private var actions: [Action] @Query(sort: \TimeSession.startAt) private var sessions: [TimeSession] @Query(sort: \CountEntry.timestamp) private var entries: [CountEntry] @Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal] private enum RangeMode: String, CaseIterable, Identifiable { case all, custom var id: String { rawValue } var label: String { switch self { case .all: return String(localized: "전체") case .custom: return String(localized: "직접 지정") } } } @State private var rangeMode: RangeMode = .all @State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -30, to: .now) ?? .now @State private var endDate: Date = .now @State private var isRendering = false @State private var resultURLs: [URL] = [] private var math: DayMath { DayMath() } /// 선택한 달력일 구간 → 논리적 하루 경계 범위 (§4.1: 달력일은 startOfDay를 키로 직접 사용) private var selectedRange: Range? { guard rangeMode == .custom else { return nil } let startKey = math.calendar.startOfDay(for: min(startDate, endDate)) let endKey = math.calendar.startOfDay(for: max(startDate, endDate)) return math.dayRange(forKey: startKey).lowerBound.. range.lowerBound } } private var visibleEntryCount: Int { guard let range = selectedRange else { return entries.count } return entries.count { range.contains($0.timestamp) } } var body: some View { List { Section { Picker("구간", selection: $rangeMode) { ForEach(RangeMode.allCases) { mode in Text(mode.label).tag(mode) } } .pickerStyle(.segmented) if rangeMode == .custom { DatePicker("부터", selection: $startDate, in: ...endDate, displayedComponents: .date) DatePicker("까지", selection: $endDate, in: startDate..., displayedComponents: .date) } } header: { Text("내보낼 구간") } footer: { if rangeMode == .custom { Text("시간·횟수 기록만 구간으로 걸러져요. 행동·목표·다짐 목록은 항상 전체가 담겨요 (가져오기에서 기록을 행동과 연결하는 데 필요해요).") } } Section { row("figure.walk", String(localized: "행동"), String(localized: "\(actions.count)개")) row("timer", String(localized: "시간 기록"), String(localized: "\(visibleSessionCount)건")) row("number", String(localized: "횟수 기록"), String(localized: "\(visibleEntryCount)건")) row("flag.checkered", String(localized: "목표·다짐"), String(localized: "\(goals.count)개")) } header: { Text("내보낼 데이터") } footer: { Text("행동·시간 기록·횟수 기록·목표·다짐을 각각 CSV 파일로 만들어요. 스프레드시트에서 열거나 백업으로 보관할 수 있어요. 시각은 ISO 8601 형식이에요.") } Section { if isRendering { HStack { ProgressView() Text("만드는 중…") .foregroundStyle(.secondary) } } else if resultURLs.isEmpty { Button { export() } label: { Label("CSV 만들기", systemImage: "tablecells") .frame(maxWidth: .infinity) } } else { ShareLink(items: resultURLs) { Label("파일 \(resultURLs.count)개 공유", systemImage: "square.and.arrow.up") .frame(maxWidth: .infinity) } Button("다시 만들기") { resultURLs = [] } .frame(maxWidth: .infinity) } } } .tint(AppTheme.green) .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("데이터 내보내기") .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .tabBar) .onChange(of: rangeMode) { resultURLs = [] } .onChange(of: startDate) { resultURLs = [] } .onChange(of: endDate) { resultURLs = [] } #if DEBUG .onAppear { // 검증용: -dataExportRangeDays → 최근 N일 구간 지정 상태로 시작 let rangeDays = UserDefaults.standard.integer(forKey: "dataExportRangeDays") if rangeDays > 0 { rangeMode = .custom startDate = math.calendar.date(byAdding: .day, value: -(rangeDays - 1), to: .now) ?? .now endDate = .now } // 검증용: -dataExportRun YES → 자동 생성 후 Documents에 결과 복사 if UserDefaults.standard.bool(forKey: "dataExportRun") { export() } } #endif } private func row(_ symbol: String, _ title: String, _ count: String) -> some View { HStack { Label { Text(title) } icon: { Image(systemName: symbol) .foregroundStyle(AppTheme.green) } Spacer() Text(count).foregroundStyle(.secondary) } } // MARK: CSV 생성 (코어는 CSVExport — 초기화 백업과 공유) private func export() { isRendering = true Task { @MainActor in let range = selectedRange let suffix = range.map { CSVExport.fileSuffix(for: $0, math: math) } ?? "" resultURLs = CSVExport.makeFiles( actions: actions, sessions: sessions, entries: entries, goals: goals, range: range, fileSuffix: suffix ) isRendering = false #if DEBUG if UserDefaults.standard.bool(forKey: "dataExportRun"), let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { for url in resultURLs { let dest = docs.appendingPathComponent(url.lastPathComponent) try? FileManager.default.removeItem(at: dest) try? FileManager.default.copyItem(at: url, to: dest) } } #endif } } }