diff --git a/.DS_Store b/.DS_Store index fca884c..a2b29aa 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/JLPTVoca/.DS_Store b/JLPTVoca/.DS_Store index 7d1e7eb..e1fdf24 100644 Binary files a/JLPTVoca/.DS_Store and b/JLPTVoca/.DS_Store differ diff --git a/JLPTVoca/JLPTVoca.xcodeproj/project.pbxproj b/JLPTVoca/JLPTVoca.xcodeproj/project.pbxproj index 3c87686..df9f462 100644 --- a/JLPTVoca/JLPTVoca.xcodeproj/project.pbxproj +++ b/JLPTVoca/JLPTVoca.xcodeproj/project.pbxproj @@ -14,24 +14,40 @@ remoteGlobalIDString = 214FEB5F2E5E320300C87957; remoteInfo = JLPTVoca; }; + 216B03632E9811150086B1F0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 214FEB582E5E320300C87957 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 214FEB5F2E5E320300C87957; + remoteInfo = JLPTVoca; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ 214FEB602E5E320300C87957 /* JLPTVoca.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = JLPTVoca.app; sourceTree = BUILT_PRODUCTS_DIR; }; 216132B32E71D3C300DE17B7 /* JLPTVocaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JLPTVocaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 216B035F2E9811150086B1F0 /* JLPTVocaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JLPTVocaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 218805562E7E60E000F5F235 /* Exceptions for "JLPTVoca" folder in "JLPTVoca" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 214FEB5F2E5E320300C87957 /* JLPTVoca */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 214FEB622E5E320300C87957 /* JLPTVoca */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 218805562E7E60E000F5F235 /* Exceptions for "JLPTVoca" folder in "JLPTVoca" target */, + ); path = JLPTVoca; sourceTree = ""; }; - 216132B42E71D3C300DE17B7 /* JLPTVocaUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = JLPTVocaUITests; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -49,6 +65,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 216B035C2E9811150086B1F0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -56,7 +79,6 @@ isa = PBXGroup; children = ( 214FEB622E5E320300C87957 /* JLPTVoca */, - 216132B42E71D3C300DE17B7 /* JLPTVocaUITests */, 214FEB612E5E320300C87957 /* Products */, ); sourceTree = ""; @@ -66,6 +88,7 @@ children = ( 214FEB602E5E320300C87957 /* JLPTVoca.app */, 216132B32E71D3C300DE17B7 /* JLPTVocaUITests.xctest */, + 216B035F2E9811150086B1F0 /* JLPTVocaTests.xctest */, ); name = Products; sourceTree = ""; @@ -108,9 +131,6 @@ dependencies = ( 216132B82E71D3C300DE17B7 /* PBXTargetDependency */, ); - fileSystemSynchronizedGroups = ( - 216132B42E71D3C300DE17B7 /* JLPTVocaUITests */, - ); name = JLPTVocaUITests; packageProductDependencies = ( ); @@ -118,6 +138,26 @@ productReference = 216132B32E71D3C300DE17B7 /* JLPTVocaUITests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 216B035E2E9811150086B1F0 /* JLPTVocaTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 216B03652E9811150086B1F0 /* Build configuration list for PBXNativeTarget "JLPTVocaTests" */; + buildPhases = ( + 216B035B2E9811150086B1F0 /* Sources */, + 216B035C2E9811150086B1F0 /* Frameworks */, + 216B035D2E9811150086B1F0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 216B03642E9811150086B1F0 /* PBXTargetDependency */, + ); + name = JLPTVocaTests; + packageProductDependencies = ( + ); + productName = JLPTVocaTests; + productReference = 216B035F2E9811150086B1F0 /* JLPTVocaTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -135,6 +175,10 @@ CreatedOnToolsVersion = 16.4; TestTargetID = 214FEB5F2E5E320300C87957; }; + 216B035E2E9811150086B1F0 = { + CreatedOnToolsVersion = 16.4; + TestTargetID = 214FEB5F2E5E320300C87957; + }; }; }; buildConfigurationList = 214FEB5B2E5E320300C87957 /* Build configuration list for PBXProject "JLPTVoca" */; @@ -153,6 +197,7 @@ targets = ( 214FEB5F2E5E320300C87957 /* JLPTVoca */, 216132B22E71D3C300DE17B7 /* JLPTVocaUITests */, + 216B035E2E9811150086B1F0 /* JLPTVocaTests */, ); }; /* End PBXProject section */ @@ -172,6 +217,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 216B035D2E9811150086B1F0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -189,6 +241,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 216B035B2E9811150086B1F0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -197,6 +256,11 @@ target = 214FEB5F2E5E320300C87957 /* JLPTVoca */; targetProxy = 216132B72E71D3C300DE17B7 /* PBXContainerItemProxy */; }; + 216B03642E9811150086B1F0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 214FEB5F2E5E320300C87957 /* JLPTVoca */; + targetProxy = 216B03632E9811150086B1F0 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -331,6 +395,7 @@ DEVELOPMENT_TEAM = 3HYFT54PK2; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = JLPTVoca/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -359,6 +424,7 @@ DEVELOPMENT_TEAM = 3HYFT54PK2; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = JLPTVoca/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -413,6 +479,42 @@ }; name = Release; }; + 216B03662E9811150086B1F0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3HYFT54PK2; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.JLPTVocaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/JLPTVoca.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/JLPTVoca"; + }; + name = Debug; + }; + 216B03672E9811150086B1F0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3HYFT54PK2; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.JLPTVocaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/JLPTVoca.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/JLPTVoca"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -443,6 +545,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 216B03652E9811150086B1F0 /* Build configuration list for PBXNativeTarget "JLPTVocaTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 216B03662E9811150086B1F0 /* Debug */, + 216B03672E9811150086B1F0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 214FEB582E5E320300C87957 /* Project object */; diff --git a/JLPTVoca/JLPTVoca/ContentView.swift b/JLPTVoca/JLPTVoca/ContentView.swift index 73b1d43..a84c129 100644 --- a/JLPTVoca/JLPTVoca/ContentView.swift +++ b/JLPTVoca/JLPTVoca/ContentView.swift @@ -9,30 +9,22 @@ import SwiftUI import SwiftData struct ContentView: View { - @Query private var words: [Word] - @Environment(\.modelContext) private var context - @State private var wordManager = WordManager() + @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding: Bool = false + @AppStorage("isDataPreloaded") private var isDataPreloaded: Bool = false + @Environment(WordManager.self) private var wordManager var body: some View { - TabView { - HomeView() - .tabItem { - Label("학습", systemImage: "house.fill") - } - - SelectDictionaryView() - .tabItem { - Label("사전", systemImage: "book.fill") - } - - SettingView() - .tabItem { - Label("설정", systemImage: "gearshape.fill") - } + Group { + if !hasCompletedOnboarding { + OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding) + } else if !isDataPreloaded { + ProgressView("단어 데이터를 준비 중입니다..") + } else { + MainTabView() + } } - .environment(wordManager) - .onAppear() { - wordManager.setContext(context: context) + .onAppear { + wordManager.prepareInitialData() } } } diff --git a/JLPTVoca/JLPTVoca/JLPTVocaApp.swift b/JLPTVoca/JLPTVoca/JLPTVocaApp.swift index cb9f2e0..91d701c 100644 --- a/JLPTVoca/JLPTVoca/JLPTVocaApp.swift +++ b/JLPTVoca/JLPTVoca/JLPTVocaApp.swift @@ -10,17 +10,20 @@ import SwiftData @main struct JLPTVocaApp: App { + let sharedModelConatainer: ModelContainer + let wordManager: WordManager + + init() { + let container = try! ModelContainer(for: Word.self, StudyState.self) + self.sharedModelConatainer = container + self.wordManager = WordManager(modelContainer: container) + } + var body: some Scene { WindowGroup { ContentView() } - .modelContainer(for: Word.self, onSetup: { result in - switch result { - case .success(let container): - DataLoader.loadInitialData(context: container.mainContext) - case .failure(let error): - fatalError("\(error.localizedDescription)") - } - }) + .modelContainer(sharedModelConatainer) + .environment(wordManager) } } diff --git a/JLPTVoca/JLPTVoca/Managers/WordManager.swift b/JLPTVoca/JLPTVoca/Managers/WordManager.swift index 80aa66c..627a468 100644 --- a/JLPTVoca/JLPTVoca/Managers/WordManager.swift +++ b/JLPTVoca/JLPTVoca/Managers/WordManager.swift @@ -7,87 +7,68 @@ import SwiftUI import SwiftData -import OSLog @Observable +@MainActor final class WordManager { - var studyStateDeck: [StudyState] = [] + let modelContainer: ModelContainer - private var context: ModelContext? - - func setContext(context: ModelContext) { - self.context = context - } - - func prepareSession() { - guard context != nil else { return } - - let reviewableStates = fetchReviewableStates() - let newWords = fetchNewWords(limit: 20) //TODO: RawVal 수정, 사용자 학습 목표량으로 수정 - let newWordStates = createStates(for: newWords) - - self.studyStateDeck = (reviewableStates + newWordStates).shuffled() - } - - private func fetchData(with descriptor: FetchDescriptor) -> [T] { - guard let context else { return [] } - - do { - return try context.fetch(descriptor) - } catch { - print("@Log - \(error.localizedDescription)") - return [] - } - } - - private func fetchReviewableStates() -> [StudyState] { - let now = Date() - let predicate = #Predicate { $0.reviewDate <= now } - let descriptor = FetchDescriptor(predicate: predicate) - - return fetchData(with: descriptor) + private var mainContext: ModelContext { + modelContainer.mainContext } - func fetchNewWords(limit: Int) -> [Word] { - let predicate = #Predicate { $0.state == nil } - var descriptor = FetchDescriptor(predicate: predicate) - descriptor.fetchLimit = limit - - return fetchData(with: descriptor) + init(modelContainer: ModelContainer) { + self.modelContainer = modelContainer } - private func createStates(for words: [Word]) -> [StudyState] { - guard let context else { return [] } + func prepareInitialData() { + let isPreloaded = UserDefaults.standard.bool(forKey: "isDataPreloaded") + guard !isPreloaded else { return } - return words.map { newWord in - let newState = StudyState(word: newWord) - context.insert(newState) - return newState + Task(priority: .background) { + do { + guard let url = Bundle.main.url(forResource: "words", withExtension: "json") else { + print("@Log - Failed to find json file") + return + } + let data = try Data(contentsOf: url) + let words = try JSONDecoder().decode([Word].self, from: data) + + let backgroundContext = ModelContext(modelContainer) + + for word in words { + backgroundContext.insert(word) + } + + try backgroundContext.save() + + await MainActor.run { + UserDefaults.standard.set(true, forKey: "isDataPreloaded") + print("@Log - Preload succeed") + } + } catch { + print("@Log - Failed to preload") + } } } - func onCardSwipe( - id: UUID, - direction: CardSwipeDirection + func updateStudyState( + for state: StudyState, + direction: CardSwipeDirection, + shouldSave: Bool = true ) { - guard let swipedState = studyStateDeck.first(where: { $0.word.id == id }) else { - return - } - if direction == .left { - swipedState.maturityState = min(swipedState.maturityState + 1, 5) + state.maturityState = 1 } else { - swipedState.maturityState = 1 + state.maturityState = min(state.maturityState + 1, 5) } - updateNextReviewDate(for: swipedState) - studyStateDeck.removeAll { $0.word.id == id } - if studyStateDeck.isEmpty { - do { - try context?.save() - } catch { - print("@Log - \(error.localizedDescription)") - } + updateNextReviewDate(for: state) + + do { + try mainContext.save() + } catch { + print("@Log - Failed to save context") } } diff --git a/JLPTVoca/JLPTVoca/Models/StudyState.swift b/JLPTVoca/JLPTVoca/Models/StudyState.swift index a5a645f..d043518 100644 --- a/JLPTVoca/JLPTVoca/Models/StudyState.swift +++ b/JLPTVoca/JLPTVoca/Models/StudyState.swift @@ -13,7 +13,7 @@ final class StudyState { @Relationship var word: Word var maturityState: Int = 0 - var reviewDate: Date = Date() + var reviewDate: Date? = nil var isFavorite: Bool = false init(word: Word) { diff --git a/JLPTVoca/JLPTVoca/Models/UserInfo.swift b/JLPTVoca/JLPTVoca/Models/UserInfo.swift deleted file mode 100644 index b63771d..0000000 --- a/JLPTVoca/JLPTVoca/Models/UserInfo.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// UserInfo.swift -// JLPTVoca -// -// Created by Rama on 8/27/25. -// - -import Foundation -import SwiftData - -@Model -final class UserSettings { - var targetExamLevel: Int - var targetExamDate: Date - var dailyNewWordGoal: Int - - init( - targetExamLevel: Int, - targetExamDate: Date, - dailyNewWordGoal: Int - ) { - self.targetExamLevel = targetExamLevel - self.targetExamDate = targetExamDate - self.dailyNewWordGoal = dailyNewWordGoal - } -} diff --git a/JLPTVoca/JLPTVoca/Views/Enum/Maturity.swift b/JLPTVoca/JLPTVoca/Views/Enum/Maturity.swift new file mode 100644 index 0000000..164e1a2 --- /dev/null +++ b/JLPTVoca/JLPTVoca/Views/Enum/Maturity.swift @@ -0,0 +1,46 @@ +// +// Maturity.swift +// JLPTVoca +// +// Created by Rama on 9/21/25. +// + +import SwiftUI + +enum Maturity: Int, CaseIterable, Codable { + case unknown = 0 + case new = 1 + case uncertain = 2 + case proficient = 3 + case mastered = 4 + + var title: String { + switch self { + case .unknown: + return "미학습 단어" + case .new: + return "새로 배운 단어" + case .uncertain: + return "아직 서툰 단어" + case .proficient: + return "숙달된 단어" + case .mastered: + return "완전히 외운 단어" + } + } + + var color: Color { + switch self { + case .unknown: + return .black30 + case .new: + return .sedano + case .uncertain: + return .seotull + case .proficient: + return .sukdal + case .mastered: + return .coral50 + } + } + } diff --git a/JLPTVoca/JLPTVoca/Views/Home/DDayView.swift b/JLPTVoca/JLPTVoca/Views/Home/DDayCard.swift similarity index 82% rename from JLPTVoca/JLPTVoca/Views/Home/DDayView.swift rename to JLPTVoca/JLPTVoca/Views/Home/DDayCard.swift index 7346d17..cc33ba7 100644 --- a/JLPTVoca/JLPTVoca/Views/Home/DDayView.swift +++ b/JLPTVoca/JLPTVoca/Views/Home/DDayCard.swift @@ -1,5 +1,5 @@ // -// DDayView.swift +// DDayCard.swift // JLPTVoca // // Created by Rama on 8/27/25. @@ -7,7 +7,7 @@ import SwiftUI -struct DDayView: View { +struct DDayCard: View { let dDay: Int var body: some View { @@ -20,7 +20,3 @@ struct DDayView: View { .cornerRadius(12) } } - -#Preview { - DDayView(dDay: 20) -} diff --git a/JLPTVoca/JLPTVoca/Views/Home/DetailedWordChart.swift b/JLPTVoca/JLPTVoca/Views/Home/DetailedWordChart.swift new file mode 100644 index 0000000..194e627 --- /dev/null +++ b/JLPTVoca/JLPTVoca/Views/Home/DetailedWordChart.swift @@ -0,0 +1,67 @@ +// +// DetailedWordChart.swift +// JLPTVoca +// +// Created by Rama on 8/27/25. +// + + +import SwiftUI + + +struct DetailedWordChart: View { + let data: [ChartData] + var lineWidth: CGFloat = 12 + + var body: some View { + let total = data.reduce(0) { $0 + $1.count } + + guard total > 0 else { + return AnyView( + Circle() + .stroke(Color.gray.opacity(0.2), lineWidth: lineWidth) + ) + } + + var startAngle: Double = 0 + let segments = data.map { item -> (data: ChartData, range: Range) in + let ratio = Double(item.count) / Double(total) + let angle = ratio * 360 + let endAngle = startAngle + angle + let range = startAngle..() - @State private var selectedLevel: Int = 3 + @AppStorage("targetLevel") private var selectedLevel: Int = 5 + @AppStorage("dailyGoal") private var dailyGoal: Int = 5 + @State private var pendingLevel: Int? @State private var isLevelSelectorExpanded = false @State private var isDetailChartEnabled = false @@ -30,6 +34,7 @@ struct HomeView: View { Spacer().frame(height: 8) StudyDashboardView( + selectedLevel: selectedLevel, isDetailChartEnabled: $isDetailChartEnabled, isLevelSelectorExpanded: $isLevelSelectorExpanded ) @@ -97,8 +102,3 @@ struct HomeView: View { .environment(router) } } - -#Preview { - HomeView() - .environment(WordManager()) -} diff --git a/JLPTVoca/JLPTVoca/Views/Home/MultiSegmentCircularChart.swift b/JLPTVoca/JLPTVoca/Views/Home/MultiSegmentCircularChart.swift deleted file mode 100644 index f47af7e..0000000 --- a/JLPTVoca/JLPTVoca/Views/Home/MultiSegmentCircularChart.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// MultiSegmentCircularChart.swift -// JLPTVoca -// -// Created by Rama on 8/27/25. -// - - -import SwiftUI - -struct MultiSegmentCircularChart: View { - let chartType: ChartType - var lineWidth: CGFloat = 12 - - var body: some View { - let total = chartType.chartData.reduce(0) { $0 + $1.1 } - - var segments: [( - String, - Int, - Color, - Double, - Double - )] = [] - var currentAngle: Double = 0 - - for item in chartType.chartData { - let ratio = Double(item.1) / Double(total) - let angle = ratio * 360 - segments.append( - ( - item.0, - item.1, - item.2, - currentAngle, - currentAngle + angle - ) - ) - currentAngle += angle - } - - return ZStack { - Circle() - .stroke( - Color.gray.opacity(0.2), - lineWidth: lineWidth - ) - - ForEach(Array(segments.enumerated()), id: \.offset) { - index, - segment in - Circle() - .trim( - from: segment.3 / 360, - to: segment.4 / 360 - ) - .stroke( - segment.2, - style: StrokeStyle( - lineWidth: lineWidth, - lineCap: .round - ) - ) - .rotationEffect(.degrees(-90)) - } - - VStack(alignment: .leading, spacing: 8) { - ForEach(Array(chartType.chartData.enumerated()), id: \.offset) { - index, - item in - HStack { - Circle() - .fill(item.2) - .frame( - width: 12, - height: 12 - ) - - Text("\(item.0) : \(item.1)") - .font(.b5) - .foregroundColor(.black50) - - Spacer() - } - } - } - .padding(.leading, 40) - } - } -} - -#Preview { - MultiSegmentCircularChart(chartType: .studyOverview) - .frame( - width: 210, - height: 210 - ) -} diff --git a/JLPTVoca/JLPTVoca/Views/Home/StartButtonView.swift b/JLPTVoca/JLPTVoca/Views/Home/StartButtonView.swift index c21555d..f401c6a 100644 --- a/JLPTVoca/JLPTVoca/Views/Home/StartButtonView.swift +++ b/JLPTVoca/JLPTVoca/Views/Home/StartButtonView.swift @@ -12,17 +12,11 @@ struct StartButtonView: View { @Environment(NavigationManager.self) private var router var body: some View { - Button(action: { - wordManager.prepareSession() - router.navigate(.wordStudy) - }) { + Button(action: { router.navigate(.wordStudy) }) { HStack { Image(.bookIcon) .resizable() - .frame( - width: 27, - height: 27 - ) + .frame(width: 27, height: 27) Text("단어 외우러 가기") .font(.h1) @@ -35,9 +29,3 @@ struct StartButtonView: View { } } } - -#Preview { - StartButtonView() - .environment(WordManager()) - .environment(NavigationManager()) -} diff --git a/JLPTVoca/JLPTVoca/Views/Home/StudyDashboardView.swift b/JLPTVoca/JLPTVoca/Views/Home/StudyDashboardView.swift index bcf3559..8daaed5 100644 --- a/JLPTVoca/JLPTVoca/Views/Home/StudyDashboardView.swift +++ b/JLPTVoca/JLPTVoca/Views/Home/StudyDashboardView.swift @@ -8,43 +8,100 @@ import SwiftUI import SwiftData +struct ChartData: Identifiable { + let id = UUID() + let label: String + let count: Int + let color: Color +} + + struct StudyDashboardView: View { + let selectedLevel: Int + @Binding var isDetailChartEnabled: Bool @Binding var isLevelSelectorExpanded: Bool + @AppStorage("targetDate") private var targetDate: Date = Date() + @AppStorage("dailyGoal") private var dailyGoal: Int = 5 + + @Query private var words: [Word] + + init( + selectedLevel: Int, + isDetailChartEnabled: Binding, + isLevelSelectorExpanded: Binding + ) { + self.selectedLevel = selectedLevel + self._isDetailChartEnabled = isDetailChartEnabled + self._isLevelSelectorExpanded = isLevelSelectorExpanded + + let predicate = #Predicate { $0.jlptLevel == selectedLevel} + self._words = Query(filter: predicate) + } + + private var dDay: Int { + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: Date()) + let startOfTargetDay = calendar.startOfDay(for: targetDate) + let components = calendar.dateComponents( + [.day], + from: startOfToday, + to: startOfTargetDay + ) + + return components.day ?? 0 + } + + private var totalCount: Int { + words.count + } + + private var studiedCount: Int { + words.filter { ($0.state?.maturityState ?? 0) > 0 }.count + } + + private var maturityCounts: [Maturity: Int] { + let groupedByMaturity = Dictionary(grouping: words) { + let rawValue = $0.state?.maturityState ?? 0 + + return Maturity(rawValue: rawValue) ?? .unknown + } + + return groupedByMaturity.mapValues { $0.count } + } + + private var chartData: [ChartData] { + return Maturity.allCases.map { maturityLevel in + let count = maturityCounts[maturityLevel] ?? 0 + return ChartData( + label: maturityLevel.title, + count: count, + color: maturityLevel.color + ) + } + } + var body: some View { ZStack { Image(.dashboardBackground) .resizable() .scaledToFit() - VStack { - Spacer().frame(height: 136) + VStack(spacing: 26) { + Spacer().frame(height: 110) - DDayView(dDay: 20) - - Spacer().frame(height: 26) + DDayCard(dDay: dDay) if isDetailChartEnabled { - MultiSegmentCircularChart(chartType: .studyOverview) - .frame( - width: 210, - height: 210 - ) + DetailedWordChart(data: chartData) + .frame(width: 210, height: 210) } else { - CircularProgressView( - current: 1000, - total: 3000 - ) - .frame( - width: 210, - height: 210 - ) + WordChart(studiedCount: studiedCount, totalCount: totalCount) + .frame(width: 210, height: 210) } - Spacer().frame(height: 26) - ChartToggleButton(isDetailChartEnabled: $isDetailChartEnabled) Spacer() diff --git a/JLPTVoca/JLPTVoca/Views/Home/CircularProgressView.swift b/JLPTVoca/JLPTVoca/Views/Home/WordChart.swift similarity index 73% rename from JLPTVoca/JLPTVoca/Views/Home/CircularProgressView.swift rename to JLPTVoca/JLPTVoca/Views/Home/WordChart.swift index 32426a2..d04fa3a 100644 --- a/JLPTVoca/JLPTVoca/Views/Home/CircularProgressView.swift +++ b/JLPTVoca/JLPTVoca/Views/Home/WordChart.swift @@ -1,5 +1,5 @@ // -// CircularProgressView.swift +// WordChart.swift // JLPTVoca // // Created by Rama on 8/27/25. @@ -7,12 +7,12 @@ import SwiftUI -struct CircularProgressView: View { - let current: Int - let total: Int +struct WordChart: View { + let studiedCount: Int + let totalCount: Int var body: some View { - var progress: Double { Double(current) / Double(total) } + var progress: Double { Double(studiedCount) / Double(totalCount) } var percentage: Int { Int(progress * 100) } ZStack { @@ -36,15 +36,10 @@ struct CircularProgressView: View { .font(.b1) .foregroundStyle(Color.coral50) - Text("\(current) / \(total)") + Text("\(studiedCount) / \(totalCount)") .font(.b3) .foregroundStyle(Color.black50) } } } } - -#Preview { - CircularProgressView(current: 1000, total: 3000) - .frame(width: 210, height: 210) -} diff --git a/JLPTVoca/JLPTVoca/Views/MainTabView.swift b/JLPTVoca/JLPTVoca/Views/MainTabView.swift new file mode 100644 index 0000000..c1a88cb --- /dev/null +++ b/JLPTVoca/JLPTVoca/Views/MainTabView.swift @@ -0,0 +1,29 @@ +// +// MainTabView.swift +// JLPTVoca +// +// Created by Rama on 9/21/25. +// + +import SwiftUI + +struct MainTabView: View { + var body: some View { + TabView { + HomeView() + .tabItem { + Label("학습", systemImage: "house.fill") + } + + SelectDictionaryView() + .tabItem { + Label("사전", systemImage: "book.fill") + } + + SettingView() + .tabItem { + Label("설정", systemImage: "gearshape.fill") + } + } + } +} diff --git a/JLPTVoca/JLPTVoca/Views/Onboarding/OnboardingView.swift b/JLPTVoca/JLPTVoca/Views/Onboarding/OnboardingView.swift new file mode 100644 index 0000000..5405ca3 --- /dev/null +++ b/JLPTVoca/JLPTVoca/Views/Onboarding/OnboardingView.swift @@ -0,0 +1,116 @@ +// +// OnboardingView.swift +// JLPTVoca +// +// Created by Rama on 9/21/25. +// + +import SwiftUI + +import SwiftUI + +struct OnboardingView: View { + @Binding var hasCompletedOnboarding: Bool + + @AppStorage("targetLevel") private var targetLevel: Int = 5 + @AppStorage("dailyGoal") private var dailyGoal: Int = 20 + @AppStorage("targetDate") private var targetDate: Date = Date() + + var body: some View { + VStack(alignment: .leading, spacing: 40) { + Text("목표를 설정하고\n학습을 시작해 보세요!") + .font(.largeTitle.bold()) + .padding(.top, 50) + + levelSelector() + + goalStepper() + + datePicker() + + Spacer() + + Button(action: { + hasCompletedOnboarding = true + }) { + Text("학습 시작하기") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + } + .padding(30) + } + + private func levelSelector() -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("목표 급수") + .font(.headline) + HStack(spacing: 10) { + ForEach(1...5, id: \.self) { level in + Button(action: { + targetLevel = level + }) { + Text("N\(level)") + .font(.subheadline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(targetLevel == level ? Color.accentColor : + Color.black.opacity(0.05)) + .foregroundColor(targetLevel == level ? .white : + .primary) + .cornerRadius(8) + .animation(.spring, value: targetLevel) + } + } + } + } + } + + private func goalStepper() -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("하루 목표 암기 개수") + .font(.headline) + HStack { + Text("\(dailyGoal)개") + .font(.title2.bold()) + Spacer() + Button(action: { + if dailyGoal > 5 { dailyGoal -= 5 } + }) { + Image(systemName: "minus.circle.fill") + .font(.title) + .foregroundStyle(Color.accentColor.opacity(0.8)) + } + Button(action: { + dailyGoal += 5 + }) { + Image(systemName: "plus.circle.fill") + .font(.title) + .foregroundStyle(Color.accentColor) + } + } + .padding() + .background(Color.black.opacity(0.05)) + .cornerRadius(10) + } + } + + private func datePicker() -> some View { + DatePicker( + "목표 시험일", + selection: $targetDate, + in: Date()..., // 과거 날짜는 선택할 수 없도록 설정 + displayedComponents: .date + ) + .font(.headline) + } +} + +#Preview { + OnboardingView(hasCompletedOnboarding: .constant(false)) +} + diff --git a/JLPTVoca/JLPTVoca/Views/OnboardingView.swift b/JLPTVoca/JLPTVoca/Views/OnboardingView.swift deleted file mode 100644 index ac84f48..0000000 --- a/JLPTVoca/JLPTVoca/Views/OnboardingView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// OnboardingView.swift -// JLPTVoca -// -// Created by Rama on 8/27/25. -// - -import SwiftUI - -struct OnboardingView: View { - var body: some View { - Text("OnboardingView") - } -} - -#Preview { - OnboardingView() -} diff --git a/JLPTVoca/JLPTVoca/Views/SettingView.swift b/JLPTVoca/JLPTVoca/Views/SettingView.swift index b01f023..8a87dab 100644 --- a/JLPTVoca/JLPTVoca/Views/SettingView.swift +++ b/JLPTVoca/JLPTVoca/Views/SettingView.swift @@ -14,8 +14,3 @@ struct SettingView: View { Text("Setting View") } } - -#Preview { - SettingView() - .environment(WordManager()) -} diff --git a/JLPTVoca/JLPTVoca/Views/Study/StudyPopupType.swift b/JLPTVoca/JLPTVoca/Views/Study/StudyPopupType.swift index e0504dd..75a3e20 100644 --- a/JLPTVoca/JLPTVoca/Views/Study/StudyPopupType.swift +++ b/JLPTVoca/JLPTVoca/Views/Study/StudyPopupType.swift @@ -10,8 +10,8 @@ import SwiftUI enum PopupType: Equatable { case exitConfirmation case studyCompletion( - studiedCount: Int, - uncertainCount: Int + studiedCount: Int = 0, + uncertainCount: Int = 0 ) var title: String { @@ -40,22 +40,4 @@ enum PopupType: Equatable { return "외운 단어: \(studiedCount)\n학습할 단어: \(uncertainCount)" } } - - var primaryButtonTitle: String { - switch self { - case .exitConfirmation: - return "이어서 하기" - case .studyCompletion(_, _): - return "홈으로 돌아가기" - } - } - - var primaryButtonRole: ButtonRole? { - switch self { - case .exitConfirmation: - return .destructive - case .studyCompletion(_, _): - return .none - } - } } diff --git a/JLPTVoca/JLPTVoca/Views/Study/StudyPopupView.swift b/JLPTVoca/JLPTVoca/Views/Study/StudyPopupView.swift index 97a7174..d5d1310 100644 --- a/JLPTVoca/JLPTVoca/Views/Study/StudyPopupView.swift +++ b/JLPTVoca/JLPTVoca/Views/Study/StudyPopupView.swift @@ -39,9 +39,10 @@ struct StudyPopupView: View { .padding(.bottom, 32) HStack(spacing: 25) { - if let cancelAction = cancelAction { + if popupType == .exitConfirmation { + Button(action: { - cancelAction() + completeAction() }) { Text("나가기") .padding(.horizontal, 20) @@ -51,24 +52,38 @@ struct StudyPopupView: View { } .overlay( RoundedRectangle(cornerRadius: 15) - .stroke( - Color.black50, - lineWidth: 1 - ) + .stroke( + Color.black50, + lineWidth: 1 + ) ) + + + Button(action: { + cancelAction?() + }) { + Text("이어서 하기") + .padding(.horizontal, 20) + .padding(.vertical, 16) + .font(.b2) + .foregroundStyle(Color.white0) + } + .background(Color.coral50) + .clipShape(RoundedRectangle(cornerRadius: 15)) + + } else if case .studyCompletion = popupType { + Button(action: { + completeAction() + }) { + Text("완료하기") + .padding(.horizontal, 20) + .padding(.vertical, 16) + .font(.b2) + .foregroundStyle(Color.white0) + } + .background(Color.coral50) + .clipShape(RoundedRectangle(cornerRadius: 15)) } - - Button(action: { - completeAction() - }) { - Text(popupType.primaryButtonTitle) - .padding(.horizontal, 20) - .padding(.vertical, 16) - .font(.b2) - .foregroundStyle(Color.white0) - } - .background(Color.coral50) - .clipShape(RoundedRectangle(cornerRadius: 15)) } } .padding(30) diff --git a/JLPTVoca/JLPTVoca/Views/Study/WordDeckView.swift b/JLPTVoca/JLPTVoca/Views/Study/WordDeckView.swift index e5513f4..4ee8098 100644 --- a/JLPTVoca/JLPTVoca/Views/Study/WordDeckView.swift +++ b/JLPTVoca/JLPTVoca/Views/Study/WordDeckView.swift @@ -1,11 +1,15 @@ import SwiftUI -struct WordDeckView: View { - @Environment(WordManager.self) private var wordManager +import SwiftUI +struct WordDeckView: View { + @Binding var deck: [StudyState] + + let onSwipe: (StudyState, CardSwipeDirection) -> Void + var body: some View { ZStack { - ForEach(wordManager.studyStateDeck) { state in + ForEach(deck) { state in WordCardView( id: state.word.id, japanese: state.word.plainJapanese, @@ -13,16 +17,11 @@ struct WordDeckView: View { korean: state.word.korean, koreanExample: state.word.koreanExample, japaneseExample: state.word.japaneseExample - ) { id, direction in - wordManager.onCardSwipe(id: id, direction: direction) + ) { _, direction in + onSwipe(state, direction) } .frame(width: 345, height: 553) } } } } - -#Preview { - WordDeckView() - .environment(WordManager()) -} diff --git a/JLPTVoca/JLPTVoca/Views/Study/WordStudyView.swift b/JLPTVoca/JLPTVoca/Views/Study/WordStudyView.swift index 6c6222b..3e7105c 100644 --- a/JLPTVoca/JLPTVoca/Views/Study/WordStudyView.swift +++ b/JLPTVoca/JLPTVoca/Views/Study/WordStudyView.swift @@ -9,25 +9,42 @@ import SwiftData import SwiftUI struct WordStudyView: View { + @AppStorage("targetLevel") private var selectedLevel: Int = 5 + @AppStorage("dailyGoal") private var dailyGoal: Int = 5 + @Environment(WordManager.self) private var wordManager @Environment(NavigationManager.self) private var router + @Environment(\.modelContext) private var modelContext + + // reviewDate가 있는 모든 StudyState 가져옴 + @Query var reviewableStates: [StudyState] + // 학습한 적 없는 모든 Word를 가져옴 + @Query(filter: #Predicate { $0.state == nil }) + private var newWords: [Word] + + @State private var sessionDeck: [StudyState] = [] + @State private var totalCardCount: Int = 0 @State private var isPopupPresented: PopupType? + @State private var sessionContext: ModelContext? var body: some View { ZStack { Color.coral50.ignoresSafeArea() - VStack(spacing: 28) { - WordProgressBar( - current: 12, - total: 40 - ) - .padding(.horizontal, 24) - - WordDeckView() - - FilterButtons() + if let sessionContext { + VStack(spacing: 28) { + WordProgressBar( + current: totalCardCount - sessionDeck.count, + total: totalCardCount + ) + .padding(.horizontal, 24) + + WordDeckView(deck: $sessionDeck, onSwipe: handleSwipe) + .environment(\.modelContext, sessionContext) + + FilterButtons() + } } if let popupType = isPopupPresented { @@ -42,8 +59,28 @@ struct WordStudyView: View { ) } } - .navigationBarBackButtonHidden(true) + .onAppear { + if sessionContext == nil { + self.sessionContext = ModelContext(modelContext.container) + } + setSession() + } + .onChange(of: sessionDeck) {_, newDeck in + if newDeck.isEmpty && totalCardCount > 0 { + do { + try sessionContext?.save() + print("@Log - Session completed and saved") + } catch { + print("@Log - Failed to save session: \(error)") + } + + isPopupPresented = .studyCompletion( + studiedCount: totalCardCount, + uncertainCount: 10 + ) + } + } .toolbar(.hidden, for: .tabBar) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -54,15 +91,46 @@ struct WordStudyView: View { } } } - .onChange(of: wordManager.studyStateDeck) { - _, - newDeck in - if newDeck.isEmpty { - isPopupPresented = .studyCompletion( - studiedCount: 30, - uncertainCount: 10 - ) - } + } + + private func setSession() { + guard sessionDeck.isEmpty, + let context = sessionContext else { return } + + let now = Date() + + // 복습 단어 필터링 + let reviewStatesForSession = reviewableStates.filter { + $0.word.jlptLevel == selectedLevel && + $0.reviewDate != nil && + $0.reviewDate! <= now } + + // 새로운 단어 필터링 + let newWordsForSession = newWords + .filter { $0.jlptLevel == selectedLevel } + .prefix(dailyGoal) + + // 세션 시작 시, sessionContext에만 StudyState를 미리 생성 + let newStatesForSession = newWordsForSession.map { word -> + StudyState in + let newState = StudyState(word: word) + context.insert(newState) + return newState + } + + let combinedDeck = newStatesForSession + reviewStatesForSession + self.sessionDeck = combinedDeck.shuffled() + self.totalCardCount = self.sessionDeck.count + } + + private func handleSwipe(state: StudyState, direction: CardSwipeDirection) { + wordManager.updateStudyState( + for: state, + direction: direction, + shouldSave: false + ) + sessionDeck.removeAll { $0.id == state.id } } } + diff --git a/JLPTVoca/JLPTVoca/words.json b/JLPTVoca/JLPTVoca/words.json index 36896e7..aea98b3 100644 --- a/JLPTVoca/JLPTVoca/words.json +++ b/JLPTVoca/JLPTVoca/words.json @@ -1,312 +1,1762 @@ [ - { - "japanese": [{ - "text":"食","furigana":"た" - },{ - "text":"べます","furigana":null - }], - "korean": "먹습니다", - "jlptLevel": 2, - "koreanExample": "저는 아침을 먹습니다.", - "japaneseExample": "私は朝ご飯を食べます。" - }, - { - "japanese": [{ - "text":"父親","furigana":"ちちおや" - }], - "korean": "아버지", - "jlptLevel": 2, - "koreanExample": "우리 아버지는 회사원입니다.", - "japaneseExample": "私の父親は会社員です。" - }, - { - "japanese": [{ - "text":"図","furigana":"と" - },{ - "text":"書","furigana":"しょ" - },{ - "text":"館","furigana":"かん" - }], - "korean": "도서관", - "jlptLevel": 2, - "koreanExample": "주말에 도서관에 갈 거예요.", - "japaneseExample": "週末に図書館へ行きます。" - }, - { - "japanese": [{ - "text":"テレビ","furigana":null - }], - "korean": "텔레비전", - "jlptLevel": 2, - "koreanExample": "저는 매일 밤 텔레비전을 봅니다.", - "japaneseExample": "私は毎晩テレビを見ます。" - }, - { - "japanese": [{ - "text":"私","furigana":"わたし" - },{ - "text":"は","furigana":null - },{ - "text":"韓国人","furigana":"かんこくじん" - },{ - "text":"です","furigana":null - }], - "korean": "저는 한국인입니다", - "jlptLevel": 2, - "koreanExample": "네, 저는 한국인입니다.", - "japaneseExample": "はい、私は韓国人です。" - }, - { - "japanese": [{ - "text":"会社員","furigana":"かいしゃいん" - }], - "korean": "회사원", - "jlptLevel": 2, - "koreanExample": "그는 평범한 회사원입니다.", - "japaneseExample": "彼は普通の会社員です。" - }, - { - "japanese": [{ - "text":"働","furigana":"はたら" - },{ - "text":"きます","furigana":null - }], - "korean": "일합니다", - "jlptLevel": 2, - "koreanExample": "저는 은행에서 일합니다.", - "japaneseExample": "私は銀行で働きます。" - }, - { - "japanese": [{ - "text":"美","furigana":"うつく" - },{ - "text":"しい","furigana":null - }], - "korean": "아름답다", - "jlptLevel": 2, - "koreanExample": "이 꽃은 정말 아름답다.", - "japaneseExample": "この花は本当に美しい。" - }, - { - "japanese": [{ - "text":"日本語","furigana":"にほんご" - },{ - "text":"能力","furigana":"のうりょく" - },{ - "text":"試験","furigana":"しけん" - }], - "korean": "일본어 능력 시험", - "jlptLevel": 2, - "koreanExample": "저는 일본어 능력 시험을 준비하고 있습니다.", - "japaneseExample": "私は日本語能力試験を準備しています。" - }, - { - "japanese": [{ - "text":"空港","furigana":"くうこう" - }], - "korean": "공항", - "jlptLevel": 2, - "koreanExample": "우리는 공항으로 서둘러 갔다.", - "japaneseExample": "私たちは空港へ急いだ。" - }, - { - "japanese": [{ - "text":"ドキドキ","furigana":null - }], - "korean": "두근두근", - "jlptLevel": 2, - "koreanExample": "발표를 앞두고 가슴이 두근두근거렸다.", - "japaneseExample": "発表を前に胸がドキドキした。" - }, - { - "japanese": [{ - "text":"会議","furigana":"かいぎ" - },{ - "text":"室","furigana":"しつ" - }], - "korean": "회의실", - "jlptLevel": 2, - "koreanExample": "회의실은 3층에 있습니다.", - "japaneseExample": "会議室は3階にあります。" - }, - { - "japanese": [{ - "text":"待","furigana":"ま" - },{ - "text":"ち合わせる","furigana":null - }], - "korean": "만나기로 약속하고 기다리다", - "jlptLevel": 2, - "koreanExample": "우리는 역 앞에서 만나기로 약속했다.", - "japaneseExample": "私たちは駅前で待ち合わせることにした。" - }, - { - "japanese": [{ - "text":"召","furigana":"め" - },{ - "text":"し上","furigana":"あ" - },{ - "text":"がる","furigana":null - }], - "korean": "드시다 (높임말)", - "jlptLevel": 2, - "koreanExample": "사장님, 점심 드시겠어요?", - "japaneseExample": "社長、昼食を召し上がりますか。" - }, - { - "japanese": [{ - "text":"申","furigana":"もう" - },{ - "text":"し訳","furigana":"わけ" - },{ - "text":"ありません","furigana":null - }], - "korean": "죄송합니다", - "jlptLevel": 2, - "koreanExample": "늦어서 정말 죄송합니다.", - "japaneseExample": "遅れて誠に申し訳ありません。" - }, - { - "japanese": [{ - "text":"天気予報","furigana":"てんきよほう" - }], - "korean": "일기예보", - "jlptLevel": 2, - "koreanExample": "일기예보에 따르면 내일은 비가 올 것입니다.", - "japaneseExample": "天気予報によると明日は雨が降るでしょう。" - }, - { - "japanese": [{ - "text":"寿司","furigana":"すし" - }], - "korean": "초밥", - "jlptLevel": 2, - "koreanExample": "저는 초밥을 아주 좋아합니다.", - "japaneseExample": "私は寿司が大好きです。" - }, - { - "japanese": [{ - "text":"嬉","furigana":"うれ" - },{ - "text":"しい","furigana":null - }], - "korean": "기쁘다", - "jlptLevel": 2, - "koreanExample": "선물을 받아서 정말 기쁘다.", - "japaneseExample": "プレゼントをもらって本当に嬉しい。" - }, - { - "japanese": [{ - "text":"新幹線","furigana":"しんかんせん" - }], - "korean": "신칸센", - "jlptLevel": 2, - "koreanExample": "저는 신칸센을 처음 타봤습니다.", - "japaneseExample": "私は新幹線に初めて乗りました。" - }, - { - "japanese": [{ - "text":"一昨日","furigana":"おととい" - }], - "korean": "그저께", - "jlptLevel": 2, - "koreanExample": "그저께 친구를 만났어요.", - "japaneseExample": "一昨日、友達に会いました。" - }, - { - "japanese": [{ - "text":"左","furigana":"ひだり" - },{ - "text":"右","furigana":"みぎ" - }], - "korean": "좌우", - "jlptLevel": 2, - "koreanExample": "길을 건널 때는 좌우를 잘 살피세요.", - "japaneseExample": "道を渡る時は左右をよく見てください。" - }, - { - "japanese": [{ - "text":"薔薇","furigana":"ばら" - }], - "korean": "장미", - "jlptLevel": 2, - "koreanExample": "그녀는 빨간 장미를 좋아한다.", - "japaneseExample": "彼女は赤い薔薇が好きだ。" - }, - { - "japanese": [{ - "text":"お疲","furigana":"つか" - },{ - "text":"れ様","furigana":"さま" - },{ - "text":"でした","furigana":null - }], - "korean": "수고하셨습니다", - "jlptLevel": 2, - "koreanExample": "오늘 하루도 수고하셨습니다.", - "japaneseExample": "今日一日もお疲れ様でした。" - }, - { - "japanese": [{ - "text":"三百","furigana":"さんびゃく" - }], - "korean": "삼백", - "jlptLevel": 2, - "koreanExample": "이 책은 삼백 페이지입니다.", - "japaneseExample": "この本は三百ページです。" - }, - { - "japanese": [{ - "text":"祖父母","furigana":"そふぼ" - }], - "korean": "조부모", - "jlptLevel": 2, - "koreanExample": "저는 조부모님과 함께 살고 있습니다.", - "japaneseExample": "私は祖父母と一緒に住んでいます。" - }, - { - "japanese": [{ - "text":"教科書","furigana":"きょうかしょ" - }], - "korean": "교과서", - "jlptLevel": 2, - "koreanExample": "교과서를 펴주세요.", - "japaneseExample": "教科書を開いてください。" - }, - { - "japanese": [{ - "text":"御社","furigana":"おんしゃ" - }], - "korean": "귀사", - "jlptLevel": 2, - "koreanExample": "귀사의 발전을 기원합니다.", - "japaneseExample": "御社の発展をお祈り申し上げます。" - }, - { - "japanese": [{ - "text":"地震","furigana":"じしん" - }], - "korean": "지진", - "jlptLevel": 2, - "koreanExample": "어젯밤에 지진이 있었습니다.", - "japaneseExample": "昨夜、地震がありました。" - }, - { - "japanese": [{ - "text":"病院","furigana":"びょういん" - }], - "korean": "병원", - "jlptLevel": 2, - "koreanExample": "감기에 걸려서 병원에 갔습니다.", - "japaneseExample": "風邪をひいて病院へ行きました。" - }, - { - "japanese": [{ - "text":"自動詞","furigana":"じどうし" - }], - "korean": "자동사", - "jlptLevel": 2, - "koreanExample": "'가다'는 대표적인 자동사입니다.", - "japaneseExample": "「行く」は代表的な自動詞です。" - } + { + "japanese": [ + { + "text": "食", + "furigana": "た" + }, + { + "text": "べます", + "furigana": null + } + ], + "korean": "먹습니다", + "jlptLevel": 2, + "koreanExample": "저는 아침을 먹습니다.", + "japaneseExample": "私は朝ご飯を食べます。" + }, + { + "japanese": [ + { + "text": "父親", + "furigana": "ちちおや" + } + ], + "korean": "아버지", + "jlptLevel": 5, + "koreanExample": "우리 아버지는 회사원입니다.", + "japaneseExample": "私の父親は会社員です。" + }, + { + "japanese": [ + { + "text": "図", + "furigana": "と" + }, + { + "text": "書", + "furigana": "しょ" + }, + { + "text": "館", + "furigana": "かん" + } + ], + "korean": "도서관", + "jlptLevel": 3, + "koreanExample": "주말에 도서관에 갈 거예요.", + "japaneseExample": "週末に図書館へ行きます。" + }, + { + "japanese": [ + { + "text": "テレビ", + "furigana": null + } + ], + "korean": "텔레비전", + "jlptLevel": 3, + "koreanExample": "저는 매일 밤 텔레비전을 봅니다.", + "japaneseExample": "私は毎晩テレビを見ます。" + }, + { + "japanese": [ + { + "text": "私", + "furigana": "わたし" + }, + { + "text": "は", + "furigana": null + }, + { + "text": "韓国人", + "furigana": "かんこくじん" + }, + { + "text": "です", + "furigana": null + } + ], + "korean": "저는 한국인입니다", + "jlptLevel": 2, + "koreanExample": "네, 저는 한국인입니다.", + "japaneseExample": "はい、私は韓国人です。" + }, + { + "japanese": [ + { + "text": "会社員", + "furigana": "かいしゃいん" + } + ], + "korean": "회사원", + "jlptLevel": 4, + "koreanExample": "그는 평범한 회사원입니다.", + "japaneseExample": "彼は普通の会社員です。" + }, + { + "japanese": [ + { + "text": "働", + "furigana": "はたら" + }, + { + "text": "きます", + "furigana": null + } + ], + "korean": "일합니다", + "jlptLevel": 5, + "koreanExample": "저는 은행에서 일합니다.", + "japaneseExample": "私は銀行で働きます。" + }, + { + "japanese": [ + { + "text": "美", + "furigana": "うつく" + }, + { + "text": "しい", + "furigana": null + } + ], + "korean": "아름답다", + "jlptLevel": 5, + "koreanExample": "이 꽃은 정말 아름답다.", + "japaneseExample": "この花は本当に美しい。" + }, + { + "japanese": [ + { + "text": "日本語", + "furigana": "にほんご" + }, + { + "text": "能力", + "furigana": "のうりょく" + }, + { + "text": "試験", + "furigana": "しけん" + } + ], + "korean": "일본어 능력 시험", + "jlptLevel": 4, + "koreanExample": "저는 일본어 능력 시험을 준비하고 있습니다.", + "japaneseExample": "私は日本語能力試験を準備しています。" + }, + { + "japanese": [ + { + "text": "空港", + "furigana": "くうこう" + } + ], + "korean": "공항", + "jlptLevel": 2, + "koreanExample": "우리는 공항으로 서둘러 갔다.", + "japaneseExample": "私たちは空港へ急いだ。" + }, + { + "japanese": [ + { + "text": "ドキドキ", + "furigana": null + } + ], + "korean": "두근두근", + "jlptLevel": 2, + "koreanExample": "발표를 앞두고 가슴이 두근두근거렸다.", + "japaneseExample": "発表を前に胸がドキドキした。" + }, + { + "japanese": [ + { + "text": "会議", + "furigana": "かいぎ" + }, + { + "text": "室", + "furigana": "しつ" + } + ], + "korean": "회의실", + "jlptLevel": 3, + "koreanExample": "회의실은 3층에 있습니다.", + "japaneseExample": "会議室は3階にあります。" + }, + { + "japanese": [ + { + "text": "待", + "furigana": "ま" + }, + { + "text": "ち合わせる", + "furigana": null + } + ], + "korean": "만나기로 약속하고 기다리다", + "jlptLevel": 2, + "koreanExample": "우리는 역 앞에서 만나기로 약속했다.", + "japaneseExample": "私たちは駅前で待ち合わせることにした。" + }, + { + "japanese": [ + { + "text": "召", + "furigana": "め" + }, + { + "text": "し上", + "furigana": "あ" + }, + { + "text": "がる", + "furigana": null + } + ], + "korean": "드시다 (높임말)", + "jlptLevel": 5, + "koreanExample": "사장님, 점심 드시겠어요?", + "japaneseExample": "社長、昼食を召し上がりますか。" + }, + { + "japanese": [ + { + "text": "申", + "furigana": "もう" + }, + { + "text": "し訳", + "furigana": "わけ" + }, + { + "text": "ありません", + "furigana": null + } + ], + "korean": "죄송합니다", + "jlptLevel": 4, + "koreanExample": "늦어서 정말 죄송합니다.", + "japaneseExample": "遅れて誠に申し訳ありません。" + }, + { + "japanese": [ + { + "text": "天気予報", + "furigana": "てんきよほう" + } + ], + "korean": "일기예보", + "jlptLevel": 2, + "koreanExample": "일기예보에 따르면 내일은 비가 올 것입니다.", + "japaneseExample": "天気予報によると明日は雨が降るでしょう。" + }, + { + "japanese": [ + { + "text": "寿司", + "furigana": "すし" + } + ], + "korean": "초밥", + "jlptLevel": 1, + "koreanExample": "저는 초밥을 아주 좋아합니다.", + "japaneseExample": "私は寿司が大好きです。" + }, + { + "japanese": [ + { + "text": "嬉", + "furigana": "うれ" + }, + { + "text": "しい", + "furigana": null + } + ], + "korean": "기쁘다", + "jlptLevel": 2, + "koreanExample": "선물을 받아서 정말 기쁘다.", + "japaneseExample": "プレゼントをもらって本当に嬉しい。" + }, + { + "japanese": [ + { + "text": "新幹線", + "furigana": "しんかんせん" + } + ], + "korean": "신칸센", + "jlptLevel": 1, + "koreanExample": "저는 신칸센을 처음 타봤습니다.", + "japaneseExample": "私は新幹線に初めて乗りました。" + }, + { + "japanese": [ + { + "text": "一昨日", + "furigana": "おととい" + } + ], + "korean": "그저께", + "jlptLevel": 4, + "koreanExample": "그저께 친구를 만났어요.", + "japaneseExample": "一昨日、友達に会いました。" + }, + { + "japanese": [ + { + "text": "左", + "furigana": "ひだり" + }, + { + "text": "右", + "furigana": "みぎ" + } + ], + "korean": "좌우", + "jlptLevel": 5, + "koreanExample": "길을 건널 때는 좌우를 잘 살피세요.", + "japaneseExample": "道を渡る時は左右をよく見てください。" + }, + { + "japanese": [ + { + "text": "薔薇", + "furigana": "ばら" + } + ], + "korean": "장미", + "jlptLevel": 4, + "koreanExample": "그녀는 빨간 장미를 좋아한다.", + "japaneseExample": "彼女は赤い薔薇が好きだ。" + }, + { + "japanese": [ + { + "text": "お疲", + "furigana": "つか" + }, + { + "text": "れ様", + "furigana": "さま" + }, + { + "text": "でした", + "furigana": null + } + ], + "korean": "수고하셨습니다", + "jlptLevel": 1, + "koreanExample": "오늘 하루도 수고하셨습니다.", + "japaneseExample": "今日一日もお疲れ様でした。" + }, + { + "japanese": [ + { + "text": "三百", + "furigana": "さんびゃく" + } + ], + "korean": "삼백", + "jlptLevel": 4, + "koreanExample": "이 책은 삼백 페이지입니다.", + "japaneseExample": "この本は三百ページです。" + }, + { + "japanese": [ + { + "text": "祖父母", + "furigana": "そふぼ" + } + ], + "korean": "조부모", + "jlptLevel": 4, + "koreanExample": "저는 조부모님과 함께 살고 있습니다.", + "japaneseExample": "私は祖父母と一緒に住んでいます。" + }, + { + "japanese": [ + { + "text": "教科書", + "furigana": "きょうかしょ" + } + ], + "korean": "교과서", + "jlptLevel": 5, + "koreanExample": "교과서를 펴주세요.", + "japaneseExample": "教科書を開いてください。" + }, + { + "japanese": [ + { + "text": "御社", + "furigana": "おんしゃ" + } + ], + "korean": "귀사", + "jlptLevel": 3, + "koreanExample": "귀사의 발전을 기원합니다.", + "japaneseExample": "御社の発展をお祈り申し上げます。" + }, + { + "japanese": [ + { + "text": "地震", + "furigana": "じしん" + } + ], + "korean": "지진", + "jlptLevel": 5, + "koreanExample": "어젯밤에 지진이 있었습니다.", + "japaneseExample": "昨夜、地震がありました。" + }, + { + "japanese": [ + { + "text": "病院", + "furigana": "びょういん" + } + ], + "korean": "병원", + "jlptLevel": 5, + "koreanExample": "감기에 걸려서 병원에 갔습니다.", + "japaneseExample": "風邪をひいて病院へ行きました。" + }, + { + "japanese": [ + { + "text": "自動詞", + "furigana": "じどうし" + } + ], + "korean": "자동사", + "jlptLevel": 5, + "koreanExample": "'가다'는 대표적인 자동사입니다.", + "japaneseExample": "「行く」は代表的な自動詞です。" + }, + { + "japanese": [ + { + "text": "食", + "furigana": "た" + }, + { + "text": "べます", + "furigana": null + } + ], + "korean": "먹습니다", + "jlptLevel": 2, + "koreanExample": "저는 아침을 먹습니다.", + "japaneseExample": "私は朝ご飯を食べます。" + }, + { + "japanese": [ + { + "text": "父親", + "furigana": "ちちおや" + } + ], + "korean": "아버지", + "jlptLevel": 1, + "koreanExample": "우리 아버지는 회사원입니다.", + "japaneseExample": "私の父親は会社員です。" + }, + { + "japanese": [ + { + "text": "図", + "furigana": "と" + }, + { + "text": "書", + "furigana": "しょ" + }, + { + "text": "館", + "furigana": "かん" + } + ], + "korean": "도서관", + "jlptLevel": 1, + "koreanExample": "주말에 도서관에 갈 거예요.", + "japaneseExample": "週末に図書館へ行きます。" + }, + { + "japanese": [ + { + "text": "テレビ", + "furigana": null + } + ], + "korean": "텔레비전", + "jlptLevel": 4, + "koreanExample": "저는 매일 밤 텔레비전을 봅니다.", + "japaneseExample": "私は毎晩テレビを見ます。" + }, + { + "japanese": [ + { + "text": "私", + "furigana": "わたし" + }, + { + "text": "は", + "furigana": null + }, + { + "text": "韓国人", + "furigana": "かんこくじん" + }, + { + "text": "です", + "furigana": null + } + ], + "korean": "저는 한국인입니다", + "jlptLevel": 3, + "koreanExample": "네, 저는 한국인입니다.", + "japaneseExample": "はい、私は韓国人です。" + }, + { + "japanese": [ + { + "text": "会社員", + "furigana": "かいしゃいん" + } + ], + "korean": "회사원", + "jlptLevel": 2, + "koreanExample": "그는 평범한 회사원입니다.", + "japaneseExample": "彼は普通の会社員です。" + }, + { + "japanese": [ + { + "text": "働", + "furigana": "はたら" + }, + { + "text": "きます", + "furigana": null + } + ], + "korean": "일합니다", + "jlptLevel": 5, + "koreanExample": "저는 은행에서 일합니다.", + "japaneseExample": "私は銀行で働きます。" + }, + { + "japanese": [ + { + "text": "美", + "furigana": "うつく" + }, + { + "text": "しい", + "furigana": null + } + ], + "korean": "아름답다", + "jlptLevel": 3, + "koreanExample": "이 꽃은 정말 아름답다.", + "japaneseExample": "この花は本当に美しい。" + }, + { + "japanese": [ + { + "text": "日本語", + "furigana": "にほんご" + }, + { + "text": "能力", + "furigana": "のうりょく" + }, + { + "text": "試験", + "furigana": "しけん" + } + ], + "korean": "일본어 능력 시험", + "jlptLevel": 5, + "koreanExample": "저는 일본어 능력 시험을 준비하고 있습니다.", + "japaneseExample": "私は日本語能力試験を準備しています。" + }, + { + "japanese": [ + { + "text": "空港", + "furigana": "くうこう" + } + ], + "korean": "공항", + "jlptLevel": 3, + "koreanExample": "우리는 공항으로 서둘러 갔다.", + "japaneseExample": "私たちは空港へ急いだ。" + }, + { + "japanese": [ + { + "text": "ドキドキ", + "furigana": null + } + ], + "korean": "두근두근", + "jlptLevel": 1, + "koreanExample": "발표를 앞두고 가슴이 두근두근거렸다.", + "japaneseExample": "発表を前に胸がドキドキした。" + }, + { + "japanese": [ + { + "text": "会議", + "furigana": "かいぎ" + }, + { + "text": "室", + "furigana": "しつ" + } + ], + "korean": "회의실", + "jlptLevel": 3, + "koreanExample": "회의실은 3층에 있습니다.", + "japaneseExample": "会議室は3階にあります。" + }, + { + "japanese": [ + { + "text": "待", + "furigana": "ま" + }, + { + "text": "ち合わせる", + "furigana": null + } + ], + "korean": "만나기로 약속하고 기다리다", + "jlptLevel": 3, + "koreanExample": "우리는 역 앞에서 만나기로 약속했다.", + "japaneseExample": "私たちは駅前で待ち合わせることにした。" + }, + { + "japanese": [ + { + "text": "召", + "furigana": "め" + }, + { + "text": "し上", + "furigana": "あ" + }, + { + "text": "がる", + "furigana": null + } + ], + "korean": "드시다 (높임말)", + "jlptLevel": 5, + "koreanExample": "사장님, 점심 드시겠어요?", + "japaneseExample": "社長、昼食を召し上がりますか。" + }, + { + "japanese": [ + { + "text": "申", + "furigana": "もう" + }, + { + "text": "し訳", + "furigana": "わけ" + }, + { + "text": "ありません", + "furigana": null + } + ], + "korean": "죄송합니다", + "jlptLevel": 3, + "koreanExample": "늦어서 정말 죄송합니다.", + "japaneseExample": "遅れて誠に申し訳ありません。" + }, + { + "japanese": [ + { + "text": "天気予報", + "furigana": "てんきよほう" + } + ], + "korean": "일기예보", + "jlptLevel": 4, + "koreanExample": "일기예보에 따르면 내일은 비가 올 것입니다.", + "japaneseExample": "天気予報によると明日は雨が降るでしょう。" + }, + { + "japanese": [ + { + "text": "寿司", + "furigana": "すし" + } + ], + "korean": "초밥", + "jlptLevel": 3, + "koreanExample": "저는 초밥을 아주 좋아합니다.", + "japaneseExample": "私は寿司が大好きです。" + }, + { + "japanese": [ + { + "text": "嬉", + "furigana": "うれ" + }, + { + "text": "しい", + "furigana": null + } + ], + "korean": "기쁘다", + "jlptLevel": 5, + "koreanExample": "선물을 받아서 정말 기쁘다.", + "japaneseExample": "プレゼントをもらって本当に嬉しい。" + }, + { + "japanese": [ + { + "text": "新幹線", + "furigana": "しんかんせん" + } + ], + "korean": "신칸센", + "jlptLevel": 5, + "koreanExample": "저는 신칸센을 처음 타봤습니다.", + "japaneseExample": "私は新幹線に初めて乗りました。" + }, + { + "japanese": [ + { + "text": "一昨日", + "furigana": "おととい" + } + ], + "korean": "그저께", + "jlptLevel": 3, + "koreanExample": "그저께 친구를 만났어요.", + "japaneseExample": "一昨日、友達に会いました。" + }, + { + "japanese": [ + { + "text": "左", + "furigana": "ひだり" + }, + { + "text": "右", + "furigana": "みぎ" + } + ], + "korean": "좌우", + "jlptLevel": 5, + "koreanExample": "길을 건널 때는 좌우를 잘 살피세요.", + "japaneseExample": "道を渡る時は左右をよく見てください。" + }, + { + "japanese": [ + { + "text": "薔薇", + "furigana": "ばら" + } + ], + "korean": "장미", + "jlptLevel": 2, + "koreanExample": "그녀는 빨간 장미를 좋아한다.", + "japaneseExample": "彼女は赤い薔薇が好きだ。" + }, + { + "japanese": [ + { + "text": "お疲", + "furigana": "つか" + }, + { + "text": "れ様", + "furigana": "さま" + }, + { + "text": "でした", + "furigana": null + } + ], + "korean": "수고하셨습니다", + "jlptLevel": 4, + "koreanExample": "오늘 하루도 수고하셨습니다.", + "japaneseExample": "今日一日もお疲れ様でした。" + }, + { + "japanese": [ + { + "text": "三百", + "furigana": "さんびゃく" + } + ], + "korean": "삼백", + "jlptLevel": 1, + "koreanExample": "이 책은 삼백 페이지입니다.", + "japaneseExample": "この本は三百ページです。" + }, + { + "japanese": [ + { + "text": "祖父母", + "furigana": "そふぼ" + } + ], + "korean": "조부모", + "jlptLevel": 5, + "koreanExample": "저는 조부모님과 함께 살고 있습니다.", + "japaneseExample": "私は祖父母と一緒に住んでいます。" + }, + { + "japanese": [ + { + "text": "教科書", + "furigana": "きょうかしょ" + } + ], + "korean": "교과서", + "jlptLevel": 2, + "koreanExample": "교과서를 펴주세요.", + "japaneseExample": "教科書を開いてください。" + }, + { + "japanese": [ + { + "text": "御社", + "furigana": "おんしゃ" + } + ], + "korean": "귀사", + "jlptLevel": 1, + "koreanExample": "귀사의 발전을 기원합니다.", + "japaneseExample": "御社の発展をお祈り申し上げます。" + }, + { + "japanese": [ + { + "text": "地震", + "furigana": "じしん" + } + ], + "korean": "지진", + "jlptLevel": 1, + "koreanExample": "어젯밤에 지진이 있었습니다.", + "japaneseExample": "昨夜、地震がありました。" + }, + { + "japanese": [ + { + "text": "病院", + "furigana": "びょういん" + } + ], + "korean": "병원", + "jlptLevel": 3, + "koreanExample": "감기에 걸려서 병원에 갔습니다.", + "japaneseExample": "風邪をひいて病院へ行きました。" + }, + { + "japanese": [ + { + "text": "自動詞", + "furigana": "じどうし" + } + ], + "korean": "자동사", + "jlptLevel": 5, + "koreanExample": "'가다'는 대표적인 자동사입니다.", + "japaneseExample": "「行く」は代表的な自動詞です。" + }, + { + "japanese": [ + { + "text": "食", + "furigana": "た" + }, + { + "text": "べます", + "furigana": null + } + ], + "korean": "먹습니다", + "jlptLevel": 3, + "koreanExample": "저는 아침을 먹습니다.", + "japaneseExample": "私は朝ご飯を食べます。" + }, + { + "japanese": [ + { + "text": "父親", + "furigana": "ちちおや" + } + ], + "korean": "아버지", + "jlptLevel": 5, + "koreanExample": "우리 아버지는 회사원입니다.", + "japaneseExample": "私の父親は会社員です。" + }, + { + "japanese": [ + { + "text": "図", + "furigana": "と" + }, + { + "text": "書", + "furigana": "しょ" + }, + { + "text": "館", + "furigana": "かん" + } + ], + "korean": "도서관", + "jlptLevel": 3, + "koreanExample": "주말에 도서관에 갈 거예요.", + "japaneseExample": "週末に図書館へ行きます。" + }, + { + "japanese": [ + { + "text": "テレビ", + "furigana": null + } + ], + "korean": "텔레비전", + "jlptLevel": 1, + "koreanExample": "저는 매일 밤 텔레비전을 봅니다.", + "japaneseExample": "私は毎晩テレビを見ます。" + }, + { + "japanese": [ + { + "text": "私", + "furigana": "わたし" + }, + { + "text": "は", + "furigana": null + }, + { + "text": "韓国人", + "furigana": "かんこくじん" + }, + { + "text": "です", + "furigana": null + } + ], + "korean": "저는 한국인입니다", + "jlptLevel": 4, + "koreanExample": "네, 저는 한국인입니다.", + "japaneseExample": "はい、私は韓国人です。" + }, + { + "japanese": [ + { + "text": "会社員", + "furigana": "かいしゃいん" + } + ], + "korean": "회사원", + "jlptLevel": 3, + "koreanExample": "그는 평범한 회사원입니다.", + "japaneseExample": "彼は普通の会社員です。" + }, + { + "japanese": [ + { + "text": "働", + "furigana": "はたら" + }, + { + "text": "きます", + "furigana": null + } + ], + "korean": "일합니다", + "jlptLevel": 4, + "koreanExample": "저는 은행에서 일합니다.", + "japaneseExample": "私は銀行で働きます。" + }, + { + "japanese": [ + { + "text": "美", + "furigana": "うつく" + }, + { + "text": "しい", + "furigana": null + } + ], + "korean": "아름답다", + "jlptLevel": 5, + "koreanExample": "이 꽃은 정말 아름답다.", + "japaneseExample": "この花は本当に美しい。" + }, + { + "japanese": [ + { + "text": "日本語", + "furigana": "にほんご" + }, + { + "text": "能力", + "furigana": "のうりょく" + }, + { + "text": "試験", + "furigana": "しけん" + } + ], + "korean": "일본어 능력 시험", + "jlptLevel": 5, + "koreanExample": "저는 일본어 능력 시험을 준비하고 있습니다.", + "japaneseExample": "私は日本語能力試験を準備しています。" + }, + { + "japanese": [ + { + "text": "空港", + "furigana": "くうこう" + } + ], + "korean": "공항", + "jlptLevel": 1, + "koreanExample": "우리는 공항으로 서둘러 갔다.", + "japaneseExample": "私たちは空港へ急いだ。" + }, + { + "japanese": [ + { + "text": "ドキドキ", + "furigana": null + } + ], + "korean": "두근두근", + "jlptLevel": 4, + "koreanExample": "발표를 앞두고 가슴이 두근두근거렸다.", + "japaneseExample": "発表を前に胸がドキドキした。" + }, + { + "japanese": [ + { + "text": "会議", + "furigana": "かいぎ" + }, + { + "text": "室", + "furigana": "しつ" + } + ], + "korean": "회의실", + "jlptLevel": 3, + "koreanExample": "회의실은 3층에 있습니다.", + "japaneseExample": "会議室は3階にあります。" + }, + { + "japanese": [ + { + "text": "待", + "furigana": "ま" + }, + { + "text": "ち合わせる", + "furigana": null + } + ], + "korean": "만나기로 약속하고 기다리다", + "jlptLevel": 4, + "koreanExample": "우리는 역 앞에서 만나기로 약속했다.", + "japaneseExample": "私たちは駅前で待ち合わせることにした。" + }, + { + "japanese": [ + { + "text": "召", + "furigana": "め" + }, + { + "text": "し上", + "furigana": "あ" + }, + { + "text": "がる", + "furigana": null + } + ], + "korean": "드시다 (높임말)", + "jlptLevel": 1, + "koreanExample": "사장님, 점심 드시겠어요?", + "japaneseExample": "社長、昼食を召し上がりますか。" + }, + { + "japanese": [ + { + "text": "申", + "furigana": "もう" + }, + { + "text": "し訳", + "furigana": "わけ" + }, + { + "text": "ありません", + "furigana": null + } + ], + "korean": "죄송합니다", + "jlptLevel": 3, + "koreanExample": "늦어서 정말 죄송합니다.", + "japaneseExample": "遅れて誠に申し訳ありません。" + }, + { + "japanese": [ + { + "text": "天気予報", + "furigana": "てんきよほう" + } + ], + "korean": "일기예보", + "jlptLevel": 4, + "koreanExample": "일기예보에 따르면 내일은 비가 올 것입니다.", + "japaneseExample": "天気予報によると明日は雨が降るでしょう。" + }, + { + "japanese": [ + { + "text": "寿司", + "furigana": "すし" + } + ], + "korean": "초밥", + "jlptLevel": 1, + "koreanExample": "저는 초밥을 아주 좋아합니다.", + "japaneseExample": "私は寿司が大好きです。" + }, + { + "japanese": [ + { + "text": "嬉", + "furigana": "うれ" + }, + { + "text": "しい", + "furigana": null + } + ], + "korean": "기쁘다", + "jlptLevel": 3, + "koreanExample": "선물을 받아서 정말 기쁘다.", + "japaneseExample": "プレゼントをもらって本当に嬉しい。" + }, + { + "japanese": [ + { + "text": "新幹線", + "furigana": "しんかんせん" + } + ], + "korean": "신칸센", + "jlptLevel": 3, + "koreanExample": "저는 신칸센을 처음 타봤습니다.", + "japaneseExample": "私は新幹線に初めて乗りました。" + }, + { + "japanese": [ + { + "text": "一昨日", + "furigana": "おととい" + } + ], + "korean": "그저께", + "jlptLevel": 3, + "koreanExample": "그저께 친구를 만났어요.", + "japaneseExample": "一昨日、友達に会いました。" + }, + { + "japanese": [ + { + "text": "左", + "furigana": "ひだり" + }, + { + "text": "右", + "furigana": "みぎ" + } + ], + "korean": "좌우", + "jlptLevel": 4, + "koreanExample": "길을 건널 때는 좌우를 잘 살피세요.", + "japaneseExample": "道を渡る時は左右をよく見てください。" + }, + { + "japanese": [ + { + "text": "薔薇", + "furigana": "ばら" + } + ], + "korean": "장미", + "jlptLevel": 3, + "koreanExample": "그녀는 빨간 장미를 좋아한다.", + "japaneseExample": "彼女は赤い薔薇が好きだ。" + }, + { + "japanese": [ + { + "text": "お疲", + "furigana": "つか" + }, + { + "text": "れ様", + "furigana": "さま" + }, + { + "text": "でした", + "furigana": null + } + ], + "korean": "수고하셨습니다", + "jlptLevel": 1, + "koreanExample": "오늘 하루도 수고하셨습니다.", + "japaneseExample": "今日一日もお疲れ様でした。" + }, + { + "japanese": [ + { + "text": "三百", + "furigana": "さんびゃく" + } + ], + "korean": "삼백", + "jlptLevel": 2, + "koreanExample": "이 책은 삼백 페이지입니다.", + "japaneseExample": "この本は三百ページです。" + }, + { + "japanese": [ + { + "text": "祖父母", + "furigana": "そふぼ" + } + ], + "korean": "조부모", + "jlptLevel": 1, + "koreanExample": "저는 조부모님과 함께 살고 있습니다.", + "japaneseExample": "私は祖父母と一緒に住んでいます。" + }, + { + "japanese": [ + { + "text": "教科書", + "furigana": "きょうかしょ" + } + ], + "korean": "교과서", + "jlptLevel": 2, + "koreanExample": "교과서를 펴주세요.", + "japaneseExample": "教科書を開いてください。" + }, + { + "japanese": [ + { + "text": "御社", + "furigana": "おんしゃ" + } + ], + "korean": "귀사", + "jlptLevel": 3, + "koreanExample": "귀사의 발전을 기원합니다.", + "japaneseExample": "御社の発展をお祈り申し上げます。" + }, + { + "japanese": [ + { + "text": "地震", + "furigana": "じしん" + } + ], + "korean": "지진", + "jlptLevel": 4, + "koreanExample": "어젯밤에 지진이 있었습니다.", + "japaneseExample": "昨夜、地震がありました。" + }, + { + "japanese": [ + { + "text": "病院", + "furigana": "びょういん" + } + ], + "korean": "병원", + "jlptLevel": 5, + "koreanExample": "감기에 걸려서 병원에 갔습니다.", + "japaneseExample": "風邪をひいて病院へ行きました。" + }, + { + "japanese": [ + { + "text": "自動詞", + "furigana": "じどうし" + } + ], + "korean": "자동사", + "jlptLevel": 2, + "koreanExample": "'가다'는 대표적인 자동사입니다.", + "japaneseExample": "「行く」は代表的な自動詞です。" + }, + { + "japanese": [ + { + "text": "食", + "furigana": "た" + }, + { + "text": "べます", + "furigana": null + } + ], + "korean": "먹습니다", + "jlptLevel": 2, + "koreanExample": "저는 아침을 먹습니다.", + "japaneseExample": "私は朝ご飯を食べます。" + }, + { + "japanese": [ + { + "text": "父親", + "furigana": "ちちおや" + } + ], + "korean": "아버지", + "jlptLevel": 1, + "koreanExample": "우리 아버지는 회사원입니다.", + "japaneseExample": "私の父親は会社員です。" + }, + { + "japanese": [ + { + "text": "図", + "furigana": "と" + }, + { + "text": "書", + "furigana": "しょ" + }, + { + "text": "館", + "furigana": "かん" + } + ], + "korean": "도서관", + "jlptLevel": 3, + "koreanExample": "주말에 도서관에 갈 거예요.", + "japaneseExample": "週末に図書館へ行きます。" + }, + { + "japanese": [ + { + "text": "テレビ", + "furigana": null + } + ], + "korean": "텔레비전", + "jlptLevel": 2, + "koreanExample": "저는 매일 밤 텔레비전을 봅니다.", + "japaneseExample": "私は毎晩テレビを見ます。" + }, + { + "japanese": [ + { + "text": "私", + "furigana": "わたし" + }, + { + "text": "は", + "furigana": null + }, + { + "text": "韓国人", + "furigana": "かんこくじん" + }, + { + "text": "です", + "furigana": null + } + ], + "korean": "저는 한국인입니다", + "jlptLevel": 2, + "koreanExample": "네, 저는 한국인입니다.", + "japaneseExample": "はい、私は韓国人です。" + }, + { + "japanese": [ + { + "text": "会社員", + "furigana": "かいしゃいん" + } + ], + "korean": "회사원", + "jlptLevel": 3, + "koreanExample": "그는 평범한 회사원입니다.", + "japaneseExample": "彼は普通の会社員です。" + }, + { + "japanese": [ + { + "text": "働", + "furigana": "はたら" + }, + { + "text": "きます", + "furigana": null + } + ], + "korean": "일합니다", + "jlptLevel": 5, + "koreanExample": "저는 은행에서 일합니다.", + "japaneseExample": "私は銀行で働きます。" + }, + { + "japanese": [ + { + "text": "美", + "furigana": "うつく" + }, + { + "text": "しい", + "furigana": null + } + ], + "korean": "아름답다", + "jlptLevel": 3, + "koreanExample": "이 꽃은 정말 아름답다.", + "japaneseExample": "この花は本当に美しい。" + }, + { + "japanese": [ + { + "text": "日本語", + "furigana": "にほんご" + }, + { + "text": "能力", + "furigana": "のうりょく" + }, + { + "text": "試験", + "furigana": "しけん" + } + ], + "korean": "일본어 능력 시험", + "jlptLevel": 1, + "koreanExample": "저는 일본어 능력 시험을 준비하고 있습니다.", + "japaneseExample": "私は日本語能力試験を準備しています。" + }, + { + "japanese": [ + { + "text": "空港", + "furigana": "くうこう" + } + ], + "korean": "공항", + "jlptLevel": 1, + "koreanExample": "우리는 공항으로 서둘러 갔다.", + "japaneseExample": "私たちは空港へ急いだ。" + }, + { + "japanese": [ + { + "text": "ドキドキ", + "furigana": null + } + ], + "korean": "두근두근", + "jlptLevel": 4, + "koreanExample": "발표를 앞두고 가슴이 두근두근거렸다.", + "japaneseExample": "発表を前に胸がドキドキした。" + }, + { + "japanese": [ + { + "text": "会議", + "furigana": "かいぎ" + }, + { + "text": "室", + "furigana": "しつ" + } + ], + "korean": "회의실", + "jlptLevel": 3, + "koreanExample": "회의실은 3층에 있습니다.", + "japaneseExample": "会議室は3階にあります。" + }, + { + "japanese": [ + { + "text": "待", + "furigana": "ま" + }, + { + "text": "ち合わせる", + "furigana": null + } + ], + "korean": "만나기로 약속하고 기다리다", + "jlptLevel": 2, + "koreanExample": "우리는 역 앞에서 만나기로 약속했다.", + "japaneseExample": "私たちは駅前で待ち合わせることにした。" + }, + { + "japanese": [ + { + "text": "召", + "furigana": "め" + }, + { + "text": "し上", + "furigana": "あ" + }, + { + "text": "がる", + "furigana": null + } + ], + "korean": "드시다 (높임말)", + "jlptLevel": 2, + "koreanExample": "사장님, 점심 드시겠어요?", + "japaneseExample": "社長、昼食を召し上がりますか。" + }, + { + "japanese": [ + { + "text": "申", + "furigana": "もう" + }, + { + "text": "し訳", + "furigana": "わけ" + }, + { + "text": "ありません", + "furigana": null + } + ], + "korean": "죄송합니다", + "jlptLevel": 5, + "koreanExample": "늦어서 정말 죄송합니다.", + "japaneseExample": "遅れて誠に申し訳ありません。" + }, + { + "japanese": [ + { + "text": "天気予報", + "furigana": "てんきよほう" + } + ], + "korean": "일기예보", + "jlptLevel": 2, + "koreanExample": "일기예보에 따르면 내일은 비가 올 것입니다.", + "japaneseExample": "天気予報によると明日は雨が降るでしょう。" + }, + { + "japanese": [ + { + "text": "寿司", + "furigana": "すし" + } + ], + "korean": "초밥", + "jlptLevel": 4, + "koreanExample": "저는 초밥을 아주 좋아합니다.", + "japaneseExample": "私は寿司が大好きです。" + }, + { + "japanese": [ + { + "text": "嬉", + "furigana": "うれ" + }, + { + "text": "しい", + "furigana": null + } + ], + "korean": "기쁘다", + "jlptLevel": 2, + "koreanExample": "선물을 받아서 정말 기쁘다.", + "japaneseExample": "プレゼントをもらって本当に嬉しい。" + }, + { + "japanese": [ + { + "text": "新幹線", + "furigana": "しんかんせん" + } + ], + "korean": "신칸센", + "jlptLevel": 5, + "koreanExample": "저는 신칸센을 처음 타봤습니다.", + "japaneseExample": "私は新幹線に初めて乗りました。" + }, + { + "japanese": [ + { + "text": "一昨日", + "furigana": "おととい" + } + ], + "korean": "그저께", + "jlptLevel": 2, + "koreanExample": "그저께 친구를 만났어요.", + "japaneseExample": "一昨日、友達に会いました。" + }, + { + "japanese": [ + { + "text": "左", + "furigana": "ひだり" + }, + { + "text": "右", + "furigana": "みぎ" + } + ], + "korean": "좌우", + "jlptLevel": 4, + "koreanExample": "길을 건널 때는 좌우를 잘 살피세요.", + "japaneseExample": "道を渡る時は左右をよく見てください。" + }, + { + "japanese": [ + { + "text": "薔薇", + "furigana": "ばら" + } + ], + "korean": "장미", + "jlptLevel": 5, + "koreanExample": "그녀는 빨간 장미를 좋아한다.", + "japaneseExample": "彼女は赤い薔薇が好きだ。" + }, + { + "japanese": [ + { + "text": "お疲", + "furigana": "つか" + }, + { + "text": "れ様", + "furigana": "さま" + }, + { + "text": "でした", + "furigana": null + } + ], + "korean": "수고하셨습니다", + "jlptLevel": 4, + "koreanExample": "오늘 하루도 수고하셨습니다.", + "japaneseExample": "今日一日もお疲れ様でした。" + }, + { + "japanese": [ + { + "text": "三百", + "furigana": "さんびゃく" + } + ], + "korean": "삼백", + "jlptLevel": 4, + "koreanExample": "이 책은 삼백 페이지입니다.", + "japaneseExample": "この本は三百ページです。" + }, + { + "japanese": [ + { + "text": "祖父母", + "furigana": "そふぼ" + } + ], + "korean": "조부모", + "jlptLevel": 3, + "koreanExample": "저는 조부모님과 함께 살고 있습니다.", + "japaneseExample": "私は祖父母と一緒に住んでいます。" + }, + { + "japanese": [ + { + "text": "教科書", + "furigana": "きょうかしょ" + } + ], + "korean": "교과서", + "jlptLevel": 1, + "koreanExample": "교과서를 펴주세요.", + "japaneseExample": "教科書を開いてください。" + }, + { + "japanese": [ + { + "text": "御社", + "furigana": "おんしゃ" + } + ], + "korean": "귀사", + "jlptLevel": 2, + "koreanExample": "귀사의 발전을 기원합니다.", + "japaneseExample": "御社の発展をお祈り申し上げます。" + }, + { + "japanese": [ + { + "text": "地震", + "furigana": "じしん" + } + ], + "korean": "지진", + "jlptLevel": 5, + "koreanExample": "어젯밤에 지진이 있었습니다.", + "japaneseExample": "昨夜、地震がありました。" + }, + { + "japanese": [ + { + "text": "病院", + "furigana": "びょういん" + } + ], + "korean": "병원", + "jlptLevel": 2, + "koreanExample": "감기에 걸려서 병원에 갔습니다.", + "japaneseExample": "風邪をひいて病院へ行きました。" + }, + { + "japanese": [ + { + "text": "自動詞", + "furigana": "じどうし" + } + ], + "korean": "자동사", + "jlptLevel": 3, + "koreanExample": "'가다'는 대표적인 자동사입니다.", + "japaneseExample": "「行く」は代表的な自動詞です。" + } ] \ No newline at end of file diff --git a/JLPTVoca/UIStyleGuide.md b/JLPTVoca/UIStyleGuide.md new file mode 100644 index 0000000..7d0257f --- /dev/null +++ b/JLPTVoca/UIStyleGuide.md @@ -0,0 +1,45 @@ +# UI 구현 스타일 가이드 (참고 프로젝트 분석) + +이 문서는 `UIStyleReference` 프로젝트 분석을 통해 도출된, 우리 프로젝트가 지향할 UI 구현 원칙과 패턴을 정리합니다. + +--- + +## 1. 상수 관리 (Constants Management) + +코드의 일관성과 유지보수성을 위해 '매직 넘버'나 '하드코딩된 문자열' 사용을 지양하고, `enum`을 활용한 네임스페이스로 상수를 관리합니다. + +- **원칙**: `case`가 없는 `enum`과 `static let`을 사용하여 인스턴스 생성을 막고 명확한 접근을 보장합니다. +- **파일 구조**: + - `Constants/` 폴더를 생성하여 상수 관련 파일을 모읍니다. + - `LayoutConstants.swift`: `Padding`, `CornerRadius` 등 UI 레이아웃 숫자 값을 카테고리별 `enum`으로 분리하여 관리합니다. (`Padding.medium`) + - `StringConstants.swift`: `AlertTitle`, `ButtonTitle` 등 문자열을 `enum`으로 분리하여 관리합니다. (`AlertTitle.changeLevel`) +- **뷰 내부 상수**: 특정 뷰에서만 사용되는 상수는 `fileprivate enum`을 사용하여 해당 뷰 내부에 지역적으로 선언합니다. + +## 2. 반응형 레이아웃 (Responsive Layout) + +다양한 화면 크기에 대응하기 위해 고정된 크기 대신 유연하고 상대적인 크기를 사용합니다. + +- **원칙**: 전역 화면 크기(`UIScreen.main.bounds`)에 직접 의존하지 않습니다. 뷰는 자신이 속한 **부모 컨테이너의 크기**에 따라 반응해야 합니다. +- **주요 기술**: + - **`GeometryReader`**: 부모 뷰의 크기를 기준으로 자식 뷰의 크기나 위치를 '비율'로 정해야 하는 복잡한 레이아웃에서 사용합니다. + - **`frame(maxWidth: .infinity)`**: 너비를 유연하게 확장해야 하는 컴포넌트에 사용합니다. + - **높이 자동 계산**: 높이를 명시적으로 지정하지 않고, `Text`나 `VStack` 등의 콘텐츠에 따라 높이가 자연스럽게 결정되도록 합니다. 팝업(Modal) 뷰에 특히 유용합니다. + +## 3. 수정자 및 스타일링 (Modifiers & Styling) + +수정자의 순서와 역할을 명확히 이해하고, 재사용 가능한 스타일을 만듭니다. + +- **모양과 테두리**: `.cornerRadius()` 대신 `.clipShape(RoundedRectangle(...))`을 사용합니다. 테두리는 `.border()` 대신 `.overlay(RoundedRectangle(...).stroke(...))`를 사용하여, 잘려진 모양을 완벽하게 따라가도록 구현합니다. +- **배경과 여백**: `.background()`를 `.padding()`보다 **먼저** 호출하여 배경을 채운 뒤, **나중에** `.padding()`을 호출하여 '외부 간격'을 만듭니다. 수정자 적용 순서의 중요성을 항상 인지합니다. +- **커스텀 수정자 (`ViewModifier`)**: 여러 수정자의 조합(예: 그림자)을 하나의 커스텀 수정자(예: `.customShadow()`)로 만들어 코드 중복을 줄이고 스타일의 일관성을 유지합니다. + +## 4. 컴포넌트 설계 (Component Design) + +뷰를 작고 재사용 가능한 단위로 분리하여 관리합니다. + +- **단일 책임 원칙**: 하나의 뷰는 하나의 기능만 담당하도록 설계합니다. (예: `WordStudyView`를 `WordDeckView`, `StudyPopupView` 등으로 분리) +- **프로토콜 기반 설계**: `FormFieldType`, `OtherMenuButton`과 같은 프로토콜을 정의하여, 데이터의 세부 타입에 얽매이지 않는 **범용 컴포넌트(Generic Component)**를 설계합니다. 이는 앱의 확장성과 재사용성을 극대화하는 고급 패턴입니다. + +## 5. 고급 패턴 (Advanced Patterns) + +- **`PreferenceKey`**: 자식 뷰에서 발생한 레이아웃 정보(크기, 위치 등)를 부모 뷰로 전달하여, 스크롤 위치에 따른 애니메이션 등 복잡한 상호작용을 구현할 때 사용합니다.