mycode/myApp/HaruDanim/IOS/Views/DataResetView.swift
songyc macbook 16e8737976 feat(1.4-b3): '일기만' 정리 옵션·라디얼 FAB 가림 수정·1.4 전용 프로모션 — 빌드 3
- 데이터 정리·초기화에 '정리할 데이터' 축(기록과 일기/일기만) 추가 — 일기만은 기록·구조·양식 보존, 백업은 일기 PDF만, DataReset.deleteDiaries(전체/날짜까지)
- 자가 검증 18→27건(일기만 9건: 기준일 포함 삭제·기록/행동/양식 보존·cascade) — 26·18.5 심 ALL PASS
- 라디얼 모드 FAB가 목록 마지막 행을 가리던 실측 문제 수정 — 탭 루트(NavigationStack 안쪽) 하단 safeAreaInset 78pt, 26·18.5 시각 확인
- 도움말 '데이터 정리·초기화'에 일기만 문구, 신규 12키 en/ja 완역(카탈로그 missing/stale 0)
- whats-new-1.4에 일기만 반영 + promotional-text-1.4.txt 신설(ko 154/en 169/ja 139자 — 이용자 3명 감사 멘트)
- iOS 18.5 심층 QA: 자가 검증 106건 ALL PASS + 라디얼/위젯/초기화 화면 확인
- CURRENT_PROJECT_VERSION 2→3, Debug/Store/워치 3빌드 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-12 02:19:34 +09:00

