- 기존 동작 검증: 전량 내보내기(무필터)였음을 확인 후 '전체(기본)/직접 지정' 세그먼트 추가 - 세션은 구간 겹침 포함(기록 탭 범위 문법), 구간 파일명 접미 -yyyyMMdd-yyyyMMdd - 행동·목표·다짐 목록은 항상 전체(가져오기 연결·백업 온전성) — footer로 안내 - CSV 생성 코어 CSVExport 신설: 데이터 정리·초기화(예정)의 삭제 전 백업이 재사용 - -dataExportRangeDays 검증 인자, 전체(2031·8044건)↔최근 7일(4·1건) 실파일 대조 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
187 lines
7.8 KiB
Swift
187 lines
7.8 KiB
Swift
//
|
|
// DataExportView.swift
|
|
// Haru_Danim
|
|
//
|
|
// 원시 데이터 내보내기 (설정 → 데이터).
|
|
// 모든 기록을 CSV 파일로 만들어 공유한다 — 백업·이동·스프레드시트 분석용.
|
|
// 리포트 이미지(ExportImageView)와 달리 가공 없는 원본 값을 담는다.
|
|
// 무료 사용자도 사용 가능 (자기 데이터에 대한 접근권).
|
|
// 1.4: 구간 지정 옵션 — 기록(시간·횟수)만 구간으로 거르고 행동·목표·다짐 목록은
|
|
// 항상 전체를 담는다. CSV 생성 코어는 CSVExport(초기화 백업과 공유)로 추출.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct DataExportView: View {
|
|
@Query(sort: \Action.createdAt) private var actions: [Action]
|
|
@Query(sort: \TimeSession.startAt) private var sessions: [TimeSession]
|
|
@Query(sort: \CountEntry.timestamp) private var entries: [CountEntry]
|
|
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal]
|
|
|
|
private enum RangeMode: String, CaseIterable, Identifiable {
|
|
case all, custom
|
|
var id: String { rawValue }
|
|
var label: String {
|
|
switch self {
|
|
case .all: return String(localized: "전체")
|
|
case .custom: return String(localized: "직접 지정")
|
|
}
|
|
}
|
|
}
|
|
|
|
@State private var rangeMode: RangeMode = .all
|
|
@State private var startDate: Date = Calendar.current.date(byAdding: .day, value: -30, to: .now) ?? .now
|
|
@State private var endDate: Date = .now
|
|
@State private var isRendering = false
|
|
@State private var resultURLs: [URL] = []
|
|
|
|
private var math: DayMath { DayMath() }
|
|
|
|
/// 선택한 달력일 구간 → 논리적 하루 경계 범위 (§4.1: 달력일은 startOfDay를 키로 직접 사용)
|
|
private var selectedRange: Range<Date>? {
|
|
guard rangeMode == .custom else { return nil }
|
|
let startKey = math.calendar.startOfDay(for: min(startDate, endDate))
|
|
let endKey = math.calendar.startOfDay(for: max(startDate, endDate))
|
|
return math.dayRange(forKey: startKey).lowerBound..<math.dayRange(forKey: endKey).upperBound
|
|
}
|
|
|
|
/// 목록 행 카운트가 실제로 내보낼 건수를 보여 주도록 구간 반영
|
|
private var visibleSessionCount: Int {
|
|
guard let range = selectedRange else { return sessions.count }
|
|
let now = Date.now
|
|
return sessions.count {
|
|
$0.startAt < range.upperBound && ($0.endAt ?? now) > range.lowerBound
|
|
}
|
|
}
|
|
|
|
private var visibleEntryCount: Int {
|
|
guard let range = selectedRange else { return entries.count }
|
|
return entries.count { range.contains($0.timestamp) }
|
|
}
|
|
|
|
var body: some View {
|
|
List {
|
|
Section {
|
|
Picker("구간", selection: $rangeMode) {
|
|
ForEach(RangeMode.allCases) { mode in
|
|
Text(mode.label).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
if rangeMode == .custom {
|
|
DatePicker("부터", selection: $startDate, in: ...endDate, displayedComponents: .date)
|
|
DatePicker("까지", selection: $endDate, in: startDate..., displayedComponents: .date)
|
|
}
|
|
} header: {
|
|
Text("내보낼 구간")
|
|
} footer: {
|
|
if rangeMode == .custom {
|
|
Text("시간·횟수 기록만 구간으로 걸러져요. 행동·목표·다짐 목록은 항상 전체가 담겨요 (가져오기에서 기록을 행동과 연결하는 데 필요해요).")
|
|
}
|
|
}
|
|
Section {
|
|
row("figure.walk", String(localized: "행동"), String(localized: "\(actions.count)개"))
|
|
row("timer", String(localized: "시간 기록"), String(localized: "\(visibleSessionCount)건"))
|
|
row("number", String(localized: "횟수 기록"), String(localized: "\(visibleEntryCount)건"))
|
|
row("flag.checkered", String(localized: "목표·다짐"), String(localized: "\(goals.count)개"))
|
|
} header: {
|
|
Text("내보낼 데이터")
|
|
} footer: {
|
|
Text("행동·시간 기록·횟수 기록·목표·다짐을 각각 CSV 파일로 만들어요. 스프레드시트에서 열거나 백업으로 보관할 수 있어요. 시각은 ISO 8601 형식이에요.")
|
|
}
|
|
Section {
|
|
if isRendering {
|
|
HStack {
|
|
ProgressView()
|
|
Text("만드는 중…")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} else if resultURLs.isEmpty {
|
|
Button {
|
|
export()
|
|
} label: {
|
|
Label("CSV 만들기", systemImage: "tablecells")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
} else {
|
|
ShareLink(items: resultURLs) {
|
|
Label("파일 \(resultURLs.count)개 공유", systemImage: "square.and.arrow.up")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
Button("다시 만들기") {
|
|
resultURLs = []
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
.tint(AppTheme.green)
|
|
.scrollContentBackground(.hidden)
|
|
.background(AppTheme.background)
|
|
.navigationTitle("데이터 내보내기")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar(.hidden, for: .tabBar)
|
|
.onChange(of: rangeMode) { resultURLs = [] }
|
|
.onChange(of: startDate) { resultURLs = [] }
|
|
.onChange(of: endDate) { resultURLs = [] }
|
|
#if DEBUG
|
|
.onAppear {
|
|
// 검증용: -dataExportRangeDays <N> → 최근 N일 구간 지정 상태로 시작
|
|
let rangeDays = UserDefaults.standard.integer(forKey: "dataExportRangeDays")
|
|
if rangeDays > 0 {
|
|
rangeMode = .custom
|
|
startDate = math.calendar.date(byAdding: .day, value: -(rangeDays - 1), to: .now) ?? .now
|
|
endDate = .now
|
|
}
|
|
// 검증용: -dataExportRun YES → 자동 생성 후 Documents에 결과 복사
|
|
if UserDefaults.standard.bool(forKey: "dataExportRun") {
|
|
export()
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func row(_ symbol: String, _ title: String, _ count: String) -> some View {
|
|
HStack {
|
|
Label {
|
|
Text(title)
|
|
} icon: {
|
|
Image(systemName: symbol)
|
|
.foregroundStyle(AppTheme.green)
|
|
}
|
|
Spacer()
|
|
Text(count).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: CSV 생성 (코어는 CSVExport — 초기화 백업과 공유)
|
|
|
|
private func export() {
|
|
isRendering = true
|
|
Task { @MainActor in
|
|
let range = selectedRange
|
|
let suffix = range.map { CSVExport.fileSuffix(for: $0, math: math) } ?? ""
|
|
resultURLs = CSVExport.makeFiles(
|
|
actions: actions,
|
|
sessions: sessions,
|
|
entries: entries,
|
|
goals: goals,
|
|
range: range,
|
|
fileSuffix: suffix
|
|
)
|
|
isRendering = false
|
|
#if DEBUG
|
|
if UserDefaults.standard.bool(forKey: "dataExportRun"),
|
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
for url in resultURLs {
|
|
let dest = docs.appendingPathComponent(url.lastPathComponent)
|
|
try? FileManager.default.removeItem(at: dest)
|
|
try? FileManager.default.copyItem(at: url, to: dest)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
}
|