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()