mycode/myApp/HaruDanim/IOS/Views/DataImportView.swift
songyc macbook 2dc81fe0e5 feat: 버전 1.1 — 맥 일기 개방 + CSV 가져오기(프리미엄) + 스크린샷 전면 개편
맥 일기 (§6.7):
- 사이드바에 일기 탭 개방 (AppTab.sidebarCases 맥 필터 제거)
- 맥 노트 도구줄: 텍스트 우선 가로 스크롤 + '그리기' 토글(마우스·트랙패드 필기, drawingPolicy anyInput)
- 그리기↔배치 상호 배타, 일기 잠금 설정 맥 노출 — 아이패드 경로는 코드 불변(회귀 차단)

CSV 가져오기 (§6.8·프리미엄):
- IOS/Core/CSVImport.swift: RFC4180 파서·데이터 행 모양 판별(언어 무관)·계획/반영 분리·초 단위 멱등 중복 처리
- 행동 연결: uuid 우선 → 이름+방식 폴백 → 새로 생성(uuid 보존), '측정 중' 행 제외, 추가 전용
- DataImportView: 파일 선택(복수) → 미리 보기 → 확인 → 저장 1회. 무료 한도 우회 방지 위해 프리미엄 게이트
- 자가 테스트 -csvImportTest 20건 ALL PASS, 진행률 자가 테스트 회귀 ALL PASS

문서·현지화·버전:
- 도움말(일기 맥·CSV 복원)·프리미엄 기능 행·설정 문구 갱신, en/ja 42키 번역·stale 10키 정리 (missing 0)
- CLAUDE.md §1/§6/§6.7/§6.8/§8/§14, Marketing 설명 ko/en/ja·whats-new-1.1, MARKETING_VERSION 1.1(4타깃)

스크린샷 전면 재제작 (ko/en/ja):
- 아이폰 10장·아이패드 10장(신규: 기록 목록/주간 타임테이블/일기 양식/다크/목표)·워치 합성 1장
- 시간대 정합: SIMCTL_CHILD_TZ 부팅 주입(Asia/Bangkok) + -marketingTZ 프로브 검증 절차 확립(README)
- frame_screenshots.swift: 문구 5종 추가·일기/동기화 문구 맥 반영·renderWatch 합성 추가 (총 63장)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVeduv1eNdXjk1ay4tSgBg
2026-07-29 11:30:25 +09:00

254 lines
10 KiB
Swift

