diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index 4d6e191..8a53c13 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -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)로 // 테마를 반전시켜 '재시작 없이 즉시 적용'되는지 확인 diff --git a/myApp/HaruDanim/IOS/Core/ArchiveReport.swift b/myApp/HaruDanim/IOS/Core/ArchiveReport.swift new file mode 100644 index 0000000..eb6ed29 --- /dev/null +++ b/myApp/HaruDanim/IOS/Core/ArchiveReport.swift @@ -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?, + 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?, + math: DayMath + ) -> [ExportSnapshot] { + let now = Date.now + + // ---- 1회 순회 집계: 행동별 합계 + 하루 키 집합 + 월별 합계 ---- + var actionSeconds: [PersistentIdentifier: TimeInterval] = [:] + var actionCounts: [PersistentIdentifier: Int] = [:] + var recordedDays = Set() + 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.. 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 + } +} diff --git a/myApp/HaruDanim/IOS/Core/DataReset.swift b/myApp/HaruDanim/IOS/Core/DataReset.swift new file mode 100644 index 0000000..511afe0 --- /dev/null +++ b/myApp/HaruDanim/IOS/Core/DataReset.swift @@ -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( + predicate: #Predicate { ($0.endAt ?? farFuture) <= upper } + )) + for session in sessions { context.delete(session) } + counts.sessions = sessions.count + + let entries = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.timestamp < upper } + )) + for entry in entries { context.delete(entry) } + counts.entries = entries.count + + // 일기 키는 달력일 자정 — 키끼리 직접 비교 (§4.1) + let diaryEntries = try context.fetch(FetchDescriptor( + 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()) + for entry in diaryEntries { context.delete(entry) } + counts.diaryEntries = diaryEntries.count + + let templates = try context.fetch(FetchDescriptor()) + for template in templates { context.delete(template) } + counts.templates = templates.count + + let goals = try context.fetch(FetchDescriptor()) + for goal in goals { context.delete(goal) } + counts.goals = goals.count + + let actions = try context.fetch(FetchDescriptor()) + for action in actions { context.delete(action) } + counts.actions = actions.count + + let tags = try context.fetch(FetchDescriptor()) + for tag in tags { context.delete(tag) } + counts.tags = tags.count + + // 행동 cascade가 못 미치는 잔존 기록(행동 연결이 없는 고아 등)까지 정리 + let sessions = try context.fetch(FetchDescriptor()) + for session in sessions { context.delete(session) } + counts.sessions = sessions.count + + let entries = try context.fetch(FetchDescriptor()) + 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())) ?? -1, 3) + expect("범위: 남은 횟수", (try? context.fetchCount(FetchDescriptor())) ?? -1, 1) + expect("범위: 남은 일기", (try? context.fetchCount(FetchDescriptor())) ?? -1, 1) + expect("범위: 일기 페이지 cascade", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) + expect("범위: 행동 보존", (try? context.fetchCount(FetchDescriptor())) ?? -1, 2) + expect("범위: 꼬리표 보존", (try? context.fetchCount(FetchDescriptor())) ?? -1, 1) + expect("범위: 목표 보존", (try? context.fetchCount(FetchDescriptor())) ?? -1, 1) + expect("범위: 양식 보존", (try? context.fetchCount(FetchDescriptor())) ?? -1, 1) + + // ---- 전체 초기화 (인메모리라 LocalPrefs는 건드리지 않음) ---- + _ = try DataReset.deleteAll(context: context, includeLocalPrefs: false) + try context.save() + expect("전체: 세션 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) + expect("전체: 횟수 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) + expect("전체: 행동 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) + expect("전체: 꼬리표 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) + expect("전체: 목표 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) + expect("전체: 일기 0", (try? context.fetchCount(FetchDescriptor())) ?? -1, 0) + expect("전체: 양식 0", (try? context.fetchCount(FetchDescriptor())) ?? -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 +} diff --git a/myApp/HaruDanim/IOS/Views/DataResetView.swift b/myApp/HaruDanim/IOS/Views/DataResetView.swift new file mode 100644 index 0000000..cb7ecdb --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/DataResetView.swift @@ -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? { + 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 + } + } +} diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 38306c8..2ea0ffd 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -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 {