mycode/myApp/HaruDanim/IOS/Views/DataExportView.swift
songyc macbook bdc771455b feat(l10n): localize CSV header row per language
- CSV 열 이름도 언어별 현지화 (파일명 현지화 b17a199의 후속, 사용자 결정) — 스프레드시트에서 열 이름은 곧 UI. 값의 rawValue(time/count, inProgress 등)는 데이터라 언어 무관 유지
- 기존 키 최대 재사용(이름/시각/수량/메모/방향/종료일/꼬리표/즐겨찾기/시작/종료 + '추적 방식' — ko 헤더만 '추적방식'→'추적 방식'으로 공백 하나 추가됨), 신규 10키(행동id/만든날/초/내용/시작일/상태/달성기준%/대상/주기/목표량) en/ja 완비 — 카탈로그 761키 missing/stale 0
- 검증(시뮬레이터 실헤더): en "id,Name,Tracking type,Tags,Favorite,Created" 등 5파일, ja "id,名前,計測方式,タグ,お気に入り,作成日" 등 5파일, ko 회귀 정상. Debug·Store 빌드 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-16 10:28:53 +09:00

191 lines
8.7 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?] = []
// ·( ) ,
// UI. rawValue(time/count ) .
urls.append(write(String(localized: "행동"), header: [
"id", String(localized: "이름"), String(localized: "추적 방식"),
String(localized: "꼬리표"), String(localized: "즐겨찾기"), String(localized: "만든날"),
],
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: [
String(localized: "행동id"), String(localized: "행동"), String(localized: "시작"),
String(localized: "종료"), String(localized: ""), String(localized: "메모"),
],
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: [
String(localized: "행동id"), String(localized: "행동"), String(localized: "시각"),
String(localized: "수량"), String(localized: "메모"),
],
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", String(localized: "내용"), String(localized: "시작일"),
String(localized: "종료일"), String(localized: "상태"), String(localized: "달성기준%"),
],
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", String(localized: "목표"), String(localized: "대상"),
String(localized: "주기"), String(localized: "목표량"), String(localized: "방향"),
],
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
}
}
}