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
This commit is contained in:
parent
a09d4b3da4
commit
cf78a3ac7e
@ -34,6 +34,8 @@ struct ContentView: View {
|
||||
NavigationStack { DataExportView() }
|
||||
} else if UserDefaults.standard.bool(forKey: "dataImportPreview") {
|
||||
NavigationStack { DataImportView() }
|
||||
} else if UserDefaults.standard.bool(forKey: "dataResetPreview") {
|
||||
NavigationStack { DataResetView() }
|
||||
} else {
|
||||
RootNavigationView()
|
||||
}
|
||||
@ -71,6 +73,7 @@ struct ContentView: View {
|
||||
DebugSeed.dumpMissingSymbolsIfRequested()
|
||||
DebugSeed.runProgressSelfTestIfRequested()
|
||||
CSVImport.selfTestIfRequested()
|
||||
DataReset.selfTestIfRequested()
|
||||
await DebugSeed.runIntentSmokeTestIfRequested()
|
||||
// 검증용: -themeAutoToggle YES → 3초 후 설정 화면과 같은 경로(App Group defaults)로
|
||||
// 테마를 반전시켜 '재시작 없이 즉시 적용'되는지 확인
|
||||
|
||||
375
myApp/HaruDanim/IOS/Core/ArchiveReport.swift
Normal file
375
myApp/HaruDanim/IOS/Core/ArchiveReport.swift
Normal file
@ -0,0 +1,375 @@
|
||||
//
|
||||
// ArchiveReport.swift
|
||||
// Haru_Danim
|
||||
//
|
||||
// 정리 리포트(1.4) — '데이터 정리·초기화'에서 삭제 전에 만드는 총정리 문서.
|
||||
// 삭제할 범위의 모든 기록을 바탕으로 여러 장의 포스터 페이지(ExportPosterView 재사용)를
|
||||
// 구성해 PDF 1개 또는 세로 스티치 PNG 1장으로 만든다.
|
||||
// - 1장: 타이틀 + 기간(첫 기록일~마지막 기록일) + 총계 히어로 + 행동별 누적 표
|
||||
// - 이후: (표가 길면 계속 페이지) → 꼬리표 합계·월별 합계 → 목표 이력
|
||||
// 집계 규칙은 화면과 동일(하루 경계 분할 = DayMath.splitByDay), 라이트 스킴 고정.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
enum ArchiveReport {
|
||||
/// 산출 형식 (이름을 Format으로 지으면 전역 Format 헬퍼를 가린다 — 금지)
|
||||
enum OutputFormat { case pdf, image }
|
||||
|
||||
/// 표가 한 페이지에 담기는 최대 행 수 (넘치면 '계속' 페이지로 나눔)
|
||||
private static let rowsPerPage = 24
|
||||
|
||||
// MARK: 진입점
|
||||
|
||||
/// 리포트 파일 생성. range = 삭제 대상 범위(nil = 전체).
|
||||
/// diaryDayKeys는 범위 안에서 일기를 쓴 날들(호출 쪽에서 걸러 전달).
|
||||
static func makeURL(
|
||||
format: OutputFormat,
|
||||
actions: [Action],
|
||||
sessions: [TimeSession],
|
||||
entries: [CountEntry],
|
||||
goals: [Goal],
|
||||
diaryDayKeys: [Date],
|
||||
range: Range<Date>?,
|
||||
math: DayMath
|
||||
) -> URL? {
|
||||
let pages = makePages(actions: actions, sessions: sessions, entries: entries,
|
||||
goals: goals, diaryDayKeys: diaryDayKeys, range: range, math: math)
|
||||
guard !pages.isEmpty else { return nil }
|
||||
let images = pages.compactMap { render($0) }
|
||||
guard !images.isEmpty else { return nil }
|
||||
switch format {
|
||||
case .pdf: return writePDF(images, slug: fileSlug(pages))
|
||||
case .image: return writeStitchedPNG(images, slug: fileSlug(pages))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 페이지 구성
|
||||
|
||||
private static func fileSlug(_ pages: [ExportSnapshot]) -> String {
|
||||
pages.first?.fileSlug ?? String(localized: "정리")
|
||||
}
|
||||
|
||||
private static func makePages(
|
||||
actions: [Action],
|
||||
sessions: [TimeSession],
|
||||
entries: [CountEntry],
|
||||
goals: [Goal],
|
||||
diaryDayKeys: [Date],
|
||||
range: Range<Date>?,
|
||||
math: DayMath
|
||||
) -> [ExportSnapshot] {
|
||||
let now = Date.now
|
||||
|
||||
// ---- 1회 순회 집계: 행동별 합계 + 하루 키 집합 + 월별 합계 ----
|
||||
var actionSeconds: [PersistentIdentifier: TimeInterval] = [:]
|
||||
var actionCounts: [PersistentIdentifier: Int] = [:]
|
||||
var recordedDays = Set<Date>()
|
||||
var monthSeconds: [Date: TimeInterval] = [:]
|
||||
var monthCounts: [Date: Int] = [:]
|
||||
|
||||
func monthKey(forDayKey key: Date) -> Date {
|
||||
let comps = math.calendar.dateComponents([.year, .month], from: key)
|
||||
return math.calendar.date(from: comps) ?? key
|
||||
}
|
||||
|
||||
for session in sessions {
|
||||
guard let action = session.action else { continue }
|
||||
var start = session.startAt
|
||||
var end = session.endAt ?? now
|
||||
if let range {
|
||||
start = max(start, range.lowerBound)
|
||||
end = min(end, range.upperBound)
|
||||
}
|
||||
guard start < end else { continue }
|
||||
for segment in math.splitByDay(start: start, end: end) {
|
||||
let seconds = segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
|
||||
actionSeconds[action.persistentModelID, default: 0] += seconds
|
||||
recordedDays.insert(segment.dayKey)
|
||||
monthSeconds[monthKey(forDayKey: segment.dayKey), default: 0] += seconds
|
||||
}
|
||||
}
|
||||
for entry in entries {
|
||||
guard let action = entry.action else { continue }
|
||||
if let range, !range.contains(entry.timestamp) { continue }
|
||||
let dayKey = math.dayKey(for: entry.timestamp)
|
||||
actionCounts[action.persistentModelID, default: 0] += entry.amount
|
||||
recordedDays.insert(dayKey)
|
||||
monthCounts[monthKey(forDayKey: dayKey), default: 0] += entry.amount
|
||||
}
|
||||
|
||||
let totalSeconds = actionSeconds.values.reduce(0, +)
|
||||
let totalCount = actionCounts.values.reduce(0, +)
|
||||
guard totalSeconds > 0 || totalCount > 0 || !diaryDayKeys.isEmpty || !goals.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
// ---- 기간 라벨: 실제 데이터가 있는 첫날~마지막날 (없으면 오늘) ----
|
||||
let allDays = recordedDays.union(diaryDayKeys)
|
||||
let firstDay = allDays.min() ?? math.dayKey(for: now)
|
||||
let lastDay = allDays.max() ?? math.dayKey(for: now)
|
||||
let periodLabel = "\(Format.fullDate(firstDay)) ~ \(Format.fullDate(lastDay))"
|
||||
let slugFormatter = DateFormatter()
|
||||
slugFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
slugFormatter.dateFormat = "yyyyMMdd"
|
||||
let slug = String(localized: "정리-\(slugFormatter.string(from: firstDay))-\(slugFormatter.string(from: lastDay))")
|
||||
|
||||
// ---- 히어로 ----
|
||||
let hero = [
|
||||
ExportHeroStat(title: String(localized: "총 측정 시간"), value: Format.durationShort(totalSeconds)),
|
||||
ExportHeroStat(title: String(localized: "총 횟수"), value: String(localized: "\(totalCount)회")),
|
||||
ExportHeroStat(title: String(localized: "기록한 날"), value: String(localized: "\(recordedDays.count)일")),
|
||||
ExportHeroStat(title: String(localized: "일기 쓴 날"), value: String(localized: "\(diaryDayKeys.count)일")),
|
||||
]
|
||||
|
||||
// ---- 행동별 누적 표 (합계 내림차순, 시간/횟수 그룹) ----
|
||||
let elapsedDays = max(recordedDays.count, 1)
|
||||
func averageLabel(_ total: Double, type: TrackingType) -> String {
|
||||
type == .time ? Format.durationShort(total / Double(elapsedDays))
|
||||
: Format.countAverage(total / Double(elapsedDays))
|
||||
}
|
||||
let timeActions = actions.filter { $0.trackingType == .time && (actionSeconds[$0.persistentModelID] ?? 0) > 0 }
|
||||
let countActions = actions.filter { $0.trackingType == .count && (actionCounts[$0.persistentModelID] ?? 0) > 0 }
|
||||
let timeNames = Format.disambiguated(timeActions.map(\.name))
|
||||
let countNames = Format.disambiguated(countActions.map(\.name))
|
||||
var timeTotals: [(action: Action, name: String, total: TimeInterval)] = zip(timeActions, timeNames)
|
||||
.map { ($0, $1, actionSeconds[$0.persistentModelID] ?? 0) }
|
||||
timeTotals.sort { $0.total > $1.total }
|
||||
let timeRows: [ExportTableData.Row] = timeTotals.map { item in
|
||||
ExportTableData.Row(
|
||||
symbol: item.action.symbolName, color: item.action.color, name: item.name,
|
||||
values: [Format.durationShort(item.total),
|
||||
averageLabel(item.total, type: .time)]
|
||||
)
|
||||
}
|
||||
var countTotals: [(action: Action, name: String, total: Int)] = zip(countActions, countNames)
|
||||
.map { ($0, $1, actionCounts[$0.persistentModelID] ?? 0) }
|
||||
countTotals.sort { $0.total > $1.total }
|
||||
let countRows: [ExportTableData.Row] = countTotals.map { item in
|
||||
ExportTableData.Row(
|
||||
symbol: item.action.symbolName, color: item.action.color, name: item.name,
|
||||
values: [String(localized: "\(item.total)회"),
|
||||
averageLabel(Double(item.total), type: .count)]
|
||||
)
|
||||
}
|
||||
let actionColumns = [String(localized: "합계"), String(localized: "하루 평균")]
|
||||
|
||||
// ---- 꼬리표별 합계 (identity 키 + 표시 이름 구분 — 통계 탭과 동일 규칙) ----
|
||||
struct TagAcc { var name: String; var color: Color; var seconds: TimeInterval = 0; var count: Int = 0 }
|
||||
var tagAccs: [PersistentIdentifier?: TagAcc] = [:]
|
||||
var tagOrder: [PersistentIdentifier?] = []
|
||||
for action in actions {
|
||||
let seconds = actionSeconds[action.persistentModelID] ?? 0
|
||||
let count = actionCounts[action.persistentModelID] ?? 0
|
||||
guard seconds > 0 || count > 0 else { continue }
|
||||
let keys: [(PersistentIdentifier?, String, Color)] = action.tags.isEmpty
|
||||
? [(nil, String(localized: "꼬리표 없음"), Color.gray)]
|
||||
: action.sortedTags.map { ($0.persistentModelID, $0.name, $0.color) }
|
||||
for (key, name, color) in keys {
|
||||
if tagAccs[key] == nil {
|
||||
tagAccs[key] = TagAcc(name: name, color: color)
|
||||
tagOrder.append(key)
|
||||
}
|
||||
tagAccs[key]?.seconds += seconds
|
||||
tagAccs[key]?.count += count
|
||||
}
|
||||
}
|
||||
let orderedTags: [TagAcc] = {
|
||||
let ordered = tagOrder.compactMap { tagAccs[$0] }
|
||||
let names = Format.disambiguated(ordered.map(\.name))
|
||||
return zip(ordered, names).map { acc, name in
|
||||
var copy = acc
|
||||
copy.name = name
|
||||
return copy
|
||||
}
|
||||
}()
|
||||
let tagTimeBars: ExportSectionData? = {
|
||||
let items = orderedTags.filter { $0.seconds > 0 }.sorted { $0.seconds > $1.seconds }
|
||||
.map { ExportBarItem(name: $0.name, value: $0.seconds / 3600,
|
||||
valueLabel: Format.durationShort($0.seconds), color: $0.color) }
|
||||
guard !items.isEmpty else { return nil }
|
||||
return .bars(title: String(localized: "꼬리표별 시간 합계"),
|
||||
xLabel: String(localized: "시간(h)"), items: items)
|
||||
}()
|
||||
let tagCountBars: ExportSectionData? = {
|
||||
let items = orderedTags.filter { $0.count > 0 }.sorted { $0.count > $1.count }
|
||||
.map { ExportBarItem(name: $0.name, value: Double($0.count),
|
||||
valueLabel: String(localized: "\($0.count)회"), color: $0.color) }
|
||||
guard !items.isEmpty else { return nil }
|
||||
return .bars(title: String(localized: "꼬리표별 횟수 합계"),
|
||||
xLabel: String(localized: "횟수"), items: items)
|
||||
}()
|
||||
|
||||
// ---- 월별 합계 표 (시간순) ----
|
||||
let monthKeys = Set(monthSeconds.keys).union(monthCounts.keys).sorted()
|
||||
let monthRows = monthKeys.map { key in
|
||||
ExportTableData.Row(
|
||||
symbol: "calendar", color: AppTheme.green,
|
||||
name: key.formatted(.dateTime.year().month()),
|
||||
values: [Format.durationShort(monthSeconds[key] ?? 0),
|
||||
String(localized: "\(monthCounts[key] ?? 0)회")]
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 목표 이력 표 ----
|
||||
let goalRows = goals.map { goal in
|
||||
let period = goal.endDate.map {
|
||||
"\(Format.shortDate(goal.startDate)) ~ \(Format.shortDate($0))"
|
||||
} ?? String(localized: "\(Format.shortDate(goal.startDate)) 시작")
|
||||
return ExportTableData.Row(
|
||||
symbol: goal.symbolName, color: goal.color, name: goal.title,
|
||||
values: [period, goal.status.label]
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 페이지 조립 (긴 표는 rowsPerPage 단위 '계속' 페이지) ----
|
||||
var pages: [ExportSnapshot] = []
|
||||
let title = String(localized: "하루 다님 기록 정리")
|
||||
|
||||
func chunkedTablePages(
|
||||
rows: [ExportTableData.Row], columns: [String], groupLabel: String,
|
||||
sectionTitle: String, pageTitle: String
|
||||
) -> [(title: String, section: ExportSectionData)] {
|
||||
guard !rows.isEmpty else { return [] }
|
||||
return stride(from: 0, to: rows.count, by: rowsPerPage).map { start in
|
||||
let chunk = Array(rows[start..<min(start + rowsPerPage, rows.count)])
|
||||
let suffix = start == 0 ? "" : String(localized: " (계속)")
|
||||
return (
|
||||
title: start == 0 ? pageTitle : pageTitle + suffix,
|
||||
section: .table(title: sectionTitle + suffix,
|
||||
ExportTableData(columns: columns,
|
||||
groups: [.init(label: groupLabel, rows: chunk)]))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 1장: 히어로 + 행동별 누적(시간) 첫 청크부터
|
||||
var firstSections: [ExportSectionData] = []
|
||||
var overflowPages: [(title: String, section: ExportSectionData)] = []
|
||||
let timeTable = chunkedTablePages(rows: timeRows, columns: actionColumns,
|
||||
groupLabel: String(localized: "시간"),
|
||||
sectionTitle: String(localized: "행동별 누적 — 시간"),
|
||||
pageTitle: title)
|
||||
let countTable = chunkedTablePages(rows: countRows, columns: actionColumns,
|
||||
groupLabel: String(localized: "횟수"),
|
||||
sectionTitle: String(localized: "행동별 누적 — 횟수"),
|
||||
pageTitle: title)
|
||||
if let first = timeTable.first {
|
||||
firstSections.append(first.section)
|
||||
overflowPages += timeTable.dropFirst()
|
||||
}
|
||||
if let firstCount = countTable.first, timeTable.count <= 1 {
|
||||
firstSections.append(firstCount.section)
|
||||
overflowPages += countTable.dropFirst()
|
||||
} else {
|
||||
overflowPages += countTable
|
||||
}
|
||||
pages.append(ExportSnapshot(
|
||||
title: title, periodLabel: periodLabel, filterNote: nil,
|
||||
hero: hero, sections: firstSections, fileSlug: slug
|
||||
))
|
||||
for page in overflowPages {
|
||||
pages.append(ExportSnapshot(
|
||||
title: page.title, periodLabel: periodLabel, filterNote: nil,
|
||||
hero: [], sections: [page.section], fileSlug: slug
|
||||
))
|
||||
}
|
||||
|
||||
// 꼬리표 + 월별 페이지
|
||||
var tagSections: [ExportSectionData] = [tagTimeBars, tagCountBars].compactMap(\.self)
|
||||
let monthPages = chunkedTablePages(rows: monthRows,
|
||||
columns: [String(localized: "시간"), String(localized: "횟수")],
|
||||
groupLabel: String(localized: "월"),
|
||||
sectionTitle: String(localized: "월별 합계"),
|
||||
pageTitle: String(localized: "꼬리표·월별 정리"))
|
||||
if let firstMonth = monthPages.first {
|
||||
tagSections.append(firstMonth.section)
|
||||
}
|
||||
if !tagSections.isEmpty {
|
||||
pages.append(ExportSnapshot(
|
||||
title: String(localized: "꼬리표·월별 정리"), periodLabel: periodLabel, filterNote: nil,
|
||||
hero: [], sections: tagSections, fileSlug: slug
|
||||
))
|
||||
}
|
||||
for page in monthPages.dropFirst() {
|
||||
pages.append(ExportSnapshot(
|
||||
title: page.title, periodLabel: periodLabel, filterNote: nil,
|
||||
hero: [], sections: [page.section], fileSlug: slug
|
||||
))
|
||||
}
|
||||
|
||||
// 목표 이력 페이지
|
||||
for page in chunkedTablePages(rows: goalRows,
|
||||
columns: [String(localized: "기간"), String(localized: "상태")],
|
||||
groupLabel: String(localized: "목표"),
|
||||
sectionTitle: String(localized: "목표 이력"),
|
||||
pageTitle: String(localized: "목표 이력")) {
|
||||
pages.append(ExportSnapshot(
|
||||
title: page.title, periodLabel: periodLabel, filterNote: nil,
|
||||
hero: [], sections: [page.section], fileSlug: slug
|
||||
))
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
// MARK: 렌더 (ExportPosterView 재사용 — 화면 리포트와 같은 시각 언어, 라이트 고정)
|
||||
|
||||
private static func render(_ snapshot: ExportSnapshot) -> UIImage? {
|
||||
let renderer = ImageRenderer(
|
||||
content: ExportPosterView(snapshot: snapshot).environment(\.colorScheme, .light)
|
||||
)
|
||||
renderer.scale = 3
|
||||
renderer.isOpaque = true
|
||||
return renderer.uiImage
|
||||
}
|
||||
|
||||
private static func writePDF(_ images: [UIImage], slug: String) -> URL? {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(String(localized: "하루다님-\(slug).pdf"))
|
||||
let width = ExportPosterView.width
|
||||
let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: width, height: width))
|
||||
do {
|
||||
try renderer.writePDF(to: url) { pdf in
|
||||
for image in images {
|
||||
let height = image.size.height * width / image.size.width
|
||||
let bounds = CGRect(x: 0, y: 0, width: width, height: height)
|
||||
pdf.beginPage(withBounds: bounds, pageInfo: [:])
|
||||
image.draw(in: bounds)
|
||||
}
|
||||
}
|
||||
return url
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func writeStitchedPNG(_ images: [UIImage], slug: String) -> URL? {
|
||||
let width = ExportPosterView.width
|
||||
let gap: CGFloat = 16
|
||||
let heights = images.map { $0.size.height * width / $0.size.width }
|
||||
let totalHeight = heights.reduce(0, +) + gap * CGFloat(max(images.count - 1, 0))
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 3
|
||||
let renderer = UIGraphicsImageRenderer(
|
||||
size: CGSize(width: width, height: totalHeight), format: format
|
||||
)
|
||||
let stitched = renderer.image { ctx in
|
||||
UIColor(AppTheme.background).setFill()
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: width, height: totalHeight))
|
||||
var y: CGFloat = 0
|
||||
for (image, height) in zip(images, heights) {
|
||||
image.draw(in: CGRect(x: 0, y: y, width: width, height: height))
|
||||
y += height + gap
|
||||
}
|
||||
}
|
||||
guard let data = stitched.pngData() else { return nil }
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(String(localized: "하루다님-\(slug).png"))
|
||||
try? data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
}
|
||||
223
myApp/HaruDanim/IOS/Core/DataReset.swift
Normal file
223
myApp/HaruDanim/IOS/Core/DataReset.swift
Normal file
@ -0,0 +1,223 @@
|
||||
//
|
||||
// DataReset.swift
|
||||
// Haru_Danim
|
||||
//
|
||||
// 데이터 정리·초기화(1.4)의 삭제 코어.
|
||||
// ⚠️ 스토어 파일 통삭제 금지 — CloudKit 미러링이 깨진다. 반드시 컨텍스트 경유
|
||||
// 개별 삭제로 해야 삭제가 다른 기기에도 전파된다 (§5.1). 저장·위젯 갱신은
|
||||
// 호출 쪽의 DataChange.commit이 담당한다.
|
||||
//
|
||||
// 범위 삭제 규칙(사용자 승인, 1.4):
|
||||
// - "처음부터 지정한 날짜까지"의 시간·횟수 기록과 일기만 삭제, 구조(행동·꼬리표·
|
||||
// 목표·다짐)와 양식 라이브러리는 보존
|
||||
// - 경계에 걸친 세션(끝이 범위 밖)과 진행 중 측정은 보존 — 남는 쪽 집계에 여전히
|
||||
// 필요하고, 집계가 겹침 분할이라 그대로 두는 게 자연스럽다
|
||||
// - 과거 기록이 사라지면 다짐의 연속 달성은 끊긴다 (화면에서 경고)
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
enum DataReset {
|
||||
struct Counts {
|
||||
var sessions = 0
|
||||
var entries = 0
|
||||
var diaryEntries = 0
|
||||
var actions = 0
|
||||
var tags = 0
|
||||
var goals = 0
|
||||
var templates = 0
|
||||
}
|
||||
|
||||
// MARK: 범위 삭제 (처음 ~ endDayKey까지)
|
||||
|
||||
/// endDayKey(달력일 자정 키, 포함)까지의 기록·일기를 삭제한다. 저장은 호출 쪽에서.
|
||||
@MainActor
|
||||
static func deleteRecords(upToDayKey endDayKey: Date, context: ModelContext,
|
||||
math: DayMath) throws -> Counts {
|
||||
var counts = Counts()
|
||||
let upper = math.dayRange(forKey: endDayKey).upperBound
|
||||
let farFuture = Date.distantFuture
|
||||
|
||||
// 완전히 범위 안에서 끝난 세션만 (진행 중 nil → farFuture라 자동 보존)
|
||||
let sessions = try context.fetch(FetchDescriptor<TimeSession>(
|
||||
predicate: #Predicate { ($0.endAt ?? farFuture) <= upper }
|
||||
))
|
||||
for session in sessions { context.delete(session) }
|
||||
counts.sessions = sessions.count
|
||||
|
||||
let entries = try context.fetch(FetchDescriptor<CountEntry>(
|
||||
predicate: #Predicate { $0.timestamp < upper }
|
||||
))
|
||||
for entry in entries { context.delete(entry) }
|
||||
counts.entries = entries.count
|
||||
|
||||
// 일기 키는 달력일 자정 — 키끼리 직접 비교 (§4.1)
|
||||
let diaryEntries = try context.fetch(FetchDescriptor<DiaryEntry>(
|
||||
predicate: #Predicate { $0.dayKey <= endDayKey }
|
||||
))
|
||||
for entry in diaryEntries { context.delete(entry) }
|
||||
counts.diaryEntries = diaryEntries.count
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
// MARK: 전체 초기화
|
||||
|
||||
/// 모든 도메인 데이터 삭제 (cascade: 목표→다짐, 행동→기록, 일기→페이지·요소·할 일).
|
||||
/// includeLocalPrefs=true면 배치·즐겨찾기 등 App Group 보기 설정도 초기화
|
||||
/// (인메모리 자가 검증에서는 false — 실기기 defaults를 건드리지 않기 위함).
|
||||
@MainActor
|
||||
static func deleteAll(context: ModelContext, includeLocalPrefs: Bool = true) throws -> Counts {
|
||||
var counts = Counts()
|
||||
|
||||
let diaryEntries = try context.fetch(FetchDescriptor<DiaryEntry>())
|
||||
for entry in diaryEntries { context.delete(entry) }
|
||||
counts.diaryEntries = diaryEntries.count
|
||||
|
||||
let templates = try context.fetch(FetchDescriptor<DiaryTemplate>())
|
||||
for template in templates { context.delete(template) }
|
||||
counts.templates = templates.count
|
||||
|
||||
let goals = try context.fetch(FetchDescriptor<Goal>())
|
||||
for goal in goals { context.delete(goal) }
|
||||
counts.goals = goals.count
|
||||
|
||||
let actions = try context.fetch(FetchDescriptor<Action>())
|
||||
for action in actions { context.delete(action) }
|
||||
counts.actions = actions.count
|
||||
|
||||
let tags = try context.fetch(FetchDescriptor<Tag>())
|
||||
for tag in tags { context.delete(tag) }
|
||||
counts.tags = tags.count
|
||||
|
||||
// 행동 cascade가 못 미치는 잔존 기록(행동 연결이 없는 고아 등)까지 정리
|
||||
let sessions = try context.fetch(FetchDescriptor<TimeSession>())
|
||||
for session in sessions { context.delete(session) }
|
||||
counts.sessions = sessions.count
|
||||
|
||||
let entries = try context.fetch(FetchDescriptor<CountEntry>())
|
||||
for entry in entries { context.delete(entry) }
|
||||
counts.entries = entries.count
|
||||
|
||||
if includeLocalPrefs {
|
||||
let keys = [
|
||||
LocalPrefsKeys.actionOrder, LocalPrefsKeys.pinnedGoals,
|
||||
LocalPrefsKeys.collapsedGoals, LocalPrefsKeys.notePromptActions,
|
||||
LocalPrefsKeys.watchGoals, LocalPrefsKeys.mainOthersCollapsed,
|
||||
]
|
||||
for key in keys { LocalPrefs.defaults.removeObject(forKey: key) }
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
// MARK: 자가 검증 (-dataResetTest — 인메모리 컨테이너·설정 고정, 실데이터 무영향)
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
static func selfTestIfRequested() {
|
||||
guard UserDefaults.standard.bool(forKey: "dataResetTest") else { return }
|
||||
var lines: [String] = []
|
||||
var failures = 0
|
||||
|
||||
func expect(_ label: String, _ actual: Int, _ expected: Int) {
|
||||
let pass = actual == expected
|
||||
if !pass { failures += 1 }
|
||||
lines.append("\(pass ? "PASS" : "FAIL") \(label): actual \(actual) / expected \(expected)")
|
||||
}
|
||||
|
||||
do {
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
let container = try ModelContainer(for: DataStore.schema, configurations: [config])
|
||||
let context = container.mainContext
|
||||
let math = DayMath(settings: TrackingSettings(weekStartWeekday: 2, dayStartMinutes: 0))
|
||||
|
||||
// 시드: 기준일(cut) 이전·경계 걸침·이후·진행 중 세션 + 횟수 + 일기 + 구조
|
||||
let cal = math.calendar
|
||||
let today = cal.startOfDay(for: Date(timeIntervalSince1970: 1_754_000_000)) // 고정 기준
|
||||
let cutKey = cal.date(byAdding: .day, value: -7, to: today)! // 7일 전까지 삭제
|
||||
let cutUpper = math.dayRange(forKey: cutKey).upperBound
|
||||
|
||||
let tag = Tag(name: "테스트", colorHex: "#2F6B4F")
|
||||
context.insert(tag)
|
||||
let action = Action(name: "검증행동", symbolName: "book.fill", trackingType: .time, sortOrder: 0)
|
||||
context.insert(action)
|
||||
let countAction = Action(name: "검증횟수", symbolName: "number", trackingType: .count, sortOrder: 1)
|
||||
context.insert(countAction)
|
||||
let goal = Goal(title: "검증목표", symbolName: "flag.fill", colorHex: "#2F6B4F",
|
||||
startDate: cutUpper.addingTimeInterval(-86400 * 30), endDate: nil)
|
||||
context.insert(goal)
|
||||
|
||||
// ① 범위 안에서 끝난 세션 (삭제 대상)
|
||||
context.insert(TimeSession(action: action,
|
||||
startAt: cutUpper.addingTimeInterval(-7200),
|
||||
endAt: cutUpper.addingTimeInterval(-3600)))
|
||||
// ② 경계 걸침 세션 (보존 — 시작은 범위 안, 끝은 밖)
|
||||
context.insert(TimeSession(action: action,
|
||||
startAt: cutUpper.addingTimeInterval(-1800),
|
||||
endAt: cutUpper.addingTimeInterval(1800)))
|
||||
// ③ 범위 밖 세션 (보존)
|
||||
context.insert(TimeSession(action: action,
|
||||
startAt: cutUpper.addingTimeInterval(3600),
|
||||
endAt: cutUpper.addingTimeInterval(7200)))
|
||||
// ④ 진행 중 세션 — 시작이 범위 안이어도 보존
|
||||
context.insert(TimeSession(action: action,
|
||||
startAt: cutUpper.addingTimeInterval(-600), endAt: nil))
|
||||
// 횟수: 범위 안 2 + 밖 1
|
||||
context.insert(CountEntry(action: countAction,
|
||||
timestamp: cutUpper.addingTimeInterval(-100), amount: 1))
|
||||
context.insert(CountEntry(action: countAction,
|
||||
timestamp: cutUpper.addingTimeInterval(-200), amount: 2))
|
||||
context.insert(CountEntry(action: countAction,
|
||||
timestamp: cutUpper.addingTimeInterval(100), amount: 3))
|
||||
// 일기: 기준일(포함)·전날 = 삭제, 다음 날 = 보존. 페이지 cascade 확인용 1장
|
||||
let diaryOld = DiaryEntry(dayKey: cal.date(byAdding: .day, value: -1, to: cutKey)!)
|
||||
context.insert(diaryOld)
|
||||
let page = DiaryPage(index: 1)
|
||||
page.entry = diaryOld
|
||||
context.insert(page)
|
||||
let diaryCut = DiaryEntry(dayKey: cutKey)
|
||||
context.insert(diaryCut)
|
||||
let diaryKeep = DiaryEntry(dayKey: cal.date(byAdding: .day, value: 1, to: cutKey)!)
|
||||
context.insert(diaryKeep)
|
||||
let template = DiaryTemplate(name: "양식", kind: .image)
|
||||
context.insert(template)
|
||||
try context.save()
|
||||
|
||||
// ---- 범위 삭제 ----
|
||||
let rangeCounts = try DataReset.deleteRecords(upToDayKey: cutKey, context: context, math: math)
|
||||
try context.save()
|
||||
expect("범위: 삭제된 세션 수(완전 포함만)", rangeCounts.sessions, 1)
|
||||
expect("범위: 삭제된 횟수 수", rangeCounts.entries, 2)
|
||||
expect("범위: 삭제된 일기 수(기준일 포함)", rangeCounts.diaryEntries, 2)
|
||||
expect("범위: 남은 세션(걸침+밖+진행 중)",
|
||||
(try? context.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 3)
|
||||
expect("범위: 남은 횟수", (try? context.fetchCount(FetchDescriptor<CountEntry>())) ?? -1, 1)
|
||||
expect("범위: 남은 일기", (try? context.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 1)
|
||||
expect("범위: 일기 페이지 cascade", (try? context.fetchCount(FetchDescriptor<DiaryPage>())) ?? -1, 0)
|
||||
expect("범위: 행동 보존", (try? context.fetchCount(FetchDescriptor<Action>())) ?? -1, 2)
|
||||
expect("범위: 꼬리표 보존", (try? context.fetchCount(FetchDescriptor<Tag>())) ?? -1, 1)
|
||||
expect("범위: 목표 보존", (try? context.fetchCount(FetchDescriptor<Goal>())) ?? -1, 1)
|
||||
expect("범위: 양식 보존", (try? context.fetchCount(FetchDescriptor<DiaryTemplate>())) ?? -1, 1)
|
||||
|
||||
// ---- 전체 초기화 (인메모리라 LocalPrefs는 건드리지 않음) ----
|
||||
_ = try DataReset.deleteAll(context: context, includeLocalPrefs: false)
|
||||
try context.save()
|
||||
expect("전체: 세션 0", (try? context.fetchCount(FetchDescriptor<TimeSession>())) ?? -1, 0)
|
||||
expect("전체: 횟수 0", (try? context.fetchCount(FetchDescriptor<CountEntry>())) ?? -1, 0)
|
||||
expect("전체: 행동 0", (try? context.fetchCount(FetchDescriptor<Action>())) ?? -1, 0)
|
||||
expect("전체: 꼬리표 0", (try? context.fetchCount(FetchDescriptor<Tag>())) ?? -1, 0)
|
||||
expect("전체: 목표 0", (try? context.fetchCount(FetchDescriptor<Goal>())) ?? -1, 0)
|
||||
expect("전체: 일기 0", (try? context.fetchCount(FetchDescriptor<DiaryEntry>())) ?? -1, 0)
|
||||
expect("전체: 양식 0", (try? context.fetchCount(FetchDescriptor<DiaryTemplate>())) ?? -1, 0)
|
||||
} catch {
|
||||
failures += 1
|
||||
lines.append("FAIL 예외: \(error)")
|
||||
}
|
||||
|
||||
lines.append(failures == 0 ? "== ALL PASS ==" : "== \(failures) FAILURES ==")
|
||||
let url = URL.documentsDirectory.appending(path: "data-reset-test.txt")
|
||||
try? lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
436
myApp/HaruDanim/IOS/Views/DataResetView.swift
Normal file
436
myApp/HaruDanim/IOS/Views/DataResetView.swift
Normal file
@ -0,0 +1,436 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -265,10 +265,15 @@ struct SettingsView: View {
|
||||
} label: {
|
||||
Label("데이터 가져오기", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
NavigationLink {
|
||||
DataResetView()
|
||||
} label: {
|
||||
Label("데이터 정리·초기화", systemImage: "trash")
|
||||
}
|
||||
} header: {
|
||||
Text("데이터")
|
||||
} footer: {
|
||||
Text("모든 기록을 CSV 파일로 만들어 백업하거나 스프레드시트에서 열 수 있어요. '데이터 가져오기'(프리미엄)로는 내보냈던 CSV의 행동·기록을 다시 불러올 수 있어요.")
|
||||
Text("모든 기록을 CSV 파일로 만들어 백업하거나 스프레드시트에서 열 수 있어요. '데이터 가져오기'(프리미엄)로는 내보냈던 CSV의 행동·기록을 다시 불러올 수 있어요. '데이터 정리·초기화'는 오래 쌓인 기록을 백업해 두고 비울 때 써요.")
|
||||
}
|
||||
Section("지원") {
|
||||
NavigationLink {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user