518 lines
23 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: "날짜 지정")
}
}
}
/// (1.4(2)): · vs .
/// · ·
private enum ResetScope: String, CaseIterable, Identifiable {
case recordsAndDiary, diaryOnly
var id: String { rawValue }
var label: String {
switch self {
case .recordsAndDiary: return String(localized: "기록과 일기")
case .diaryOnly: return String(localized: "일기만")
}
}
}
private enum ReportFormat: String, CaseIterable, Identifiable {
case pdf, image
var id: String { rawValue }
var label: String {
switch self {
case .pdf: return "PDF"
case .image: return String(localized: "이미지")
}
}
}
@State private var scope: ResetScope = .recordsAndDiary
@State private var rangeMode: RangeMode = .all
@State private var endDate: Date = Calendar.current.date(byAdding: .month, value: -3, to: .now) ?? .now
@State private var includeCSV = true
@State private var includeDiary = true
@State private var includeReport = true
@State private var reportFormat: ReportFormat = .pdf
@State private var isGenerating = false
@State private var backupURLs: [URL] = []
@State private var confirmingDelete = false
@State private var finalConfirm = false
@State private var isDeleting = false
@State private var deletedCounts: DataReset.Counts?
private var math: DayMath { DayMath() }
/// () DatePicker (§4.1)
private var endKey: Date { math.calendar.startOfDay(for: endDate) }
private var upperBound: Date { math.dayRange(forKey: endKey).upperBound }
/// (CSV·)
private var backupRange: Range<Date>? {
rangeMode == .all ? nil : Date.distantPast..<upperBound
}
/// PDF
/// (CSV· )
private var wantsBackup: Bool {
scope == .diaryOnly ? includeDiary : (includeCSV || includeDiary || includeReport)
}
/// ,
private var canDelete: Bool {
(!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: scope) { backupURLs = [] }
.onChange(of: rangeMode) { backupURLs = [] }
.onChange(of: endDate) { backupURLs = [] }
.onChange(of: includeCSV) { backupURLs = [] }
.onChange(of: includeDiary) { backupURLs = [] }
.onChange(of: includeReport) { backupURLs = [] }
.onChange(of: reportFormat) { backupURLs = [] }
.confirmationDialog(
confirmTitle,
isPresented: $confirmingDelete,
titleVisibility: .visible
) {
Button("삭제 진행", role: .destructive) { finalConfirm = true }
} message: {
Text(confirmMessage)
}
.alert("정말 삭제할까요?", isPresented: $finalConfirm) {
Button("취소", role: .cancel) {}
Button("삭제", role: .destructive) { performDelete() }
} message: {
Text("삭제는 되돌릴 수 없어요. 백업 파일을 저장했는지 다시 확인해 주세요.")
}
#if DEBUG
.onAppear {
// : -dataResetScope diary ''
if UserDefaults.standard.string(forKey: "dataResetScope") == "diary" {
scope = .diaryOnly
}
// : -dataResetRangeDays <N> ' '(-N)
let days = UserDefaults.standard.integer(forKey: "dataResetRangeDays")
if days > 0 {
rangeMode = .upTo
endDate = math.calendar.date(byAdding: .day, value: -days, to: .now) ?? .now
}
// : -dataResetBackupRun pdf|image 3 Documents
if let raw = UserDefaults.standard.string(forKey: "dataResetBackupRun") {
includeCSV = true
includeDiary = true
includeReport = true
reportFormat = raw == "image" ? .image : .pdf
makeBackups(copyToDocuments: true)
}
}
#endif
}
// MARK:
private var rangeSection: some View {
Section {
Picker("정리할 데이터", selection: $scope.animation()) {
ForEach(ResetScope.allCases) { scope in
Text(scope.label).tag(scope)
}
}
.pickerStyle(.segmented)
Picker("범위", selection: $rangeMode) {
ForEach(RangeMode.allCases) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)
if rangeMode == .upTo {
DatePicker("까지", selection: $endDate, in: ...Date.now, displayedComponents: .date)
}
} header: {
Text("정리할 범위")
} footer: {
switch (scope, rangeMode) {
case (.recordsAndDiary, .all):
Text("모든 행동·꼬리표·목표·다짐·기록·일기·양식을 삭제하고 처음 상태로 돌아가요. 앱 설정(테마·하루 시작 시간 등)과 프리미엄은 그대로예요.")
case (.recordsAndDiary, .upTo):
Text("처음부터 지정한 날짜(포함)까지의 시간·횟수 기록과 일기만 삭제해요. 행동·꼬리표·목표·다짐과 양식 라이브러리는 남아요. 지정한 날짜에 걸쳐 있는 측정과 진행 중인 측정은 남아요.")
case (.diaryOnly, .all):
Text("모든 일기(필기·사진·할 일 포함)를 삭제해요. 시간·횟수 기록과 행동·목표, 일기 양식은 그대로 남아요 — 사진·필기로 커진 일기 용량만 줄일 때 좋아요.")
case (.diaryOnly, .upTo):
Text("처음부터 지정한 날짜(포함)까지의 일기만 삭제해요. 시간·횟수 기록과 행동·목표, 일기 양식은 그대로 남아요.")
}
}
}
private var targetSection: some View {
Section("삭제 대상") {
if scope == .recordsAndDiary {
countRow("timer", String(localized: "시간 기록"), String(localized: "\(targetSessionCount)"))
countRow("number", String(localized: "횟수 기록"), String(localized: "\(targetEntryCount)"))
}
countRow("book.closed.fill", String(localized: "일기"), String(localized: "\(targetDiaryCount)"))
if scope == .recordsAndDiary && rangeMode == .all {
countRow("figure.walk", String(localized: "행동"), String(localized: "\(targetActionCount)"))
countRow("flag.checkered", String(localized: "목표"), String(localized: "\(targetGoalCount)"))
}
}
}
private var backupSection: some View {
Section {
// (CSV· )
if scope == .recordsAndDiary {
Toggle(isOn: $includeCSV) {
Label("CSV 내보내기", systemImage: "tablecells")
}
}
Toggle(isOn: $includeDiary) {
Label("일기 PDF", systemImage: "book.closed")
}
if scope == .recordsAndDiary {
Toggle(isOn: $includeReport) {
Label("정리 리포트", systemImage: "doc.richtext")
}
if includeReport {
Picker("리포트 형식", selection: $reportFormat) {
ForEach(ReportFormat.allCases) { format in
Text(format.label).tag(format)
}
}
.pickerStyle(.segmented)
}
}
if isGenerating {
HStack {
ProgressView()
Text("백업 만드는 중…")
.foregroundStyle(.secondary)
}
} else if backupURLs.isEmpty {
if wantsBackup {
Button {
makeBackups()
} label: {
Label("백업 파일 만들기", systemImage: "square.and.arrow.down.on.square")
.frame(maxWidth: .infinity)
}
}
} else {
ShareLink(items: backupURLs) {
Label("백업 파일 \(backupURLs.count)개 공유", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
Button("다시 만들기") {
backupURLs = []
}
.frame(maxWidth: .infinity)
}
} header: {
Text("삭제 전 백업")
} footer: {
Text(scope == .diaryOnly
? "삭제할 일기들을 PDF 하나로 만들어 보관할 수 있어요. 백업을 켰다면 파일을 만들어 저장한 뒤에 삭제할 수 있어요."
: "CSV는 '데이터 가져오기'로 복원할 수 있는 원본 백업이에요. 정리 리포트는 삭제할 기록을 한눈에 정리한 문서예요(기간·행동별 누적·꼬리표·월별·목표 이력). 백업을 켰다면 파일을 만들어 저장한 뒤에 삭제할 수 있어요.")
}
}
private var deleteSection: some View {
Section {
if isDeleting {
HStack {
ProgressView()
Text("삭제하는 중…")
.foregroundStyle(.secondary)
}
} else {
Button(role: .destructive) {
confirmingDelete = true
} label: {
Label(deleteButtonTitle, systemImage: "trash")
.frame(maxWidth: .infinity)
}
.disabled(!canDelete)
}
} footer: {
if !canDelete && !isDeleting && deletedCounts == nil {
Text("먼저 위에서 백업 파일을 만들어 주세요. 백업 없이 삭제하려면 백업 항목을 모두 꺼요.")
}
}
}
private func doneSection(_ counts: DataReset.Counts) -> some View {
Section {
Label {
Text("삭제를 마쳤어요")
.font(.headline)
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(AppTheme.green)
}
if scope == .recordsAndDiary {
countRow("timer", String(localized: "시간 기록"), String(localized: "\(counts.sessions)"))
countRow("number", String(localized: "횟수 기록"), String(localized: "\(counts.entries)"))
}
countRow("book.closed.fill", String(localized: "일기"), String(localized: "\(counts.diaryEntries)"))
if counts.actions > 0 || counts.goals > 0 {
countRow("figure.walk", String(localized: "행동"), String(localized: "\(counts.actions)"))
countRow("flag.checkered", String(localized: "목표"), String(localized: "\(counts.goals)"))
}
Button("완료") { dismiss() }
.frame(maxWidth: .infinity)
} footer: {
Text("iCloud 동기화를 켰다면 다른 기기에는 잠시 뒤에 반영돼요.")
}
}
private var confirmTitle: String {
switch (scope, rangeMode) {
case (.recordsAndDiary, .all):
return String(localized: "모든 데이터를 삭제할까요?")
case (.recordsAndDiary, .upTo):
return String(localized: "\(Format.fullDate(endKey))까지의 기록·일기를 삭제할까요?")
case (.diaryOnly, .all):
return String(localized: "모든 일기를 삭제할까요?")
case (.diaryOnly, .upTo):
return String(localized: "\(Format.fullDate(endKey))까지의 일기를 삭제할까요?")
}
}
private var confirmMessage: String {
switch (scope, rangeMode) {
case (.recordsAndDiary, .all):
return String(localized: "행동·꼬리표·목표·다짐·기록·일기·양식이 모두 삭제돼요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요.")
case (.recordsAndDiary, .upTo):
return String(localized: "지정한 날짜까지의 시간·횟수 기록과 일기가 삭제돼요. 과거 기록이 사라지면 다짐의 연속 달성 기록이 끊겨요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요.")
case (.diaryOnly, _):
return String(localized: "일기의 필기·사진·할 일이 함께 삭제돼요. 시간·횟수 기록과 행동·목표, 일기 양식은 남아요. iCloud 동기화를 켰다면 다른 기기에서도 함께 삭제돼요.")
}
}
private var deleteButtonTitle: String {
switch (scope, rangeMode) {
case (.recordsAndDiary, .all): return String(localized: "전체 데이터 삭제…")
case (.recordsAndDiary, .upTo): return String(localized: "지정한 날짜까지 삭제…")
case (.diaryOnly, .all): return String(localized: "모든 일기 삭제…")
case (.diaryOnly, .upTo): return String(localized: "지정한 날짜까지 일기 삭제…")
}
}
private func countRow(_ symbol: String, _ title: String, _ value: String) -> some View {
HStack {
Label {
Text(title)
} icon: {
Image(systemName: symbol)
.foregroundStyle(AppTheme.green)
}
Spacer()
Text(value).foregroundStyle(.secondary)
}
}
// MARK: ( CSVExport·DiaryExportRenderer·ArchiveReport)
private func makeBackups(copyToDocuments: Bool = false) {
isGenerating = true
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(60))
var urls: [URL] = []
do {
let actions = try context.fetch(
FetchDescriptor<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 scope == .recordsAndDiary && includeCSV {
// CSVExport.fileSuffix(for:) dayKeys distantPast
// '~'
var suffix = ""
if rangeMode == .upTo {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd"
suffix = "-upto-\(formatter.string(from: endKey))"
}
urls += CSVExport.makeFiles(
actions: actions, sessions: sessions, entries: entries, goals: goals,
range: backupRange, fileSuffix: suffix
)
}
if includeDiary, !diaryEntries.isEmpty {
let orderedActions = LocalPrefs.orderedActions(actions, raw: actionOrderRaw)
let templates = try context.fetch(FetchDescriptor<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 scope == .recordsAndDiary && includeReport {
if let url = ArchiveReport.makeURL(
format: reportFormat == .pdf ? .pdf : .image,
actions: actions, sessions: sessions, entries: entries, goals: goals,
diaryDayKeys: diaryEntries.map(\.dayKey),
range: backupRange, math: math
) {
urls.append(url)
}
}
} catch {
urls = []
}
backupURLs = urls
isGenerating = false
#if DEBUG
if copyToDocuments,
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
for url in urls {
let dest = docs.appendingPathComponent(url.lastPathComponent)
try? FileManager.default.removeItem(at: dest)
try? FileManager.default.copyItem(at: url, to: dest)
}
}
#endif
}
}
// MARK:
private func performDelete() {
isDeleting = true
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(60))
do {
let counts: DataReset.Counts
switch (scope, rangeMode) {
case (.diaryOnly, .all):
counts = try DataReset.deleteDiaries(upToDayKey: nil, context: context)
case (.diaryOnly, .upTo):
counts = try DataReset.deleteDiaries(upToDayKey: endKey, context: context)
case (.recordsAndDiary, .all):
counts = try DataReset.deleteAll(context: context)
case (.recordsAndDiary, .upTo):
counts = try DataReset.deleteRecords(upToDayKey: endKey, context: context, math: math)
}
// + ··LiveActivity ( )
DataChange.commit(context: context)
deletedCounts = counts
} catch {
deletedCounts = nil
}
isDeleting = false
}
}
}