- DataReset 코어: 범위 삭제(완전 포함 세션만·경계 걸침/진행 중 보존·일기 dayKey≤키·구조/양식 보존), 전체 초기화(cascade+고아 정리+LocalPrefs 6키), 컨텍스트 경유 개별 삭제(CloudKit 전파 안전) - ArchiveReport: 삭제 범위 총정리 다페이지 포스터(타이틀+기간(첫~마지막 기록일)+히어로+행동별 누적 표+꼬리표 합계+월별 표+목표 이력, 24행 청크) → PDF/스티치 PNG, ExportPosterView 재사용 - DataResetView(설정→데이터, 무료): 범위 세그먼트+삭제 대상 fetchCount+백업 토글 3종+2단계 확인(다이얼로그→얼럿)+iCloud·연속 끊김 경고+완료 요약 - 검증: -dataResetTest 인메모리 18건 ALL PASS, -dataResetBackupRun로 CSV 5종+3페이지 리포트 PDF 실물 확인(기간 라벨 2025-03-23~오늘), 화면 UI 확인 - 신규 인자: -dataResetPreview·-dataResetBackupRun pdf|image·-dataResetRangeDays·-dataResetTest Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
437 lines
19 KiB
Swift
437 lines
19 KiB
Swift
//
|
||
// 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<Date>? {
|
||
rangeMode == .all ? nil : Date.distantPast..<upperBound
|
||
}
|
||
|
||
/// 백업을 하나도 안 만들거나, 만들었을 때만 삭제로 진행 가능
|
||
private var canDelete: Bool {
|
||
let wantsBackup = includeCSV || includeDiary || includeReport
|
||
return (!wantsBackup || !backupURLs.isEmpty) && !isGenerating && !isDeleting
|
||
&& deletedCounts == nil
|
||
}
|
||
|
||
// MARK: 삭제 대상 집계 (fetchCount — 전량 로드 없음)
|
||
|
||
private var targetSessionCount: Int {
|
||
let farFuture = Date.distantFuture
|
||
let descriptor: FetchDescriptor<TimeSession>
|
||
if rangeMode == .all {
|
||
descriptor = FetchDescriptor<TimeSession>()
|
||
} else {
|
||
let upper = upperBound
|
||
descriptor = FetchDescriptor<TimeSession>(
|
||
predicate: #Predicate { ($0.endAt ?? farFuture) <= upper }
|
||
)
|
||
}
|
||
return (try? context.fetchCount(descriptor)) ?? 0
|
||
}
|
||
|
||
private var targetEntryCount: Int {
|
||
let descriptor: FetchDescriptor<CountEntry>
|
||
if rangeMode == .all {
|
||
descriptor = FetchDescriptor<CountEntry>()
|
||
} else {
|
||
let upper = upperBound
|
||
descriptor = FetchDescriptor<CountEntry>(predicate: #Predicate { $0.timestamp < upper })
|
||
}
|
||
return (try? context.fetchCount(descriptor)) ?? 0
|
||
}
|
||
|
||
private var targetDiaryCount: Int {
|
||
let descriptor: FetchDescriptor<DiaryEntry>
|
||
if rangeMode == .all {
|
||
descriptor = FetchDescriptor<DiaryEntry>()
|
||
} else {
|
||
let key = endKey
|
||
descriptor = FetchDescriptor<DiaryEntry>(predicate: #Predicate { $0.dayKey <= key })
|
||
}
|
||
return (try? context.fetchCount(descriptor)) ?? 0
|
||
}
|
||
|
||
private var targetActionCount: Int {
|
||
(try? context.fetchCount(FetchDescriptor<Action>())) ?? 0
|
||
}
|
||
|
||
private var targetGoalCount: Int {
|
||
(try? context.fetchCount(FetchDescriptor<Goal>())) ?? 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> → '날짜 지정'(오늘-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<Action>(sortBy: [SortDescriptor(\.createdAt)])
|
||
)
|
||
let sessions = try context.fetch(
|
||
FetchDescriptor<TimeSession>(sortBy: [SortDescriptor(\.startAt)])
|
||
)
|
||
let entries = try context.fetch(
|
||
FetchDescriptor<CountEntry>(sortBy: [SortDescriptor(\.timestamp)])
|
||
)
|
||
let goals = try context.fetch(FetchDescriptor<Goal>(
|
||
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
|
||
))
|
||
let diaryDescriptor: FetchDescriptor<DiaryEntry> = {
|
||
if rangeMode == .all {
|
||
return FetchDescriptor<DiaryEntry>(sortBy: [SortDescriptor(\.dayKey)])
|
||
}
|
||
let key = endKey
|
||
return FetchDescriptor<DiaryEntry>(
|
||
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<DiaryTemplate>())
|
||
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
|
||
}
|
||
}
|
||
}
|