//
// DataImportView.swift
// Haru_Danim
//
// CSV ( , IOS/Core/CSVImport.swift).
// : () ( ) '' .
// (: CSV )
// ( 2026-07-29 ).
//
import SwiftUI
import SwiftData
import UniformTypeIdentifiers
struct DataImportView: View {
@Environment(\.modelContext) private var context
private let premium = PremiumManager.shared
@State private var showingPicker = false
@State private var showingPremiumSheet = false
/// , ( apply )
@State private var plan: CSVImport.Plan?
@State private var loadedFiles: [CSVImport.LoadedFile] = []
/// ( · )
@State private var fileNotices: [String] = []
@State private var summary: CSVImport.Summary?
@State private var isWorking = false
var body: some View {
List {
if !premium.isPremium {
lockedSection
} else if let summary {
doneSection(summary)
} else {
pickSection
if plan != nil {
previewSection
}
}
}
.tint(AppTheme.green)
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("데이터 가져오기")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.fileImporter(
isPresented: $showingPicker,
allowedContentTypes: [.commaSeparatedText, .plainText],
allowsMultipleSelection: true
) { result in
load(result)
}
.sheet(isPresented: $showingPremiumSheet) {
PremiumSheetView()
}
}
// MARK:
private var lockedSection: some View {
Section {
VStack(spacing: 12) {
Image(systemName: "crown.fill")
.font(.system(size: 34))
.foregroundStyle(AppTheme.yellow)
Text("가져오기는 프리미엄 기능이에요")
.font(.headline)
Text("내보냈던 CSV 파일에서 행동과 시간·횟수 기록을 복원할 수 있어요. 데이터 내보내기는 무료로 계속 쓸 수 있어요.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
showingPremiumSheet = true
} label: {
Label("프리미엄 알아보기", systemImage: "crown")
.padding(.horizontal, 6)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
}
}
// MARK:
private var pickSection: some View {
Section {
Button {
summary = nil
showingPicker = true
} label: {
Label("CSV 파일 선택", systemImage: "folder")
.frame(maxWidth: .infinity)
}
.disabled(isWorking)
ForEach(Array(fileNotices.enumerated()), id: \.offset) { _, notice in
Label(notice, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("가져올 파일")
} footer: {
Text("'데이터 내보내기'로 만든 행동·시간 기록·횟수 기록 CSV를 선택하세요 (여러 개 가능, 내보낸 언어와 달라도 돼요). 기존 데이터는 바꾸지 않고 새 기록만 추가하며, 이미 있는 기록은 자동으로 건너뛰어요. 목표와 다짐은 CSV로 가져오지 않아요 — 기록이 복원되면 새로 만든 다짐에 그대로 집계돼요.")
}
}
// MARK:
@ViewBuilder
private var previewSection: some View {
if let plan {
Section {
ForEach(Array(plan.fileSummaries.enumerated()), id: \.offset) { _, line in
Label(line, systemImage: "doc.text")
.font(.subheadline)
}
if !plan.newActions.isEmpty {
row(String(localized: "새로 만드는 행동 \(plan.newActions.count)"),
detail: plan.newActions.prefix(4).map(\.name).joined(separator: ", ")
+ (plan.newActions.count > 4 ? "" : ""))
}
if plan.matchedByID + plan.linkedByName > 0 {
row(String(localized: "기존 행동에 이어서 \(plan.matchedByID + plan.linkedByName)"),
detail: plan.linkedByName > 0
? String(localized: "이름이 같아 이어진 행동 \(plan.linkedByName)개 포함")
: nil)
}
row(String(localized: "추가할 기록 — 시간 \(plan.sessions.count)건 · 횟수 \(plan.counts.count)"), detail: nil)
if plan.duplicateCount + plan.runningSkipped + plan.invalidCount > 0 {
row(String(localized: "건너뜀 — 중복 \(plan.duplicateCount)건 · 측정 중 \(plan.runningSkipped)건 · 오류 \(plan.invalidCount)"),
detail: plan.invalidSamples.first)
}
} header: {
Text("미리 보기")
} footer: {
if !plan.hasWork {
Text("가져올 새 내용이 없어요. 모두 이미 있는 기록이에요.")
}
}
Section {
Button {
applyPlan()
} label: {
if isWorking {
HStack {
ProgressView()
Text("가져오는 중…")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
} else {
Label("가져오기", systemImage: "square.and.arrow.down")
.frame(maxWidth: .infinity)
}
}
.disabled(!plan.hasWork || isWorking)
}
}
}
private func row(_ title: String, detail: String?) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.medium))
if let detail, !detail.isEmpty {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK:
private func doneSection(_ summary: CSVImport.Summary) -> some View {
Section {
VStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 34))
.foregroundStyle(AppTheme.green)
Text("가져오기 완료")
.font(.headline)
Text("행동 \(summary.actionsCreated)개 · 시간 기록 \(summary.sessionsAdded)건 · 횟수 기록 \(summary.countsAdded)건을 추가했어요.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if summary.duplicatesSkipped > 0 {
Text("이미 있던 기록 \(summary.duplicatesSkipped)건은 건너뛰었어요.")
.font(.caption)
.foregroundStyle(.tertiary)
}
Button("다른 파일 가져오기") {
reset()
}
.font(.callout)
.padding(.top, 4)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
}
}
// MARK:
private func reset() {
plan = nil
loadedFiles = []
fileNotices = []
summary = nil
}
private func load(_ result: Result<[URL], Error>) {
guard case .success(let urls) = result else { return }
reset()
var files: [CSVImport.LoadedFile] = []
for url in urls {
let accessing = url.startAccessingSecurityScopedResource()
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
let name = url.lastPathComponent
guard let data = try? Data(contentsOf: url),
let text = String(data: data, encoding: .utf8) else {
fileNotices.append(String(localized: "UTF-8 텍스트 파일이 아니에요: \(name)"))
continue
}
let rows = CSVImport.parse(text)
guard let kind = CSVImport.classify(rows) else {
fileNotices.append(String(localized: "가져올 수 있는 형식이 아니에요: \(name)"))
continue
}
files.append(CSVImport.LoadedFile(name: name, kind: kind, rows: rows))
}
guard !files.isEmpty else { return }
loadedFiles = files
plan = CSVImport.makePlan(files: files, context: context)
}
private func applyPlan() {
guard let plan, plan.hasWork else { return }
isWorking = true
// ·· DataChange.commit 1
let result = CSVImport.apply(plan, context: context)
DataChange.commit(context: context)
summary = result
self.plan = nil
loadedFiles = []
isWorking = false
}
}