- 내보내기 파일명을 언어별로 현지화 — 수신 환경(PC·클라우드·메일)이 한글 파일명을 지원하지 않을 수 있음 (사용자 결정). ko는 기존 그대로("하루다님-기록-…"), en/ja는 라틴 접두 "HaruDanim-" + 그 언어의 종류 단어 (History/記録, Diary/日記, Stats/統計, Actions/アクション 등)
- 대상 전부: 기록·통계 리포트 PNG(ExportSnapshot.fileName + 슬러그 3종), 일기 PDF·스티치 PNG(슬러그 3종 포함), CSV 5종(행동·목표·다짐은 기존 키 재사용, 시간기록·횟수기록 신규 키). CSV 헤더는 데이터로 보고 한국어 유지
- 카탈로그 신규 11키 en/ja 완비(751키 missing/stale 0), CLAUDE.md §7에 규칙 기재
- 검증(시뮬레이터 실파일명): en CSV 5종 "HaruDanim-Actions/TimeRecords/CountRecords/Goals/Quest.csv", en 리포트 "HaruDanim-History-2026-07-16.png", iPad en 일기 "HaruDanim-Diary-2026-07-15~2026-07-16.pdf"(기간 슬러그), ko 회귀 "하루다님-행동.csv" 등 5종 그대로. Debug·Store 빌드 성공
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
175 lines
7.9 KiB
Swift
175 lines
7.9 KiB
Swift
//
|
|
// DataExportView.swift
|
|
// Haru_Danim
|
|
//
|
|
// 원시 데이터 내보내기 (설정 → 데이터).
|
|
// 모든 기록을 CSV 파일로 만들어 공유한다 — 백업·이동·스프레드시트 분석용.
|
|
// 리포트 이미지(ExportImageView)와 달리 가공 없는 원본 값을 담는다.
|
|
// 무료 사용자도 사용 가능 (자기 데이터에 대한 접근권).
|
|
//
|
|
|
|
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]
|
|
|
|
@State private var isRendering = false
|
|
@State private var resultURLs: [URL] = []
|
|
|
|
var body: some View {
|
|
List {
|
|
Section {
|
|
row("figure.walk", String(localized: "행동"), String(localized: "\(actions.count)개"))
|
|
row("timer", String(localized: "시간 기록"), String(localized: "\(sessions.count)건"))
|
|
row("number", String(localized: "횟수 기록"), String(localized: "\(entries.count)건"))
|
|
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)
|
|
#if DEBUG
|
|
.onAppear {
|
|
// 검증용: -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 생성
|
|
|
|
private func export() {
|
|
isRendering = true
|
|
Task { @MainActor in
|
|
var urls: [URL?] = []
|
|
// 파일명은 언어별 현지화 (수신 환경이 한글을 지원하지 않을 수 있음 — 헤더는 데이터라 유지)
|
|
urls.append(write(String(localized: "행동"), header: ["id", "이름", "추적방식", "꼬리표", "즐겨찾기", "만든날"],
|
|
rows: actions.map { action in
|
|
[action.uuid.uuidString, action.name,
|
|
action.trackingType == .time ? "time" : "count",
|
|
action.sortedTags.map(\.name).joined(separator: "; "),
|
|
action.isFavorite ? "true" : "false",
|
|
iso(action.createdAt)]
|
|
}))
|
|
urls.append(write(String(localized: "시간기록", comment: "CSV 파일명용 (공백 없이)"), header: ["행동id", "행동", "시작", "종료", "초", "메모"],
|
|
rows: sessions.compactMap { session in
|
|
guard let action = session.action else { return nil }
|
|
return [action.uuid.uuidString, action.name,
|
|
iso(session.startAt),
|
|
session.endAt.map(iso) ?? "",
|
|
String(Int(session.duration())),
|
|
session.note]
|
|
}))
|
|
urls.append(write(String(localized: "횟수기록", comment: "CSV 파일명용 (공백 없이)"), header: ["행동id", "행동", "시각", "수량", "메모"],
|
|
rows: entries.compactMap { entry in
|
|
guard let action = entry.action else { return nil }
|
|
return [action.uuid.uuidString, action.name,
|
|
iso(entry.timestamp), String(entry.amount), entry.note]
|
|
}))
|
|
urls.append(write(String(localized: "목표"), header: ["id", "내용", "시작일", "종료일", "상태", "달성기준%"],
|
|
rows: goals.map { goal in
|
|
[goal.uuid.uuidString, goal.title, iso(goal.startDate),
|
|
goal.endDate.map(iso) ?? "", goal.statusRaw,
|
|
String(goal.achieveThresholdPercent)]
|
|
}))
|
|
urls.append(write(String(localized: "다짐"), header: ["id", "목표", "대상", "주기", "목표량", "방향"],
|
|
rows: goals.flatMap(\.sortedQuests).map { quest in
|
|
[quest.uuid.uuidString, quest.goal?.title ?? "",
|
|
quest.targetName, quest.scheduleLabel,
|
|
quest.measure == .time ? String(Int(quest.targetSeconds)) + "s" : String(quest.targetCount),
|
|
quest.direction == .atLeast ? "atLeast" : "atMost"]
|
|
}))
|
|
resultURLs = urls.compactMap(\.self)
|
|
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
|
|
}
|
|
}
|
|
|
|
private func iso(_ date: Date) -> String {
|
|
date.formatted(.iso8601)
|
|
}
|
|
|
|
/// CSV 필드 이스케이프 (쉼표·따옴표·줄바꿈 포함 시 따옴표로 감쌈)
|
|
private func escape(_ field: String) -> String {
|
|
if field.contains(",") || field.contains("\"") || field.contains("\n") {
|
|
return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\""
|
|
}
|
|
return field
|
|
}
|
|
|
|
private func write(_ name: String, header: [String], rows: [[String]]) -> URL? {
|
|
let lines = [header.map(escape).joined(separator: ",")]
|
|
+ rows.map { $0.map(escape).joined(separator: ",") }
|
|
// 엑셀이 UTF-8 한글을 바로 인식하도록 BOM을 붙인다
|
|
let content = "\u{FEFF}" + lines.joined(separator: "\n")
|
|
let url = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(String(localized: "하루다님-\(name).csv"))
|
|
do {
|
|
try content.write(to: url, atomically: true, encoding: .utf8)
|
|
return url
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
}
|