// // 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: "날짜 지정") } } } /// 정리 대상 (1.4(2)): 기록·일기 전부 vs 일기만. /// 일기만 모드는 사진·필기로 커진 일기 용량만 줄이고 기록·구조는 남긴다 private enum ResetScope: String, CaseIterable, Identifiable { case recordsAndDiary, diaryOnly var id: String { rawValue } var label: String { switch self { case .recordsAndDiary: return String(localized: "기록과 일기") case .diaryOnly: 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 scope: ResetScope = .recordsAndDiary @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: scope) { backupURLs = [] } .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( confirmTitle, isPresented: $confirmingDelete, titleVisibility: .visible ) { Button("삭제 진행", role: .destructive) { finalConfirm = true } } message: { Text(confirmMessage) } .alert("정말 삭제할까요?", isPresented: $finalConfirm) { Button("취소", role: .cancel) {} Button("삭제", role: .destructive) { performDelete() } } message: { Text("삭제는 되돌릴 수 없어요. 백업 파일을 저장했는지 다시 확인해 주세요.") } #if DEBUG .onAppear { // 검증용: -dataResetScope diary → '일기만' 선택 상태로 시작 if UserDefaults.standard.string(forKey: "dataResetScope") == "diary" { scope = .diaryOnly } // 검증용: -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: $scope.animation()) { ForEach(ResetScope.allCases) { scope in Text(scope.label).tag(scope) } } .pickerStyle(.segmented) 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: { switch (scope, rangeMode) { case (.recordsAndDiary, .all): Text("모든 행동·꼬리표·목표·다짐·기록·일기·양식을 삭제하고 처음 상태로 돌아가요. 앱 설정(테마·하루 시작 시간 등)과 프리미엄은 그대로예요.") case (.recordsAndDiary, .upTo): Text("처음부터 지정한 날짜(포함)까지의 시간·횟수 기록과 일기만 삭제해요. 행동·꼬리표·목표·다짐과 양식 라이브러리는 남아요. 지정한 날짜에 걸쳐 있는 측정과 진행 중인 측정은 남아요.") case (.diaryOnly, .all): Text("모든 일기(필기·사진·할 일 포함)를 삭제해요. 시간·횟수 기록과 행동·목표, 일기 양식은 그대로 남아요 — 사진·필기로 커진 일기 용량만 줄일 때 좋아요.") case (.diaryOnly, .upTo): Text("처음부터 지정한 날짜(포함)까지의 일기만 삭제해요. 시간·횟수 기록과 행동·목표, 일기 양식은 그대로 남아요.") } } } private var targetSection: some View { Section("삭제 대상") { if scope == .recordsAndDiary { countRow("timer", String(localized: "시간 기록"), String(localized: "\(targetSessionCount)건")) countRow("number", String(localized: "횟수 기록"), String(localized: "\(targetEntryCount)건")) } countRow("book.closed.fill", String(localized: "일기"), String(localized: "\(targetDiaryCount)일")) if scope == .recordsAndDiary && rangeMode == .all { countRow("figure.walk", String(localized: "행동"), String(localized: "\(targetActionCount)개")) countRow("flag.checkered", String(localized: "목표"), String(localized: "\(targetGoalCount)개")) } } } private var backupSection: some View { Section { // 일기만 모드에서는 기록 백업(CSV·정리 리포트) 토글을 숨긴다 — 대상이 아님 if scope == .recordsAndDiary { Toggle(isOn: $includeCSV) { Label("CSV 내보내기", systemImage: "tablecells") } } Toggle(isOn: $includeDiary) { Label("일기 PDF", systemImage: "book.closed") } if scope == .recordsAndDiary { 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 wantsBackup { 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(scope == .diaryOnly ? "삭제할 일기들을 PDF 하나로 만들어 보관할 수 있어요. 백업을 켰다면 파일을 만들어 저장한 뒤에 삭제할 수 있어요." : "CSV는 '데이터 가져오기'로 복원할 수 있는 원본 백업이에요. 정리 리포트는 삭제할 기록을 한눈에 정리한 문서예요(기간·행동별 누적·꼬리표·월별·목표 이력). 백업을 켰다면 파일을 만들어 저장한 뒤에 삭제할 수 있어요.") } } private var deleteSection: some View { Section { if isDeleting { HStack { ProgressView() Text("삭제하는 중…") .foregroundStyle(.secondary) } } else { Button(role: .destructive) { confirmingDelete = true } label: { Label(deleteButtonTitle, 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) } if scope == .recordsAndDiary { 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 var confirmTitle: String { switch (scope, rangeMode) { case (.recordsAndDiary, .all): return String(localized: "모든 데이터를 삭제할까요?") case (.recordsAndDiary, .upTo): return String(localized: "\(Format.fullDate(endKey))까지의 기록·일기를 삭제할까요?") case (.diaryOnly, .all): return String(localized: "모든 일기를 삭제할까요?") case (.diaryOnly, .upTo): return String(localized: "\(Format.fullDate(endKey))까지의 일기를 삭제할까요?") } } private var confirmMessage: String { switch (scope, rangeMode) { case (.recordsAndDiary, .all): return String(localized: "행동·꼬리표·목표·다짐·기록·일기·양식이 모두 삭제돼요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요.") case (.recordsAndDiary, .upTo): return String(localized: "지정한 날짜까지의 시간·횟수 기록과 일기가 삭제돼요. 과거 기록이 사라지면 다짐의 연속 달성 기록이 끊겨요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요.") case (.diaryOnly, _): return String(localized: "일기의 필기·사진·할 일이 함께 삭제돼요. 시간·횟수 기록과 행동·목표, 일기 양식은 남아요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요.") } } private var deleteButtonTitle: String { switch (scope, rangeMode) { case (.recordsAndDiary, .all): return String(localized: "전체 데이터 삭제…") case (.recordsAndDiary, .upTo): return String(localized: "지정한 날짜까지 삭제…") case (.diaryOnly, .all): return String(localized: "모든 일기 삭제…") case (.diaryOnly, .upTo): return String(localized: "지정한 날짜까지 일기 삭제…") } } 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 scope == .recordsAndDiary && 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 scope == .recordsAndDiary && 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 switch (scope, rangeMode) { case (.diaryOnly, .all): counts = try DataReset.deleteDiaries(upToDayKey: nil, context: context) case (.diaryOnly, .upTo): counts = try DataReset.deleteDiaries(upToDayKey: endKey, context: context) case (.recordsAndDiary, .all): counts = try DataReset.deleteAll(context: context) case (.recordsAndDiary, .upTo): counts = try DataReset.deleteRecords(upToDayKey: endKey, context: context, math: math) } // 저장 + 위젯·워치·LiveActivity 동기화 (진행 중 측정도 전체 삭제면 함께 정리됨) DataChange.commit(context: context) deletedCounts = counts } catch { deletedCounts = nil } isDeleting = false } } }