From d7b602f08bf158b5afd20d674a0c5f269616d874 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Tue, 14 Jul 2026 00:16:10 +0900 Subject: [PATCH] feat(export): raw data export to CSV from Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 → 데이터 → '데이터 내보내기': 행동·시간 기록·횟수 기록·목표·다짐을 CSV 5개 파일로 만들어 공유한다 (백업·이동·스프레드시트 분석용). - 가공 없는 원본 값, 시각은 ISO 8601, 필드 이스케이프 처리, 엑셀 한글 인식용 UTF-8 BOM - 무료 사용자도 사용 가능 (iCloud 백업이 없는 무료 사용자의 백업 수단) - 검증용 -dataExportPreview(화면 직행)·-dataExportRun(자동 생성 후 Documents 복사) 추가. 시뮬레이터에서 5개 파일 생성·내용 확인 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/IOS/ContentView.swift | 2 + .../HaruDanim/IOS/Views/DataExportView.swift | 173 ++++++++++++++++++ myApp/HaruDanim/IOS/Views/SettingsView.swift | 11 ++ 3 files changed, 186 insertions(+) create mode 100644 myApp/HaruDanim/IOS/Views/DataExportView.swift diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index 594e11e..c3b9a27 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -25,6 +25,8 @@ struct ContentView: View { NavigationStack { PremiumView() } } else if UserDefaults.standard.bool(forKey: "helpPreview") { NavigationStack { HelpView() } + } else if UserDefaults.standard.bool(forKey: "dataExportPreview") { + NavigationStack { DataExportView() } } else { RootNavigationView() } diff --git a/myApp/HaruDanim/IOS/Views/DataExportView.swift b/myApp/HaruDanim/IOS/Views/DataExportView.swift new file mode 100644 index 0000000..5864767 --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/DataExportView.swift @@ -0,0 +1,173 @@ +// +// 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("행동", 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("시간기록", 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("횟수기록", 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("목표", 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("다짐", 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("하루다님-\(name).csv") + do { + try content.write(to: url, atomically: true, encoding: .utf8) + return url + } catch { + return nil + } + } +} diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 0dabfd2..4c4d413 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -173,6 +173,17 @@ struct SettingsView: View { } } .id("premiumSection") + Section { + NavigationLink { + DataExportView() + } label: { + Label("데이터 내보내기", systemImage: "tablecells") + } + } header: { + Text("데이터") + } footer: { + Text("모든 기록을 CSV 파일로 만들어 백업하거나 스프레드시트에서 열 수 있어요.") + } Section("지원") { NavigationLink { HelpView()