// // 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 } }