// // DataResetView.swift // Haru_Danim // // 데이터 정리·초기화 (1.4, 설정 → 데이터). 무료 — 자기 데이터에 대한 접근권. // 범위: 전체 / 처음부터 지정한 날짜까지(기록·일기만, 구조 보존 — DataReset 참고). // 삭제 전 백업(CSV·일기 PDF·정리 리포트)을 같은 화면에서 만들어 공유하고, // 2단계 확인(다이얼로그 → 얼럿)을 거쳐 삭제한다. 삭제 코어는 DataReset, // 백업 부품은 CSVExport·DiaryExportRenderer·ArchiveReport 재사용. // import SwiftUI import SwiftData struct DataResetView: View { @Environment(\.modelContext) private var context @Environment(\.dismiss) private var dismiss @AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = "" private enum RangeMode: String, CaseIterable, Identifiable { case all, upTo var id: String { rawValue } var label: String { switch self { case .all: return String(localized: "전체") case .upTo: return String(localized: "날짜 지정") } } } private enum ReportFormat: String, CaseIterable, Identifiable { case pdf, image var id: String { rawValue } var label: String { switch self { case .pdf: return "PDF" case .image: return String(localized: "이미지") } } } @State private var rangeMode: RangeMode = .all @State private var endDate: Date = Calendar.current.date(byAdding: .month, value: -3, to: .now) ?? .now @State private var includeCSV = true @State private var includeDiary = true @State private var includeReport = true @State private var reportFormat: ReportFormat = .pdf @State private var isGenerating = false @State private var backupURLs: [URL] = [] @State private var confirmingDelete = false @State private var finalConfirm = false @State private var isDeleting = false @State private var deletedCounts: DataReset.Counts? private var math: DayMath { DayMath() } /// 선택한 달력일(포함)까지 — DatePicker의 달력일을 키로 직접 사용 (§4.1) private var endKey: Date { math.calendar.startOfDay(for: endDate) } private var upperBound: Date { math.dayRange(forKey: endKey).upperBound } /// 백업(CSV·리포트)용 범위 — 삭제와 달리 경계 걸침 세션까지 담는 게 안전해 겹침 포함 private var backupRange: Range? { rangeMode == .all ? nil : Date.distantPast.. if rangeMode == .all { descriptor = FetchDescriptor() } else { let upper = upperBound descriptor = FetchDescriptor( predicate: #Predicate { ($0.endAt ?? farFuture) <= upper } ) } return (try? context.fetchCount(descriptor)) ?? 0 } private var targetEntryCount: Int { let descriptor: FetchDescriptor if rangeMode == .all { descriptor = FetchDescriptor() } else { let upper = upperBound descriptor = FetchDescriptor(predicate: #Predicate { $0.timestamp < upper }) } return (try? context.fetchCount(descriptor)) ?? 0 } private var targetDiaryCount: Int { let descriptor: FetchDescriptor if rangeMode == .all { descriptor = FetchDescriptor() } else { let key = endKey descriptor = FetchDescriptor(predicate: #Predicate { $0.dayKey <= key }) } return (try? context.fetchCount(descriptor)) ?? 0 } private var targetActionCount: Int { (try? context.fetchCount(FetchDescriptor())) ?? 0 } private var targetGoalCount: Int { (try? context.fetchCount(FetchDescriptor())) ?? 0 } var body: some View { List { if let counts = deletedCounts { doneSection(counts) } else { rangeSection targetSection backupSection deleteSection } } .tint(AppTheme.green) .scrollContentBackground(.hidden) .background(AppTheme.background) .navigationTitle("데이터 정리·초기화") .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .tabBar) .onChange(of: rangeMode) { backupURLs = [] } .onChange(of: endDate) { backupURLs = [] } .onChange(of: includeCSV) { backupURLs = [] } .onChange(of: includeDiary) { backupURLs = [] } .onChange(of: includeReport) { backupURLs = [] } .onChange(of: reportFormat) { backupURLs = [] } .confirmationDialog( rangeMode == .all ? String(localized: "모든 데이터를 삭제할까요?") : String(localized: "\(Format.fullDate(endKey))까지의 기록·일기를 삭제할까요?"), isPresented: $confirmingDelete, titleVisibility: .visible ) { Button("삭제 진행", role: .destructive) { finalConfirm = true } } message: { Text(rangeMode == .all ? "행동·꼬리표·목표·다짐·기록·일기·양식이 모두 삭제돼요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요." : "지정한 날짜까지의 시간·횟수 기록과 일기가 삭제돼요. 과거 기록이 사라지면 다짐의 연속 달성 기록이 끊겨요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요.") } .alert("정말 삭제할까요?", isPresented: $finalConfirm) { Button("취소", role: .cancel) {} Button("삭제", role: .destructive) { performDelete() } } message: { Text("삭제는 되돌릴 수 없어요. 백업 파일을 저장했는지 다시 확인해 주세요.") } #if DEBUG .onAppear { // 검증용: -dataResetRangeDays → '날짜 지정'(오늘-N일까지)로 시작 let days = UserDefaults.standard.integer(forKey: "dataResetRangeDays") if days > 0 { rangeMode = .upTo endDate = math.calendar.date(byAdding: .day, value: -days, to: .now) ?? .now } // 검증용: -dataResetBackupRun pdf|image → 백업 3종 자동 생성 후 Documents 복사 if let raw = UserDefaults.standard.string(forKey: "dataResetBackupRun") { includeCSV = true includeDiary = true includeReport = true reportFormat = raw == "image" ? .image : .pdf makeBackups(copyToDocuments: true) } } #endif } // MARK: 섹션들 private var rangeSection: some View { Section { Picker("범위", selection: $rangeMode) { ForEach(RangeMode.allCases) { mode in Text(mode.label).tag(mode) } } .pickerStyle(.segmented) if rangeMode == .upTo { DatePicker("까지", selection: $endDate, in: ...Date.now, displayedComponents: .date) } } header: { Text("정리할 범위") } footer: { Text(rangeMode == .all ? "모든 행동·꼬리표·목표·다짐·기록·일기·양식을 삭제하고 처음 상태로 돌아가요. 앱 설정(테마·하루 시작 시간 등)과 프리미엄은 그대로예요." : "처음부터 지정한 날짜(포함)까지의 시간·횟수 기록과 일기만 삭제해요. 행동·꼬리표·목표·다짐과 양식 라이브러리는 남아요. 지정한 날짜에 걸쳐 있는 측정과 진행 중인 측정은 남아요.") } } private var targetSection: some View { Section("삭제 대상") { countRow("timer", String(localized: "시간 기록"), String(localized: "\(targetSessionCount)건")) countRow("number", String(localized: "횟수 기록"), String(localized: "\(targetEntryCount)건")) countRow("book.closed.fill", String(localized: "일기"), String(localized: "\(targetDiaryCount)일")) if rangeMode == .all { countRow("figure.walk", String(localized: "행동"), String(localized: "\(targetActionCount)개")) countRow("flag.checkered", String(localized: "목표"), String(localized: "\(targetGoalCount)개")) } } } private var backupSection: some View { Section { Toggle(isOn: $includeCSV) { Label("CSV 내보내기", systemImage: "tablecells") } Toggle(isOn: $includeDiary) { Label("일기 PDF", systemImage: "book.closed") } Toggle(isOn: $includeReport) { Label("정리 리포트", systemImage: "doc.richtext") } if includeReport { Picker("리포트 형식", selection: $reportFormat) { ForEach(ReportFormat.allCases) { format in Text(format.label).tag(format) } } .pickerStyle(.segmented) } if isGenerating { HStack { ProgressView() Text("백업 만드는 중…") .foregroundStyle(.secondary) } } else if backupURLs.isEmpty { if includeCSV || includeDiary || includeReport { Button { makeBackups() } label: { Label("백업 파일 만들기", systemImage: "square.and.arrow.down.on.square") .frame(maxWidth: .infinity) } } } else { ShareLink(items: backupURLs) { Label("백업 파일 \(backupURLs.count)개 공유", systemImage: "square.and.arrow.up") .frame(maxWidth: .infinity) } Button("다시 만들기") { backupURLs = [] } .frame(maxWidth: .infinity) } } header: { Text("삭제 전 백업") } footer: { Text("CSV는 '데이터 가져오기'로 복원할 수 있는 원본 백업이에요. 정리 리포트는 삭제할 기록을 한눈에 정리한 문서예요(기간·행동별 누적·꼬리표·월별·목표 이력). 백업을 켰다면 파일을 만들어 저장한 뒤에 삭제할 수 있어요.") } } private var deleteSection: some View { Section { if isDeleting { HStack { ProgressView() Text("삭제하는 중…") .foregroundStyle(.secondary) } } else { Button(role: .destructive) { confirmingDelete = true } label: { Label(rangeMode == .all ? "전체 데이터 삭제…" : "지정한 날짜까지 삭제…", systemImage: "trash") .frame(maxWidth: .infinity) } .disabled(!canDelete) } } footer: { if !canDelete && !isDeleting && deletedCounts == nil { Text("먼저 위에서 백업 파일을 만들어 주세요. 백업 없이 삭제하려면 백업 항목을 모두 꺼요.") } } } private func doneSection(_ counts: DataReset.Counts) -> some View { Section { Label { Text("삭제를 마쳤어요") .font(.headline) } icon: { Image(systemName: "checkmark.circle.fill") .foregroundStyle(AppTheme.green) } countRow("timer", String(localized: "시간 기록"), String(localized: "\(counts.sessions)건")) countRow("number", String(localized: "횟수 기록"), String(localized: "\(counts.entries)건")) countRow("book.closed.fill", String(localized: "일기"), String(localized: "\(counts.diaryEntries)일")) if counts.actions > 0 || counts.goals > 0 { countRow("figure.walk", String(localized: "행동"), String(localized: "\(counts.actions)개")) countRow("flag.checkered", String(localized: "목표"), String(localized: "\(counts.goals)개")) } Button("완료") { dismiss() } .frame(maxWidth: .infinity) } footer: { Text("iCloud 동기화를 켰다면 다른 기기에는 잠시 뒤에 반영돼요.") } } private func countRow(_ symbol: String, _ title: String, _ value: String) -> some View { HStack { Label { Text(title) } icon: { Image(systemName: symbol) .foregroundStyle(AppTheme.green) } Spacer() Text(value).foregroundStyle(.secondary) } } // MARK: 백업 생성 (부품 재사용 — CSVExport·DiaryExportRenderer·ArchiveReport) private func makeBackups(copyToDocuments: Bool = false) { isGenerating = true Task { @MainActor in try? await Task.sleep(for: .milliseconds(60)) var urls: [URL] = [] do { let actions = try context.fetch( FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]) ) let sessions = try context.fetch( FetchDescriptor(sortBy: [SortDescriptor(\.startAt)]) ) let entries = try context.fetch( FetchDescriptor(sortBy: [SortDescriptor(\.timestamp)]) ) let goals = try context.fetch(FetchDescriptor( sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)] )) let diaryDescriptor: FetchDescriptor = { if rangeMode == .all { return FetchDescriptor(sortBy: [SortDescriptor(\.dayKey)]) } let key = endKey return FetchDescriptor( predicate: #Predicate { $0.dayKey <= key }, sortBy: [SortDescriptor(\.dayKey)] ) }() let diaryEntries = try context.fetch(diaryDescriptor).filter(\.hasContent) if includeCSV { // ⚠️ CSVExport.fileSuffix(for:)는 dayKeys 순회라 distantPast 범위 금지 — // '처음~까지'는 끝 날짜만 접미로 표기한다 var suffix = "" if rangeMode == .upTo { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyyMMdd" suffix = "-upto-\(formatter.string(from: endKey))" } urls += CSVExport.makeFiles( actions: actions, sessions: sessions, entries: entries, goals: goals, range: backupRange, fileSuffix: suffix ) } if includeDiary, !diaryEntries.isEmpty { let orderedActions = LocalPrefs.orderedActions(actions, raw: actionOrderRaw) let templates = try context.fetch(FetchDescriptor()) let renderer = DiaryExportRenderer( context: context, orderedActions: orderedActions, templates: templates, math: math ) let slug = String(localized: "일기-정리-\(DiaryExportRenderer.slugDate(diaryEntries.first!.dayKey))~\(DiaryExportRenderer.slugDate(diaryEntries.last!.dayKey))") if let url = renderer.pdfURL(entries: diaryEntries, fileSlug: slug) { urls.append(url) } } if includeReport { if let url = ArchiveReport.makeURL( format: reportFormat == .pdf ? .pdf : .image, actions: actions, sessions: sessions, entries: entries, goals: goals, diaryDayKeys: diaryEntries.map(\.dayKey), range: backupRange, math: math ) { urls.append(url) } } } catch { urls = [] } backupURLs = urls isGenerating = false #if DEBUG if copyToDocuments, let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { for url in urls { let dest = docs.appendingPathComponent(url.lastPathComponent) try? FileManager.default.removeItem(at: dest) try? FileManager.default.copyItem(at: url, to: dest) } } #endif } } // MARK: 삭제 실행 private func performDelete() { isDeleting = true Task { @MainActor in try? await Task.sleep(for: .milliseconds(60)) do { let counts: DataReset.Counts if rangeMode == .all { counts = try DataReset.deleteAll(context: context) } else { counts = try DataReset.deleteRecords(upToDayKey: endKey, context: context, math: math) } // 저장 + 위젯·워치·LiveActivity 동기화 (진행 중 측정도 전체 삭제면 함께 정리됨) DataChange.commit(context: context) deletedCounts = counts } catch { deletedCounts = nil } isDeleting = false } } }