diff --git a/Projects/App/iOS/Support/Info.plist b/Projects/App/iOS/Support/Info.plist
index 83c167e6..dfeae89d 100644
--- a/Projects/App/iOS/Support/Info.plist
+++ b/Projects/App/iOS/Support/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 11.3
+ 12.0
CFBundleURLTypes
@@ -32,7 +32,7 @@
CFBundleVersion
- 84
+ 85
ITSAppUsesNonExemptEncryption
LSRequiresIPhoneOS
diff --git a/Projects/Feature/MainFeature/Sources/MainCore.swift b/Projects/Feature/MainFeature/Sources/MainCore.swift
index 7e652f56..44502b06 100644
--- a/Projects/Feature/MainFeature/Sources/MainCore.swift
+++ b/Projects/Feature/MainFeature/Sources/MainCore.swift
@@ -35,7 +35,7 @@ public struct MainCore: Reducer {
@PresentationState public var settingsCore: SettingsCore.State?
@PresentationState public var noticeCore: NoticeCore.State?
public var isShowingReviewToast: Bool = false
- public var dateSelectionMode: DateSelectionMode = .daily
+ public var dateSelectionMode: DateSelectionMode = .weekly
public var displayTitle: String {
let calendar = Calendar.autoupdatingCurrent
@@ -80,9 +80,7 @@ public struct MainCore: Reducer {
case onAppear
case tabTapped(Int)
case tabSwiped(Int)
- case mealCore(MealCore.Action)
case weeklyMealCore(WeeklyMealCore.Action)
- case timeTableCore(TimeTableCore.Action)
case weeklyTimeTableCore(WeeklyTimeTableCore.Action)
case settingButtonDidTap
case noticeButtonDidTap
@@ -92,7 +90,6 @@ public struct MainCore: Reducer {
case showReviewToast
case hideReviewToast
case requestReview
- case weeklyModeUpdated(weeklyEnabled: Bool)
}
@Dependency(\.userDefaultsClient) var userDefaultsClient
@@ -152,21 +149,16 @@ public struct MainCore: Reducer {
state.school = userDefaultsClient.getValue(.school) as? String ?? ""
state.grade = "\(userDefaultsClient.getValue(.grade) as? Int ?? 1)"
state.class = "\(userDefaultsClient.getValue(.class) as? Int ?? 1)"
- if state.mealCore == nil {
- state.mealCore = .init(displayDate: state.$displayDate)
- }
+
if state.weeklyMealCore == nil {
state.weeklyMealCore = .init(displayDate: state.$displayDate)
}
- if state.timeTableCore == nil {
- state.timeTableCore = .init(displayDate: state.$displayDate)
- }
if state.weeklyTimeTableCore == nil {
state.weeklyTimeTableCore = .init(displayDate: state.$displayDate)
}
return .none
- case .mealCore(.refresh), .weeklyMealCore(.refresh), .timeTableCore(.refresh), .weeklyTimeTableCore(.refresh):
+ case .weeklyMealCore(.refresh), .weeklyTimeTableCore(.refresh):
let isSkipWeekend = userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false
let isSkipAfterDinner = userDefaultsClient.getValue(.isSkipAfterDinner) as? Bool ?? true
@@ -183,7 +175,7 @@ public struct MainCore: Reducer {
state.currentTab = tab
logTabSelected(index: tab, selectionType: .swiped)
- case .settingButtonDidTap, .mealCore(.settingsButtonDidTap), .weeklyMealCore(.settingsButtonDidTap):
+ case .settingButtonDidTap, .weeklyMealCore(.settingsButtonDidTap):
state.settingsCore = .init()
let log = SettingButtonClickedEventLog()
TWLog.event(log)
@@ -225,30 +217,6 @@ public struct MainCore: Reducer {
}
return .none
- case let .weeklyModeUpdated(weeklyEnabled):
- let newMode: State.DateSelectionMode = weeklyEnabled ? .weekly : .daily
- guard state.dateSelectionMode != newMode else { return .none }
- state.dateSelectionMode = newMode
-
- let isSkipWeekend = userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false
- let isSkipAfterDinner = userDefaultsClient.getValue(.isSkipAfterDinner) as? Bool ?? true
- let datePolicy = DatePolicy(
- isSkipWeekend: isSkipWeekend,
- isSkipAfterDinner: isSkipAfterDinner
- )
- let today = Date()
- let adjustedDate = datePolicy.adjustedDate(for: today)
-
- state.$displayDate.withLock { date in
- switch newMode {
- case .daily:
- date = adjustedDate
- case .weekly:
- date = datePolicy.startOfWeek(for: adjustedDate)
- }
- }
- return .none
-
default:
return .none
}
@@ -282,16 +250,10 @@ public struct MainCore: Reducer {
extension Reducer where State == MainCore.State, Action == MainCore.Action {
func subFeatures() -> some ReducerOf {
self
- .ifLet(\.mealCore, action: /Action.mealCore) {
- MealCore()
- }
- .ifLet(\.weeklyMealCore, action: /Action.weeklyMealCore) {
+ .ifLet(\.weeklyMealCore, action: \.weeklyMealCore) {
WeeklyMealCore()
}
- .ifLet(\.timeTableCore, action: /Action.timeTableCore) {
- TimeTableCore()
- }
- .ifLet(\.weeklyTimeTableCore, action: /Action.weeklyTimeTableCore) {
+ .ifLet(\.weeklyTimeTableCore, action: \.weeklyTimeTableCore) {
WeeklyTimeTableCore()
}
.ifLet(\.$settingsCore, action: \.settingsCore) {
diff --git a/Projects/Feature/MainFeature/Sources/MainView.swift b/Projects/Feature/MainFeature/Sources/MainView.swift
index b05c9c70..9ea6cf11 100644
--- a/Projects/Feature/MainFeature/Sources/MainView.swift
+++ b/Projects/Feature/MainFeature/Sources/MainView.swift
@@ -15,7 +15,6 @@ public struct MainView: View {
@Environment(\.openURL) var openURL
@Environment(\.calendar) var calendar
@Dependency(\.userDefaultsClient) var userDefaultsClient
- @RemoteConfigProperty(key: "enable_weekly", fallback: false) private var enableWeeklyView
public init(store: StoreOf) {
self.store = store
@@ -51,51 +50,33 @@ public struct MainView: View {
).animation(.default)
) {
VStack {
- if enableWeeklyView {
- IfLetStore(
- store.scope(
- state: \.weeklyMealCore,
- action: MainCore.Action.weeklyMealCore
- )
- ) { store in
- WeeklyMealView(store: store)
- }
- } else {
- IfLetStore(
- store.scope(state: \.mealCore, action: MainCore.Action.mealCore)
- ) { store in
- MealView(store: store)
- }
+ IfLetStore(
+ store.scope(
+ state: \.weeklyMealCore,
+ action: MainCore.Action.weeklyMealCore
+ )
+ ) { store in
+ WeeklyMealView(store: store)
}
}
.tag(0)
VStack {
- if enableWeeklyView {
- IfLetStore(
- store.scope(
- state: \.weeklyTimeTableCore,
- action: MainCore.Action.weeklyTimeTableCore
- )
- ) { store in
- WeeklyTimeTableView(store: store)
- }
- } else {
- IfLetStore(
- store.scope(state: \.timeTableCore, action: MainCore.Action.timeTableCore)
- ) { store in
- TimeTableView(store: store)
- }
+ IfLetStore(
+ store.scope(
+ state: \.weeklyTimeTableCore,
+ action: MainCore.Action.weeklyTimeTableCore
+ )
+ ) { store in
+ WeeklyTimeTableView(store: store)
}
}
.tag(1)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.background {
- if enableWeeklyView {
- Color.backgroundSecondary
- .ignoresSafeArea()
- }
+ Color.backgroundSecondary
+ .ignoresSafeArea()
}
if viewStore.isShowingReviewToast {
@@ -124,104 +105,52 @@ public struct MainView: View {
ToolbarItem(placement: .topBarLeading) {
Menu {
- let isWeeklyModeEnabled = enableWeeklyView
let isSkipWeekend = userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false
let isSkipAfterDinner = userDefaultsClient.getValue(.isSkipAfterDinner) as? Bool ?? true
let datePolicy = DatePolicy(isSkipWeekend: isSkipWeekend, isSkipAfterDinner: isSkipAfterDinner)
let today = Date()
- if isWeeklyModeEnabled {
- let currentWeekStart = datePolicy.startOfWeek(for: today)
- let previousWeek = datePolicy.previousWeekStart(from: currentWeekStart)
- let nextWeek = datePolicy.nextWeekStart(from: currentWeekStart)
- ForEach([previousWeek, currentWeekStart, nextWeek], id: \.timeIntervalSince1970) { weekStart in
- let normalizedWeekStart = datePolicy.startOfWeek(for: weekStart)
- Button {
- let tense: SelectDateTenseEventLog.Tense
- if calendar.isDate(normalizedWeekStart, inSameDayAs: currentWeekStart) {
- tense = .present
- } else if normalizedWeekStart > currentWeekStart {
- tense = .future
- } else {
- tense = .past
- }
-
- TWLog.event(SelectDateTenseEventLog(tense: tense))
- _ = viewStore.send(.dateSelected(normalizedWeekStart))
- } label: {
- let labelText = datePolicy.weekDisplayText(for: normalizedWeekStart, baseDate: today)
- let isSelected = calendar.isDate(
- datePolicy.startOfWeek(for: viewStore.displayDate),
- inSameDayAs: normalizedWeekStart
- )
- if isSelected {
- Label {
- Text(labelText)
- .twFont(.body1)
- .foregroundStyle(Color.extraWhite)
- .animation(.easeInOut(duration: 0.2), value: viewStore.displayDate)
- } icon: {
- Image(systemName: "checkmark")
- }
- } else {
- Text(labelText)
- .twFont(.body1)
- .foregroundStyle(Color.extraBlack)
- .animation(.easeInOut(duration: 0.2), value: viewStore.displayDate)
- }
+ let currentWeekStart = datePolicy.startOfWeek(for: today)
+ let previousWeek = datePolicy.previousWeekStart(from: currentWeekStart)
+ let nextWeek = datePolicy.nextWeekStart(from: currentWeekStart)
+ ForEach([previousWeek, currentWeekStart, nextWeek], id: \.timeIntervalSince1970) { weekStart in
+ let normalizedWeekStart = datePolicy.startOfWeek(for: weekStart)
+ Button {
+ let tense: SelectDateTenseEventLog.Tense
+ if calendar.isDate(normalizedWeekStart, inSameDayAs: currentWeekStart) {
+ tense = .present
+ } else if normalizedWeekStart > currentWeekStart {
+ tense = .future
+ } else {
+ tense = .past
}
- .accessibilityLabel("\(datePolicy.weekDisplayText(for: normalizedWeekStart, baseDate: today)) 선택")
- .accessibilityHint("주 변경")
- }
- } else {
- let yesterday = datePolicy.previousDay(from: today)
- let tomorrow = datePolicy.nextDay(from: today)
-
- ForEach([yesterday, today, tomorrow], id: \.timeIntervalSince1970) { date in
- Button {
- let today = Date()
- let tense: SelectDateTenseEventLog.Tense
-
- if calendar.isDate(date, inSameDayAs: today) {
- tense = .present
- } else if date > today {
- tense = .future
- } else {
- tense = .past
- }
-
- TWLog.event(SelectDateTenseEventLog(tense: tense))
-
- _ = viewStore.send(.dateSelected(date))
- } label: {
- if calendar.isDate(viewStore.displayDate, inSameDayAs: date) {
- Label {
- Text(datePolicy.displayText(for: date, baseDate: today))
- .twFont(.body1)
- .foregroundStyle(
- calendar.isDate(viewStore.displayDate, inSameDayAs: date)
- ? Color.extraWhite
- : Color.extraBlack
- )
- .animation(.easeInOut(duration: 0.2), value: viewStore.displayDate)
- } icon: {
- Image(systemName: "checkmark")
- }
-
- } else {
- Text(datePolicy.displayText(for: date, baseDate: today))
+
+ TWLog.event(SelectDateTenseEventLog(tense: tense))
+ _ = viewStore.send(.dateSelected(normalizedWeekStart))
+ } label: {
+ let labelText = datePolicy.weekDisplayText(for: normalizedWeekStart, baseDate: today)
+ let isSelected = calendar.isDate(
+ datePolicy.startOfWeek(for: viewStore.displayDate),
+ inSameDayAs: normalizedWeekStart
+ )
+ if isSelected {
+ Label {
+ Text(labelText)
.twFont(.body1)
- .foregroundStyle(
- calendar.isDate(viewStore.displayDate, inSameDayAs: date)
- ? Color.extraWhite
- : Color.extraBlack
- )
+ .foregroundStyle(Color.extraWhite)
.animation(.easeInOut(duration: 0.2), value: viewStore.displayDate)
+ } icon: {
+ Image(systemName: "checkmark")
}
+ } else {
+ Text(labelText)
+ .twFont(.body1)
+ .foregroundStyle(Color.extraBlack)
+ .animation(.easeInOut(duration: 0.2), value: viewStore.displayDate)
}
- .accessibilityLabel("\(datePolicy.displayText(for: date, baseDate: today)) 선택")
- .accessibilityHint("날짜 변경")
}
+ .accessibilityLabel("\(datePolicy.weekDisplayText(for: normalizedWeekStart, baseDate: today)) 선택")
+ .accessibilityHint("주 변경")
}
} label: {
HStack(spacing: 0) {
@@ -262,17 +191,10 @@ public struct MainView: View {
}
}
.onAppear {
- TWLog.setUserProperty(property: .enableWeeklyView, value: enableWeeklyView.description)
viewStore.send(.onAppear, animation: .default)
- viewStore.send(.weeklyModeUpdated(weeklyEnabled: enableWeeklyView))
}
- .onChange(of: enableWeeklyView, perform: { _ in
- TWLog.setUserProperty(property: .enableWeeklyView, value: enableWeeklyView.description)
- viewStore.send(.weeklyModeUpdated(weeklyEnabled: enableWeeklyView))
- })
.onLoad {
viewStore.send(.onLoad)
- viewStore.send(.weeklyModeUpdated(weeklyEnabled: enableWeeklyView))
}
.background {
navigationLinks
diff --git a/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealCore.swift b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealCore.swift
index 8265c814..7b841119 100644
--- a/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealCore.swift
+++ b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealCore.swift
@@ -62,24 +62,10 @@ public struct WeeklyMealCore: Reducer {
Reduce { state, action in
switch action {
case .onLoad:
- do {
- state.allergyList = try localDatabaseClient.readRecords(as: AllergyLocalEntity.self)
- .compactMap { AllergyType(rawValue: $0.allergy) ?? nil }
- } catch {}
-
- state.showWeekend = !(userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false)
- state.isLoading = true
-
- let displayDate = state.displayDate
- let showWeekend = state.showWeekend
-
- return .merge(
- fetchWeeklyMeals(displayDate: displayDate, showWeekend: showWeekend),
- .publisher {
- state.$displayDate.publisher
- .map { _ in Action.refreshData }
- }
- )
+ return .publisher {
+ state.$displayDate.publisher
+ .map { _ in Action.refreshData }
+ }
case .onAppear:
do {
diff --git a/Projects/Shared/Entity/Sources/Local/MealLocalEntity.swift b/Projects/Shared/Entity/Sources/Local/MealLocalEntity.swift
index a899cdd4..45907091 100644
--- a/Projects/Shared/Entity/Sources/Local/MealLocalEntity.swift
+++ b/Projects/Shared/Entity/Sources/Local/MealLocalEntity.swift
@@ -2,5 +2,71 @@ import Foundation
import GRDB
public struct MealLocalEntity: Codable, FetchableRecord, PersistableRecord {
- public let date: Date
+ public let id: String
+ public let date: String // yyyyMMdd format
+ public let breakfastMeals: String // JSON array
+ public let breakfastCal: Double
+ public let lunchMeals: String // JSON array
+ public let lunchCal: Double
+ public let dinnerMeals: String // JSON array
+ public let dinnerCal: Double
+ public let createdAt: Date
+
+ public init(
+ id: String = UUID().uuidString,
+ date: String,
+ breakfastMeals: String,
+ breakfastCal: Double,
+ lunchMeals: String,
+ lunchCal: Double,
+ dinnerMeals: String,
+ dinnerCal: Double,
+ createdAt: Date = Date()
+ ) {
+ self.id = id
+ self.date = date
+ self.breakfastMeals = breakfastMeals
+ self.breakfastCal = breakfastCal
+ self.lunchMeals = lunchMeals
+ self.lunchCal = lunchCal
+ self.dinnerMeals = dinnerMeals
+ self.dinnerCal = dinnerCal
+ self.createdAt = createdAt
+ }
+
+ public static func persistenceKey(for date: String) -> String {
+ return date
+ }
+
+ public static var databaseTableName: String {
+ return "mealLocalEntity"
+ }
+}
+
+public extension MealLocalEntity {
+ init(date: String, meal: Meal) {
+ let encoder = JSONEncoder()
+ self.init(
+ date: date,
+ breakfastMeals: (try? String(data: encoder.encode(meal.breakfast.meals), encoding: .utf8)) ?? "[]",
+ breakfastCal: meal.breakfast.cal,
+ lunchMeals: (try? String(data: encoder.encode(meal.lunch.meals), encoding: .utf8)) ?? "[]",
+ lunchCal: meal.lunch.cal,
+ dinnerMeals: (try? String(data: encoder.encode(meal.dinner.meals), encoding: .utf8)) ?? "[]",
+ dinnerCal: meal.dinner.cal
+ )
+ }
+
+ func toMeal() -> Meal {
+ let decoder = JSONDecoder()
+ let breakfastMealArray = (try? decoder.decode([String].self, from: Data(breakfastMeals.utf8))) ?? []
+ let lunchMealArray = (try? decoder.decode([String].self, from: Data(lunchMeals.utf8))) ?? []
+ let dinnerMealArray = (try? decoder.decode([String].self, from: Data(dinnerMeals.utf8))) ?? []
+
+ return Meal(
+ breakfast: Meal.SubMeal(meals: breakfastMealArray, cal: breakfastCal),
+ lunch: Meal.SubMeal(meals: lunchMealArray, cal: lunchCal),
+ dinner: Meal.SubMeal(meals: dinnerMealArray, cal: dinnerCal)
+ )
+ }
}
diff --git a/Projects/Shared/Entity/Sources/Local/TimeTableLocalEntity.swift b/Projects/Shared/Entity/Sources/Local/TimeTableLocalEntity.swift
new file mode 100644
index 00000000..9c806c26
--- /dev/null
+++ b/Projects/Shared/Entity/Sources/Local/TimeTableLocalEntity.swift
@@ -0,0 +1,73 @@
+import Foundation
+import GRDB
+
+public struct TimeTableLocalEntity: Codable, FetchableRecord, PersistableRecord {
+ public let id: String
+ public let date: String // yyyyMMdd format
+ public let timeTableData: String // JSON array of TimeTableItem
+ public let createdAt: Date
+
+ public init(
+ id: String = UUID().uuidString,
+ date: String,
+ timeTableData: String,
+ createdAt: Date = Date()
+ ) {
+ self.id = id
+ self.date = date
+ self.timeTableData = timeTableData
+ self.createdAt = createdAt
+ }
+
+ public static func persistenceKey(for date: String) -> String {
+ return date
+ }
+
+ public static var databaseTableName: String {
+ return "timeTableLocalEntity"
+ }
+}
+
+// Helper structure for JSON serialization
+private struct TimeTableItem: Codable {
+ let perio: Int
+ let content: String
+}
+
+public extension TimeTableLocalEntity {
+ init(date: String, timeTables: [TimeTable]) {
+ let encoder = JSONEncoder()
+ let items = timeTables.map { TimeTableItem(perio: $0.perio, content: $0.content) }
+ let jsonString = (try? String(data: encoder.encode(items), encoding: .utf8)) ?? "[]"
+
+ self.init(
+ date: date,
+ timeTableData: jsonString
+ )
+ }
+
+ private static let dateOnlyFormatter = {
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyyMMdd"
+ dateFormatter.timeZone = .autoupdatingCurrent
+ dateFormatter.locale = .autoupdatingCurrent
+ return dateFormatter
+ }()
+
+ func toTimeTables() -> [TimeTable] {
+ let decoder = JSONDecoder()
+ guard let items = try? decoder.decode([TimeTableItem].self, from: Data(timeTableData.utf8)) else {
+ return []
+ }
+
+ let dateObject = Self.dateOnlyFormatter.date(from: date)
+
+ return items.map { item in
+ TimeTable(
+ perio: item.perio,
+ content: item.content,
+ date: dateObject
+ )
+ }
+ }
+}
diff --git a/Projects/Shared/Entity/Sources/Meal.swift b/Projects/Shared/Entity/Sources/Meal.swift
index caa9f075..603b04bc 100644
--- a/Projects/Shared/Entity/Sources/Meal.swift
+++ b/Projects/Shared/Entity/Sources/Meal.swift
@@ -34,4 +34,8 @@ public struct Meal: Equatable, Hashable {
return dinner
}
}
+
+ public var isEmpty: Bool {
+ return breakfast.meals.isEmpty && lunch.meals.isEmpty && dinner.meals.isEmpty
+ }
}
diff --git a/Projects/Shared/LocalDatabaseClient/Sources/LocalDatabaseClient.swift b/Projects/Shared/LocalDatabaseClient/Sources/LocalDatabaseClient.swift
index d4b3064b..ca49b1e7 100644
--- a/Projects/Shared/LocalDatabaseClient/Sources/LocalDatabaseClient.swift
+++ b/Projects/Shared/LocalDatabaseClient/Sources/LocalDatabaseClient.swift
@@ -40,6 +40,26 @@ public struct LocalDatabaseClient {
}
}
+ public func readRecordByColumn(
+ record: Record.Type,
+ column: String,
+ value: some DatabaseValueConvertible
+ ) throws -> Record? {
+ try dbQueue.read { db in
+ try record.filter(Column(column) == value).fetchOne(db)
+ }
+ }
+
+ public func readRecordsByColumn(
+ record: Record.Type,
+ column: String,
+ values: [some DatabaseValueConvertible]
+ ) throws -> [Record] {
+ try dbQueue.read { db in
+ try record.filter(values.contains(Column(column))).fetchAll(db)
+ }
+ }
+
public func updateRecord(
record: Record.Type,
at key: any DatabaseValueConvertible,
@@ -216,6 +236,27 @@ extension LocalDatabaseClient: DependencyKey {
table.column("content", .text).notNull().defaults(to: "")
}
}
+
+ migrator.registerMigration("v1.2.0") { db in
+ try db.create(table: "mealLocalEntity") { table in
+ table.column("id", .text).primaryKey(onConflict: .replace).notNull()
+ table.column("date", .text).notNull().unique(onConflict: .replace)
+ table.column("breakfastMeals", .text).notNull()
+ table.column("breakfastCal", .double).notNull()
+ table.column("lunchMeals", .text).notNull()
+ table.column("lunchCal", .double).notNull()
+ table.column("dinnerMeals", .text).notNull()
+ table.column("dinnerCal", .double).notNull()
+ table.column("createdAt", .date).notNull()
+ }
+
+ try db.create(table: "timeTableLocalEntity") { table in
+ table.column("id", .text).primaryKey(onConflict: .replace).notNull()
+ table.column("date", .text).notNull().unique(onConflict: .replace)
+ table.column("timeTableData", .text).notNull()
+ table.column("createdAt", .date).notNull()
+ }
+ }
}
}
diff --git a/Projects/Shared/MealClient/Project.swift b/Projects/Shared/MealClient/Project.swift
index d81240b1..e5e9d717 100644
--- a/Projects/Shared/MealClient/Project.swift
+++ b/Projects/Shared/MealClient/Project.swift
@@ -9,6 +9,7 @@ let project = Project.module(
.shared(target: .ComposableArchitectureWrapper),
.shared(target: .NeisClient),
.shared(target: .UserDefaultsClient),
+ .shared(target: .LocalDatabaseClient),
.shared(target: .DateUtil),
.shared(target: .EnumUtil),
.shared(target: .Entity)
diff --git a/Projects/Shared/MealClient/Sources/MealClient.swift b/Projects/Shared/MealClient/Sources/MealClient.swift
index c5236fec..9360e537 100644
--- a/Projects/Shared/MealClient/Sources/MealClient.swift
+++ b/Projects/Shared/MealClient/Sources/MealClient.swift
@@ -3,6 +3,7 @@ import Dependencies
import Entity
import EnumUtil
import Foundation
+import LocalDatabaseClient
import NeisClient
import UserDefaultsClient
@@ -11,11 +12,19 @@ public struct MealClient: Sendable {
public var fetchMeals: @Sendable (_ startDate: Date, _ dayCount: Int) async throws -> [Date: Meal]
}
+private func formatDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMdd"
+ formatter.locale = Locale(identifier: "ko_kr")
+ return formatter.string(from: date)
+}
+
extension MealClient: DependencyKey {
public static var liveValue: MealClient = MealClient(
fetchMeal: { date in
var date = date
@Dependency(\.userDefaultsClient) var userDefaultsClient: UserDefaultsClient
+ @Dependency(\.localDatabaseClient) var localDatabaseClient: LocalDatabaseClient
if userDefaultsClient.getValue(.isSkipWeekend) as? Bool == true {
if date.weekday == 7 {
@@ -25,50 +34,34 @@ extension MealClient: DependencyKey {
}
}
- guard
- let orgCode = userDefaultsClient.getValue(.orgCode) as? String,
- let code = userDefaultsClient.getValue(.schoolCode) as? String
- else {
- return Meal.empty
- }
+ let reqDate = formatDate(date)
+
+ if let cachedEntity = try? localDatabaseClient.readRecordByColumn(
+ record: MealLocalEntity.self,
+ column: "date",
+ value: reqDate
+ ) {
+ let cachedMeal = cachedEntity.toMeal()
- @Dependency(\.neisClient) var neisClient
-
- let month = date.month < 10 ? "0\(date.month)" : "\(date.month)"
- let day = date.day < 10 ? "0\(date.day)" : "\(date.day)"
- let reqDate = "\(date.year)\(month)\(day)"
-
- let key = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String
- let response: [SingleMealResponseDTO]
- do {
- response = try await neisClient.fetchDataOnNeis(
- "mealServiceDietInfo",
- queryItem: [
- .init(name: "KEY", value: key),
- .init(name: "Type", value: "json"),
- .init(name: "pIndex", value: "1"),
- .init(name: "pSize", value: "10"),
- .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
- .init(name: "SD_SCHUL_CODE", value: code),
- .init(name: "MLSV_FROM_YMD", value: reqDate),
- .init(name: "MLSV_TO_YMD", value: reqDate)
- ],
- key: "mealServiceDietInfo",
- type: [SingleMealResponseDTO].self
- )
- } catch {
- response = []
+ if !cachedMeal.isEmpty {
+ Task.detached {
+ await syncMealFromServer(date: date, reqDate: reqDate)
+ }
+ return cachedMeal
+ }
}
- let breakfast = parseMeal(response: response, type: .breakfast)
- let lunch = parseMeal(response: response, type: .lunch)
- let dinner = parseMeal(response: response, type: .dinner)
+ let meal = await fetchMealFromServer(date: date, reqDate: reqDate)
+
+ let entity = MealLocalEntity(date: reqDate, meal: meal)
+ try? localDatabaseClient.save(record: entity)
- return Meal(breakfast: breakfast, lunch: lunch, dinner: dinner)
+ return meal
},
fetchMeals: { startDate, dayCount in
guard dayCount > 0 else { return [:] }
@Dependency(\.userDefaultsClient) var userDefaultsClient: UserDefaultsClient
+ @Dependency(\.localDatabaseClient) var localDatabaseClient: LocalDatabaseClient
let calendar = Calendar.autoupdatingCurrent
let normalizedStart = calendar.startOfDay(for: startDate)
@@ -79,55 +72,195 @@ extension MealClient: DependencyKey {
return populateEmptyMeals(calendar: calendar, start: normalizedStart, dayCount: dayCount)
}
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyyMMdd"
- formatter.locale = Locale(identifier: "ko_kr")
-
- let endDate = calendar.date(byAdding: .day, value: dayCount - 1, to: normalizedStart) ?? normalizedStart
-
- @Dependency(\.neisClient) var neisClient
- let key = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String
- let response: [SingleMealResponseDTO]
- do {
- response = try await neisClient.fetchDataOnNeis(
- "mealServiceDietInfo",
- queryItem: [
- .init(name: "KEY", value: key),
- .init(name: "Type", value: "json"),
- .init(name: "pIndex", value: "1"),
- .init(name: "pSize", value: "100"),
- .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
- .init(name: "SD_SCHUL_CODE", value: code),
- .init(name: "MLSV_FROM_YMD", value: formatter.string(from: normalizedStart)),
- .init(name: "MLSV_TO_YMD", value: formatter.string(from: endDate))
- ],
- key: "mealServiceDietInfo",
- type: [SingleMealResponseDTO].self
- )
- } catch {
- response = []
+ var dateStrings: [String] = []
+ var dateMapping: [String: Date] = [:]
+ for offset in 0.. Meal {
+ @Dependency(\.userDefaultsClient) var userDefaultsClient: UserDefaultsClient
+ @Dependency(\.neisClient) var neisClient
+
+ guard
+ let orgCode = userDefaultsClient.getValue(.orgCode) as? String,
+ let code = userDefaultsClient.getValue(.schoolCode) as? String
+ else {
+ return Meal.empty
+ }
+
+ let key = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String
+ let response: [SingleMealResponseDTO]
+ do {
+ response = try await neisClient.fetchDataOnNeis(
+ "mealServiceDietInfo",
+ queryItem: [
+ .init(name: "KEY", value: key),
+ .init(name: "Type", value: "json"),
+ .init(name: "pIndex", value: "1"),
+ .init(name: "pSize", value: "10"),
+ .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
+ .init(name: "SD_SCHUL_CODE", value: code),
+ .init(name: "MLSV_FROM_YMD", value: reqDate),
+ .init(name: "MLSV_TO_YMD", value: reqDate)
+ ],
+ key: "mealServiceDietInfo",
+ type: [SingleMealResponseDTO].self
+ )
+ } catch {
+ response = []
+ }
+
+ let breakfast = parseMeal(response: response, type: .breakfast)
+ let lunch = parseMeal(response: response, type: .lunch)
+ let dinner = parseMeal(response: response, type: .dinner)
+
+ return Meal(breakfast: breakfast, lunch: lunch, dinner: dinner)
+}
+
+private func syncMealFromServer(date: Date, reqDate: String) async {
+ @Dependency(\.localDatabaseClient) var localDatabaseClient: LocalDatabaseClient
+
+ let meal = await fetchMealFromServer(date: date, reqDate: reqDate)
+ let entity = MealLocalEntity(date: reqDate, meal: meal)
+
+ try? localDatabaseClient.save(record: entity)
+}
+
+private func fetchMealsFromServer(
+ startDate: Date,
+ dayCount: Int,
+ orgCode: String,
+ code: String
+) async -> [Date: Meal] {
+ @Dependency(\.neisClient) var neisClient
+ @Dependency(\.localDatabaseClient) var localDatabaseClient: LocalDatabaseClient
+
+ let calendar = Calendar.autoupdatingCurrent
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMdd"
+ formatter.locale = Locale(identifier: "ko_kr")
+
+ let endDate = calendar.date(byAdding: .day, value: dayCount - 1, to: startDate) ?? startDate
+
+ let key = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String
+ let response: [SingleMealResponseDTO]
+ do {
+ response = try await neisClient.fetchDataOnNeis(
+ "mealServiceDietInfo",
+ queryItem: [
+ .init(name: "KEY", value: key),
+ .init(name: "Type", value: "json"),
+ .init(name: "pIndex", value: "1"),
+ .init(name: "pSize", value: "100"),
+ .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
+ .init(name: "SD_SCHUL_CODE", value: code),
+ .init(name: "MLSV_FROM_YMD", value: formatter.string(from: startDate)),
+ .init(name: "MLSV_TO_YMD", value: formatter.string(from: endDate))
+ ],
+ key: "mealServiceDietInfo",
+ type: [SingleMealResponseDTO].self
+ )
+ } catch {
+ response = []
+ }
+
+ let groupedByDate = Dictionary(grouping: response, by: \.serviceDate)
+
+ var result: [Date: Meal] = [:]
+
+ for (dateString, entries) in groupedByDate {
+ let breakfast = parseMeal(response: entries, type: .breakfast)
+ let lunch = parseMeal(response: entries, type: .lunch)
+ let dinner = parseMeal(response: entries, type: .dinner)
+
+ let meal = Meal(
+ breakfast: breakfast,
+ lunch: lunch,
+ dinner: dinner
+ )
+
+ let entity = MealLocalEntity(date: dateString, meal: meal)
+
+ try? localDatabaseClient.save(record: entity)
+
+ if let date = formatter.date(from: dateString) {
+ let normalizedDate = calendar.startOfDay(for: date)
+ result[normalizedDate] = meal
+ }
+ }
+
+ var currentDate = calendar.startOfDay(for: startDate)
+ for _ in 0.. [TimeTable]
}
+private func formatDate(_ date: Date) -> String {
+ let month = date.month < 10 ? "0\(date.month)" : "\(date.month)"
+ let day = date.day < 10 ? "0\(date.day)" : "\(date.day)"
+ return "\(date.year)\(month)\(day)"
+}
+
extension TimeTableClient: DependencyKey {
public static var liveValue: TimeTableClient = TimeTableClient(
fetchTimeTable: { date in
var date = date
@Dependency(\.userDefaultsClient) var userDefaultsClient: UserDefaultsClient
+ @Dependency(\.localDatabaseClient) var localDatabaseClient: LocalDatabaseClient
if userDefaultsClient.getValue(.isSkipWeekend) as? Bool == true {
if date.weekday == 7 {
@@ -26,71 +34,33 @@ extension TimeTableClient: DependencyKey {
}
}
- guard
- let typeRaw = userDefaultsClient.getValue(.schoolType) as? String,
- let type = SchoolType(rawValue: typeRaw),
- let code = userDefaultsClient.getValue(.schoolCode) as? String,
- let orgCode = userDefaultsClient.getValue(.orgCode) as? String,
- let grade = userDefaultsClient.getValue(.grade) as? Int,
- let `class` = userDefaultsClient.getValue(.class) as? Int
- else {
- return []
- }
- let major = userDefaultsClient.getValue(.major) as? String
+ let reqDate = formatDate(date)
- let month = date.month < 10 ? "0\(date.month)" : "\(date.month)"
- let day = date.day < 10 ? "0\(date.day)" : "\(date.day)"
- let reqDate = "\(date.year)\(month)\(day)"
-
- @Dependency(\.neisClient) var neisClient
-
- let key = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String
- let response: [SingleTimeTableResponseDTO]
- do {
- response = try await neisClient.fetchDataOnNeis(
- type.toSubURL(),
- queryItem: [
- .init(name: "KEY", value: key),
- .init(name: "Type", value: "json"),
- .init(name: "pIndex", value: "1"),
- .init(name: "pSize", value: "100"),
- .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
- .init(name: "SD_SCHUL_CODE", value: code),
- .init(name: "DDDEP_NM", value: major),
- .init(name: "GRADE", value: "\(grade)"),
- .init(name: "CLASS_NM", value: "\(`class`)"),
- .init(name: "TI_FROM_YMD", value: reqDate),
- .init(name: "TI_TO_YMD", value: reqDate)
- ],
- key: type.toSubURL(),
- type: [SingleTimeTableResponseDTO].self
- )
- } catch {
- response = try await neisClient.fetchDataOnNeis(
- type.toSubURL(),
- queryItem: [
- .init(name: "KEY", value: key),
- .init(name: "Type", value: "json"),
- .init(name: "pIndex", value: "1"),
- .init(name: "pSize", value: "100"),
- .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
- .init(name: "SD_SCHUL_CODE", value: code),
- .init(name: "GRADE", value: "\(grade)"),
- .init(name: "CLASS_NM", value: "\(`class`)"),
- .init(name: "TI_FROM_YMD", value: reqDate),
- .init(name: "TI_TO_YMD", value: reqDate)
- ],
- key: type.toSubURL(),
- type: [SingleTimeTableResponseDTO].self
- )
+ if let cachedEntity = try? localDatabaseClient.readRecordByColumn(
+ record: TimeTableLocalEntity.self,
+ column: "date",
+ value: reqDate
+ ) {
+ let cachedTimeTables = cachedEntity.toTimeTables()
+
+ if !cachedTimeTables.isEmpty {
+ Task.detached {
+ await syncTimeTableFromServer(date: date, reqDate: reqDate)
+ }
+ return cachedTimeTables
+ }
}
- return response
- .map { $0.toDomain() }
- .uniqued()
+ let timeTables = await fetchTimeTableFromServer(date: date, reqDate: reqDate)
+
+ let entity = TimeTableLocalEntity(date: reqDate, timeTables: timeTables)
+ try? localDatabaseClient.save(record: entity)
+
+ return timeTables
},
fetchTimeTableRange: { startAt, endAt in
@Dependency(\.userDefaultsClient) var userDefaultsClient: UserDefaultsClient
+ @Dependency(\.localDatabaseClient) var localDatabaseClient: LocalDatabaseClient
guard
let typeRaw = userDefaultsClient.getValue(.schoolType) as? String,
@@ -104,67 +74,232 @@ extension TimeTableClient: DependencyKey {
}
let major = userDefaultsClient.getValue(.major) as? String
- let startReqDate = {
- let month = startAt.month < 10 ? "0\(startAt.month)" : "\(startAt.month)"
- let day = startAt.day < 10 ? "0\(startAt.day)" : "\(startAt.day)"
- return "\(startAt.year)\(month)\(day)"
- }()
- let endReqDate = {
- let month = endAt.month < 10 ? "0\(endAt.month)" : "\(endAt.month)"
- let day = endAt.day < 10 ? "0\(endAt.day)" : "\(endAt.day)"
- return "\(endAt.year)\(month)\(day)"
- }()
-
- @Dependency(\.neisClient) var neisClient
-
- let key = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String
- let response: [SingleTimeTableResponseDTO]
- do {
- response = try await neisClient.fetchDataOnNeis(
- type.toSubURL(),
- queryItem: [
- .init(name: "KEY", value: key),
- .init(name: "Type", value: "json"),
- .init(name: "pIndex", value: "1"),
- .init(name: "pSize", value: "100"),
- .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
- .init(name: "SD_SCHUL_CODE", value: code),
- .init(name: "DDDEP_NM", value: major),
- .init(name: "GRADE", value: "\(grade)"),
- .init(name: "CLASS_NM", value: "\(`class`)"),
- .init(name: "TI_FROM_YMD", value: startReqDate),
- .init(name: "TI_TO_YMD", value: endReqDate)
- ],
- key: type.toSubURL(),
- type: [SingleTimeTableResponseDTO].self
+ let startReqDate = formatDate(startAt)
+ let endReqDate = formatDate(endAt)
+
+ var dateStrings: [String] = []
+ let calendar = Calendar.autoupdatingCurrent
+ var currentDate = calendar.startOfDay(for: startAt)
+ let normalizedEnd = calendar.startOfDay(for: endAt)
+
+ while currentDate <= normalizedEnd {
+ dateStrings.append(formatDate(currentDate))
+ guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else { break }
+ currentDate = nextDate
+ }
+
+ var cachedTimeTables: [TimeTable] = []
+ if let cachedEntities = try? localDatabaseClient.readRecordsByColumn(
+ record: TimeTableLocalEntity.self,
+ column: "date",
+ values: dateStrings
+ ) {
+ cachedTimeTables = cachedEntities.flatMap { $0.toTimeTables() }
+ }
+
+ Task.detached {
+ await syncTimeTableRangeFromServer(
+ startAt: startAt,
+ endAt: endAt,
+ type: type,
+ orgCode: orgCode,
+ code: code,
+ grade: grade,
+ classNum: `class`,
+ major: major
)
- } catch {
- response = try await neisClient.fetchDataOnNeis(
- type.toSubURL(),
- queryItem: [
- .init(name: "KEY", value: key),
- .init(name: "Type", value: "json"),
- .init(name: "pIndex", value: "1"),
- .init(name: "pSize", value: "100"),
- .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
- .init(name: "SD_SCHUL_CODE", value: code),
- .init(name: "GRADE", value: "\(grade)"),
- .init(name: "CLASS_NM", value: "\(`class`)"),
- .init(name: "TI_FROM_YMD", value: startReqDate),
- .init(name: "TI_TO_YMD", value: endReqDate)
- ],
- key: type.toSubURL(),
- type: [SingleTimeTableResponseDTO].self
+ }
+
+ if cachedTimeTables.isEmpty {
+ return await fetchTimeTableRangeFromServer(
+ startAt: startAt,
+ endAt: endAt,
+ type: type,
+ orgCode: orgCode,
+ code: code,
+ grade: grade,
+ classNum: `class`,
+ major: major
)
}
- return response
- .map { $0.toDomain() }
- .uniqued()
+ return cachedTimeTables
}
)
}
+private func fetchTimeTableFromServer(date: Date, reqDate: String) async -> [TimeTable] {
+ @Dependency(\.userDefaultsClient) var userDefaultsClient: UserDefaultsClient
+ @Dependency(\.neisClient) var neisClient
+
+ guard
+ let typeRaw = userDefaultsClient.getValue(.schoolType) as? String,
+ let type = SchoolType(rawValue: typeRaw),
+ let code = userDefaultsClient.getValue(.schoolCode) as? String,
+ let orgCode = userDefaultsClient.getValue(.orgCode) as? String,
+ let grade = userDefaultsClient.getValue(.grade) as? Int,
+ let `class` = userDefaultsClient.getValue(.class) as? Int
+ else {
+ return []
+ }
+ let major = userDefaultsClient.getValue(.major) as? String
+
+ let key = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String
+ let response: [SingleTimeTableResponseDTO]
+ do {
+ response = try await neisClient.fetchDataOnNeis(
+ type.toSubURL(),
+ queryItem: [
+ .init(name: "KEY", value: key),
+ .init(name: "Type", value: "json"),
+ .init(name: "pIndex", value: "1"),
+ .init(name: "pSize", value: "100"),
+ .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
+ .init(name: "SD_SCHUL_CODE", value: code),
+ .init(name: "DDDEP_NM", value: major),
+ .init(name: "GRADE", value: "\(grade)"),
+ .init(name: "CLASS_NM", value: "\(`class`)"),
+ .init(name: "TI_FROM_YMD", value: reqDate),
+ .init(name: "TI_TO_YMD", value: reqDate)
+ ],
+ key: type.toSubURL(),
+ type: [SingleTimeTableResponseDTO].self
+ )
+ } catch {
+ do {
+ response = try await neisClient.fetchDataOnNeis(
+ type.toSubURL(),
+ queryItem: [
+ .init(name: "KEY", value: key),
+ .init(name: "Type", value: "json"),
+ .init(name: "pIndex", value: "1"),
+ .init(name: "pSize", value: "100"),
+ .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
+ .init(name: "SD_SCHUL_CODE", value: code),
+ .init(name: "GRADE", value: "\(grade)"),
+ .init(name: "CLASS_NM", value: "\(`class`)"),
+ .init(name: "TI_FROM_YMD", value: reqDate),
+ .init(name: "TI_TO_YMD", value: reqDate)
+ ],
+ key: type.toSubURL(),
+ type: [SingleTimeTableResponseDTO].self
+ )
+ } catch {
+ response = []
+ }
+ }
+
+ return response
+ .map { $0.toDomain() }
+ .uniqued()
+}
+
+private func syncTimeTableFromServer(date: Date, reqDate: String) async {
+ @Dependency(\.localDatabaseClient) var localDatabaseClient: LocalDatabaseClient
+
+ let timeTables = await fetchTimeTableFromServer(date: date, reqDate: reqDate)
+ let entity = TimeTableLocalEntity(date: reqDate, timeTables: timeTables)
+
+ try? localDatabaseClient.save(record: entity)
+}
+
+private func fetchTimeTableRangeFromServer(
+ startAt: Date,
+ endAt: Date,
+ type: SchoolType,
+ orgCode: String,
+ code: String,
+ grade: Int,
+ classNum: Int,
+ major: String?
+) async -> [TimeTable] {
+ @Dependency(\.neisClient) var neisClient
+ @Dependency(\.localDatabaseClient) var localDatabaseClient: LocalDatabaseClient
+
+ let startReqDate = formatDate(startAt)
+ let endReqDate = formatDate(endAt)
+
+ let key = Bundle.main.object(forInfoDictionaryKey: "API_KEY") as? String
+ let response: [SingleTimeTableResponseDTO]
+ do {
+ response = try await neisClient.fetchDataOnNeis(
+ type.toSubURL(),
+ queryItem: [
+ .init(name: "KEY", value: key),
+ .init(name: "Type", value: "json"),
+ .init(name: "pIndex", value: "1"),
+ .init(name: "pSize", value: "100"),
+ .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
+ .init(name: "SD_SCHUL_CODE", value: code),
+ .init(name: "DDDEP_NM", value: major),
+ .init(name: "GRADE", value: "\(grade)"),
+ .init(name: "CLASS_NM", value: "\(classNum)"),
+ .init(name: "TI_FROM_YMD", value: startReqDate),
+ .init(name: "TI_TO_YMD", value: endReqDate)
+ ],
+ key: type.toSubURL(),
+ type: [SingleTimeTableResponseDTO].self
+ )
+ } catch {
+ do {
+ response = try await neisClient.fetchDataOnNeis(
+ type.toSubURL(),
+ queryItem: [
+ .init(name: "KEY", value: key),
+ .init(name: "Type", value: "json"),
+ .init(name: "pIndex", value: "1"),
+ .init(name: "pSize", value: "100"),
+ .init(name: "ATPT_OFCDC_SC_CODE", value: orgCode),
+ .init(name: "SD_SCHUL_CODE", value: code),
+ .init(name: "GRADE", value: "\(grade)"),
+ .init(name: "CLASS_NM", value: "\(classNum)"),
+ .init(name: "TI_FROM_YMD", value: startReqDate),
+ .init(name: "TI_TO_YMD", value: endReqDate)
+ ],
+ key: type.toSubURL(),
+ type: [SingleTimeTableResponseDTO].self
+ )
+ } catch {
+ response = []
+ }
+ }
+
+ let timeTables = response.map { $0.toDomain() }.uniqued()
+
+ let groupedByDate = Dictionary(grouping: timeTables, by: { $0.date })
+ for (date, tables) in groupedByDate {
+ guard let date = date else { continue }
+ let dateString = formatDate(date)
+ let entity = TimeTableLocalEntity(date: dateString, timeTables: tables)
+
+ try? localDatabaseClient.save(record: entity)
+ }
+
+ return timeTables
+}
+
+private func syncTimeTableRangeFromServer(
+ startAt: Date,
+ endAt: Date,
+ type: SchoolType,
+ orgCode: String,
+ code: String,
+ grade: Int,
+ classNum: Int,
+ major: String?
+) async {
+ _ = await fetchTimeTableRangeFromServer(
+ startAt: startAt,
+ endAt: endAt,
+ type: type,
+ orgCode: orgCode,
+ code: code,
+ grade: grade,
+ classNum: classNum,
+ major: major
+ )
+}
+
private extension Sequence where Element: Hashable {
func uniqued() -> [Element] {
var set = Set()