맥 일기 (§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
254 lines
10 KiB
Swift
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
|
|
}
|
|
}
|