mycode/myApp/HaruDanim/IOS/Views/DataResetView.swift
songyc macbook cf78a3ac7e feat(data): 데이터 정리·초기화 — 전체/처음~날짜까지 + 삭제 전 백업(CSV·일기 PDF·정리 리포트)
- 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
2026-08-11 17:11:51 +09:00

437 lines
19 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
}
}
}