diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index da0f2e27..e1f3cc85 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -206,6 +206,10 @@ let schemes: [Scheme] = [ let project = Project( name: env.name, organizationName: env.organizationName, + options: .options( + defaultKnownRegions: ["ko"], + developmentRegion: "ko" + ), settings: settings, targets: targets, schemes: schemes diff --git a/Projects/App/iOS/Sources/Application/AppDelegate.swift b/Projects/App/iOS/Sources/Application/AppDelegate.swift index dc668b90..cc382170 100644 --- a/Projects/App/iOS/Sources/Application/AppDelegate.swift +++ b/Projects/App/iOS/Sources/Application/AppDelegate.swift @@ -1,4 +1,5 @@ import Dependencies +import DesignSystem import Entity import EnumUtil import FeatureFlagClient @@ -29,11 +30,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { FirebaseApp.configure() Task { do { - try await featureFlagClient.activate() + try await RemoteConfig.remoteConfig().fetchAndActivate() } catch { TWLog.error(error) } } + DesignSystemFontFamily.Suit.all.forEach { $0.register() } initializeAnalyticsUserID() sendUserPropertyWidget() session = WCSession.default diff --git a/Projects/App/iOS/Support/Info.plist b/Projects/App/iOS/Support/Info.plist index 5ec1af8e..6614bafe 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 - 9.0 + 10.0 CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 72 + 79 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift b/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift index 2d00d99c..00ca6805 100644 --- a/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift +++ b/Projects/Feature/MainFeature/Sources/Components/ReviewToast.swift @@ -8,7 +8,7 @@ struct ReviewToast: View { let onTap: () -> Void var body: some View { - Button { + let baseButton = Button { onTap() } label: { HStack(spacing: 8) { @@ -21,9 +21,18 @@ struct ReviewToast: View { } .padding(.horizontal, 16) .padding(.vertical, 12) - .background(Color.extraWhite) - .cornerRadius(20) - .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 4) + } + + if #available(iOS 26.0, *) { + baseButton + .glassEffect(.regular.interactive(), in: .capsule) + } else { + baseButton + .background { + RoundedRectangle(cornerRadius: 20) + .fill(Color.extraWhite) + } + .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 4) } } } diff --git a/Projects/Feature/MainFeature/Sources/Components/SchoolInfoCardView.swift b/Projects/Feature/MainFeature/Sources/Components/SchoolInfoCardView.swift index 04863150..14943ece 100644 --- a/Projects/Feature/MainFeature/Sources/Components/SchoolInfoCardView.swift +++ b/Projects/Feature/MainFeature/Sources/Components/SchoolInfoCardView.swift @@ -1,10 +1,12 @@ import ComposableArchitecture import DesignSystem +import FirebaseRemoteConfig import SwiftUI struct SchoolInfoCardView: View { let store: StoreOf @ObservedObject var viewStore: ViewStoreOf + @RemoteConfigProperty(key: "enable_weekly", fallback: false) private var enableWeeklyView init(store: StoreOf) { self.store = store @@ -20,9 +22,15 @@ struct SchoolInfoCardView: View { let gradeClassString = "\(viewStore.grade)학년 \(viewStore.class)반" let dateString = "\(viewStore.displayDate.toString())" - Text("\(gradeClassString) • \(dateString)") - .twFont(.body2, color: .textSecondary) - .accessibilitySortPriority(3) + if enableWeeklyView { + Text(gradeClassString) + .twFont(.body2, color: .textSecondary) + .accessibilitySortPriority(3) + } else { + Text("\(gradeClassString) • \(dateString)") + .twFont(.body2, color: .textSecondary) + .accessibilitySortPriority(3) + } } Spacer() diff --git a/Projects/Feature/MainFeature/Sources/DatePolicy.swift b/Projects/Feature/MainFeature/Sources/DatePolicy.swift index 5911a499..06a8b971 100644 --- a/Projects/Feature/MainFeature/Sources/DatePolicy.swift +++ b/Projects/Feature/MainFeature/Sources/DatePolicy.swift @@ -32,6 +32,29 @@ public struct DatePolicy: Sendable { return formatter.string(from: date) } + public func weekDisplayText(for weekStartDate: Date, baseDate: Date) -> String { + let calendar = Calendar.current + let currentWeekStart = startOfWeek(for: baseDate) + if calendar.isDate(weekStartDate, inSameDayAs: currentWeekStart) { + return "이번주" + } + + if let previousWeek = calendar.date(byAdding: .day, value: -7, to: currentWeekStart), + calendar.isDate(weekStartDate, inSameDayAs: previousWeek) { + return "저번주" + } + + if let nextWeek = calendar.date(byAdding: .day, value: 7, to: currentWeekStart), + calendar.isDate(weekStartDate, inSameDayAs: nextWeek) { + return "다음주" + } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_kr") + formatter.dateFormat = "M월 d일" + return "\(formatter.string(from: weekStartDate)) 주" + } + public func adjustedDate(for date: Date) -> Date { var adjustedDate = date @@ -79,4 +102,27 @@ public struct DatePolicy: Sendable { return nextDate } + + public func startOfWeek(for date: Date) -> Date { + let calendar = Calendar.current + + let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date) + let weekStart = calendar.date(from: components) ?? date + return weekStart + } + + public func adjustedWeekStart(for date: Date) -> Date { + let adjusted = adjustedDate(for: date) + return startOfWeek(for: adjusted) + } + + public func previousWeekStart(from date: Date) -> Date { + let weekStart = startOfWeek(for: date) + return weekStart.adding(by: .day, value: -7) + } + + public func nextWeekStart(from date: Date) -> Date { + let weekStart = startOfWeek(for: date) + return weekStart.adding(by: .day, value: 7) + } } diff --git a/Projects/Feature/MainFeature/Sources/MainCore.swift b/Projects/Feature/MainFeature/Sources/MainCore.swift index 6820f5d0..94c7e769 100644 --- a/Projects/Feature/MainFeature/Sources/MainCore.swift +++ b/Projects/Feature/MainFeature/Sources/MainCore.swift @@ -17,42 +17,56 @@ public struct MainCore: Reducer { public init() {} public struct State: Equatable { + public enum DateSelectionMode: Equatable { + case daily + case weekly + } + public var school = "" public var grade = "" public var `class` = "" @Shared public var displayDate: Date public var currentTab = 0 public var isInitial: Bool = true - public var isExistNewVersion: Bool = false public var mealCore: MealCore.State? + public var weeklyMealCore: WeeklyMealCore.State? public var timeTableCore: TimeTableCore.State? public var weeklyTimeTableCore: WeeklyTimeTableCore.State? @PresentationState public var settingsCore: SettingsCore.State? @PresentationState public var noticeCore: NoticeCore.State? public var isShowingReviewToast: Bool = false + public var dateSelectionMode: DateSelectionMode = .daily public var displayTitle: String { let calendar = Calendar.current let today = Date() - if calendar.isDate(displayDate, inSameDayAs: today) { - return "오늘뭐임" - } + switch dateSelectionMode { + case .daily: + if calendar.isDate(displayDate, inSameDayAs: today) { + return "오늘뭐임" + } - if let yesterday = calendar.date(byAdding: .day, value: -1, to: today), - calendar.isDate(displayDate, inSameDayAs: yesterday) { - return "어제뭐임" - } + if let yesterday = calendar.date(byAdding: .day, value: -1, to: today), + calendar.isDate(displayDate, inSameDayAs: yesterday) { + return "어제뭐임" + } - if let tomorrow = calendar.date(byAdding: .day, value: 1, to: today), - calendar.isDate(displayDate, inSameDayAs: tomorrow) { - return "내일뭐임" - } + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: today), + calendar.isDate(displayDate, inSameDayAs: tomorrow) { + return "내일뭐임" + } - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "ko_kr") - formatter.dateFormat = "EEEE" - return "\(formatter.string(from: displayDate))뭐임" + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_kr") + formatter.dateFormat = "EEEE" + return "\(formatter.string(from: displayDate))뭐임" + + case .weekly: + let policy = DatePolicy(isSkipWeekend: false, isSkipAfterDinner: false) + let label = policy.weekDisplayText(for: policy.startOfWeek(for: displayDate), baseDate: today) + return "\(label)뭐임" + } } public init() { @@ -67,10 +81,10 @@ public struct MainCore: Reducer { 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 checkVersion(TaskResult) case noticeButtonDidTap case settingsCore(PresentationAction) case noticeCore(PresentationAction) @@ -78,6 +92,7 @@ public struct MainCore: Reducer { case showReviewToast case hideReviewToast case requestReview + case weeklyModeUpdated(weeklyEnabled: Bool) } @Dependency(\.userDefaultsClient) var userDefaultsClient @@ -91,6 +106,25 @@ public struct MainCore: Reducer { let pageShowedEvengLog = PageShowedEventLog(pageName: "main_page") TWLog.event(pageShowedEvengLog) + 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 state.dateSelectionMode { + case .daily: + date = adjustedDate + case .weekly: + date = datePolicy.startOfWeek(for: adjustedDate) + } + } + if shouldRequestReview() { return .send(.showReviewToast) } @@ -105,7 +139,15 @@ public struct MainCore: Reducer { ) let today = Date() - state.$displayDate.withLock { date in date = datePolicy.adjustedDate(for: today) } + let adjustedDate = datePolicy.adjustedDate(for: today) + state.$displayDate.withLock { date in + switch state.dateSelectionMode { + case .daily: + date = adjustedDate + case .weekly: + date = datePolicy.startOfWeek(for: adjustedDate) + } + } state.school = userDefaultsClient.getValue(.school) as? String ?? "" state.grade = "\(userDefaultsClient.getValue(.grade) as? Int ?? 1)" @@ -113,22 +155,18 @@ public struct MainCore: Reducer { 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 Effect.run { send in - let checkVersion = await Action.checkVersion( - TaskResult { - try await iTunesClient.fetchCurrentVersion(.ios) - } - ) - await send(checkVersion) - } + return .none - case .mealCore(.refresh), .timeTableCore(.refresh): + case .mealCore(.refresh), .weeklyMealCore(.refresh), .timeTableCore(.refresh), .weeklyTimeTableCore(.refresh): let isSkipWeekend = userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false let isSkipAfterDinner = userDefaultsClient.getValue(.isSkipAfterDinner) as? Bool ?? true @@ -137,9 +175,6 @@ public struct MainCore: Reducer { isSkipAfterDinner: isSkipAfterDinner ) - let today = Date() - state.$displayDate.withLock { date in date = datePolicy.adjustedDate(for: today) } - case let .tabTapped(tab): state.currentTab = tab logTabSelected(index: tab, selectionType: .tapped) @@ -148,7 +183,7 @@ public struct MainCore: Reducer { state.currentTab = tab logTabSelected(index: tab, selectionType: .swiped) - case .settingButtonDidTap, .mealCore(.settingsButtonDidTap): + case .settingButtonDidTap, .mealCore(.settingsButtonDidTap), .weeklyMealCore(.settingsButtonDidTap): state.settingsCore = .init() let log = SettingButtonClickedEventLog() TWLog.event(log) @@ -162,11 +197,6 @@ public struct MainCore: Reducer { case .settingsCore(.presented(.schoolSettingCore(.presented(.schoolSettingFinished)))): state.settingsCore = nil - case let .checkVersion(.success(latestVersion)): - guard !latestVersion.isEmpty else { break } - let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" - state.isExistNewVersion = currentVersion != latestVersion - case .noticeButtonDidTap: state.noticeCore = .init() let log = BellButtonClickedEventLog() @@ -195,6 +225,30 @@ 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 } @@ -231,6 +285,9 @@ extension Reducer where State == MainCore.State, Action == MainCore.Action { .ifLet(\.mealCore, action: /Action.mealCore) { MealCore() } + .ifLet(\.weeklyMealCore, action: /Action.weeklyMealCore) { + WeeklyMealCore() + } .ifLet(\.timeTableCore, action: /Action.timeTableCore) { TimeTableCore() } diff --git a/Projects/Feature/MainFeature/Sources/MainView.swift b/Projects/Feature/MainFeature/Sources/MainView.swift index cd74259c..871dc956 100644 --- a/Projects/Feature/MainFeature/Sources/MainView.swift +++ b/Projects/Feature/MainFeature/Sources/MainView.swift @@ -15,7 +15,7 @@ public struct MainView: View { @Environment(\.openURL) var openURL @Environment(\.calendar) var calendar @Dependency(\.userDefaultsClient) var userDefaultsClient - @RemoteConfigProperty(key: "enable_weekly_time_table", fallback: false) private var enableWeeklyTimeTable + @RemoteConfigProperty(key: "enable_weekly", fallback: false) private var enableWeeklyView public init(store: StoreOf) { self.store = store @@ -51,16 +51,27 @@ public struct MainView: View { ).animation(.default) ) { VStack { - IfLetStore( - store.scope(state: \.mealCore, action: MainCore.Action.mealCore) - ) { store in - MealView(store: store) + 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) + } } } .tag(0) VStack { - if enableWeeklyTimeTable { + if enableWeeklyView { IfLetStore( store.scope( state: \.weeklyTimeTableCore, @@ -80,6 +91,12 @@ public struct MainView: View { .tag(1) } .tabViewStyle(.page(indexDisplayMode: .never)) + .background { + if enableWeeklyView { + Color.backgroundSecondary + .ignoresSafeArea() + } + } if viewStore.isShowingReviewToast { ReviewToast { @@ -88,69 +105,110 @@ public struct MainView: View { } .frame(maxWidth: .infinity) .padding(.horizontal, 16) - .padding(.bottom, viewStore.isExistNewVersion ? 72 : 16) + .padding(.bottom, 24) .animation(.default, value: viewStore.isShowingReviewToast) .transition(.move(edge: .bottom).combined(with: .opacity)) .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - viewStore.send(.hideReviewToast) + DispatchQueue.main.asyncAfter(deadline: .now() + 7.5) { + viewStore.send(.hideReviewToast, animation: .default) } } } - - if viewStore.isExistNewVersion { - Button { - let url = URL( - string: "https://apps.apple.com/app/id1629567018" - ) ?? URL(string: "https://google.com")! - openURL(url) - } label: { - Circle() - .frame(width: 56, height: 56) - .foregroundColor(.extraBlack) - .overlay { - Image(systemName: "arrow.down.to.line") - .foregroundColor(.extraWhite) - .accessibilityHidden(true) - } - } - .padding([.bottom, .trailing], 16) - .accessibilityLabel("새 버전 업데이트") - .accessibilityHint("앱스토어로 이동하여 새 버전을 설치할 수 있습니다") - } } } .background(Color.backgroundMain) .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItem(placement: .principal) { + Text("") + } + + 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() - 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 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 + } - 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(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) + } } + .accessibilityLabel("\(datePolicy.weekDisplayText(for: normalizedWeekStart, baseDate: today)) 선택") + .accessibilityHint("주 변경") + } + } else { + let yesterday = datePolicy.previousDay(from: today) + let tomorrow = datePolicy.nextDay(from: today) - TWLog.event(SelectDateTenseEventLog(tense: tense)) + 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 + } - _ = viewStore.send(.dateSelected(date)) - } label: { - if calendar.isDate(viewStore.displayDate, inSameDayAs: date) { - Label { + 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)) .twFont(.body1) .foregroundStyle( @@ -159,24 +217,11 @@ public struct MainView: View { : Color.extraBlack ) .animation(.easeInOut(duration: 0.2), value: viewStore.displayDate) - } icon: { - Image(systemName: "checkmark") } - - - } else { - 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) } + .accessibilityLabel("\(datePolicy.displayText(for: date, baseDate: today)) 선택") + .accessibilityHint("날짜 변경") } - .accessibilityLabel("\(datePolicy.displayText(for: date, baseDate: today)) 선택") - .accessibilityHint("날짜 변경") } } label: { HStack(spacing: 0) { @@ -194,7 +239,7 @@ public struct MainView: View { .accessibilityHint("클릭하여 날짜를 선택할 수 있습니다") } - ToolbarItemGroup(placement: .navigationBarTrailing) { + ToolbarItemGroup(placement: .topBarTrailing) { Button { viewStore.send(.noticeButtonDidTap) } label: { @@ -218,16 +263,21 @@ public struct MainView: View { } .onAppear { viewStore.send(.onAppear, animation: .default) + viewStore.send(.weeklyModeUpdated(weeklyEnabled: enableWeeklyView)) } - .onChange(of: enableWeeklyTimeTable, perform: { _ in - TWLog.setUserProperty(property: .enableWeeklyTimeTable, value: enableWeeklyTimeTable.description) + .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 } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("급식 & 시간표") } .navigationViewStyle(.stack) } diff --git a/Projects/Feature/MealFeature/Sources/MealCore.swift b/Projects/Feature/MealFeature/Sources/Daily/MealCore.swift similarity index 100% rename from Projects/Feature/MealFeature/Sources/MealCore.swift rename to Projects/Feature/MealFeature/Sources/Daily/MealCore.swift diff --git a/Projects/Feature/MealFeature/Sources/MealView.swift b/Projects/Feature/MealFeature/Sources/Daily/MealView.swift similarity index 81% rename from Projects/Feature/MealFeature/Sources/MealView.swift rename to Projects/Feature/MealFeature/Sources/Daily/MealView.swift index 4b7e62b7..60a1bdde 100644 --- a/Projects/Feature/MealFeature/Sources/MealView.swift +++ b/Projects/Feature/MealFeature/Sources/Daily/MealView.swift @@ -4,6 +4,12 @@ import Entity import EnumUtil import SwiftUI import UserDefaultsClient +#if canImport(UIKit) +import UIKit +#endif +#if canImport(AppKit) +import AppKit +#endif public struct MealView: View { let store: StoreOf @@ -82,15 +88,19 @@ public struct MealView: View { LazyVStack { ForEach(subMeal.meals, id: \.hashValue) { meal in + let display = mealDisplay(meal: meal) + let containsAllergy = isMealContainsAllergy(meal: meal) + HStack { - Text(mealDisplay(meal: meal)) - .twFont(.headline4, color: .textPrimary) + Text(display) + .twFont(.headline4, color: containsAllergy ? .point : .textPrimary) Spacer() - if isMealContainsAllergy(meal: meal) { + if containsAllergy { Image.allergy - .renderingMode(.original) + .renderingMode(.template) + .foregroundColor(.point) .accessibilityHidden(true) } } @@ -103,9 +113,13 @@ public struct MealView: View { } .padding(.horizontal, 16) .accessibilityElement(children: .combine) - .accessibilityLabel("\(mealDisplay(meal: meal))") - .accessibilityHint(isMealContainsAllergy(meal: meal) ? "알레르기 유발 식품이 포함되어 있습니다" : "") + .accessibilityLabel("\(display)") + .accessibilityHint(containsAllergy ? "알레르기 유발 식품이 포함되어 있습니다" : "") .accessibilitySortPriority(2) + .contentShape(Rectangle()) + .onLongPressGesture { + copyMealToClipboard(display) + } } } .padding(.bottom, 24) @@ -128,3 +142,12 @@ private extension Meal { self.dinner.meals.isEmpty } } + +private func copyMealToClipboard(_ text: String) { +#if canImport(UIKit) + UIPasteboard.general.string = text +#elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) +#endif +} diff --git a/Projects/Feature/MealFeature/Sources/EventLog/ShareMealEventLog.swift b/Projects/Feature/MealFeature/Sources/EventLog/ShareMealEventLog.swift new file mode 100644 index 00000000..c08319d0 --- /dev/null +++ b/Projects/Feature/MealFeature/Sources/EventLog/ShareMealEventLog.swift @@ -0,0 +1,6 @@ +import TWLog + +struct ShareMealEventLog: EventLog { + let name: String = "share_meal" + let params: [String: String] = [:] +} diff --git a/Projects/Feature/MealFeature/Sources/EventLog/ShareMealImageEventLog.swift b/Projects/Feature/MealFeature/Sources/EventLog/ShareMealImageEventLog.swift new file mode 100644 index 00000000..332e685b --- /dev/null +++ b/Projects/Feature/MealFeature/Sources/EventLog/ShareMealImageEventLog.swift @@ -0,0 +1,6 @@ +import TWLog + +struct ShareMealImageEventLog: EventLog { + let name: String = "share_meal_image" + let params: [String: String] = [:] +} diff --git a/Projects/Feature/MealFeature/Sources/EventLog/TapTodayMealEventLog.swift b/Projects/Feature/MealFeature/Sources/EventLog/TapTodayMealEventLog.swift new file mode 100644 index 00000000..f53f1a31 --- /dev/null +++ b/Projects/Feature/MealFeature/Sources/EventLog/TapTodayMealEventLog.swift @@ -0,0 +1,6 @@ +import TWLog + +struct TapTodayMealEventLog: EventLog { + let name: String = "tap_today_meal" + let params: [String: String] = [:] +} diff --git a/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealCore.swift b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealCore.swift new file mode 100644 index 00000000..933f93fa --- /dev/null +++ b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealCore.swift @@ -0,0 +1,156 @@ +import ComposableArchitecture +import Entity +import EnumUtil +import Foundation +import LocalDatabaseClient +import MealClient +import Sharing +import UserDefaultsClient + +public struct WeeklyMealCore: Reducer { + private enum CancellableID: Hashable { + case fetch + } + + public init() {} + + public struct State: Equatable { + public struct DayMeal: Equatable { + public let date: Date + public let meal: Meal + + public init(date: Date, meal: Meal) { + self.date = date + self.meal = meal + } + + public var isEmpty: Bool { + meal.breakfast.meals.isEmpty && + meal.lunch.meals.isEmpty && + meal.dinner.meals.isEmpty + } + } + + public var weeklyMeals: [DayMeal] = [] + public var isLoading = false + public var allergyList: [AllergyType] = [] + public var showWeekend = false + public var currentTimeMealType: MealType = .breakfast + public var today: Date = Date() + @Shared public var displayDate: Date + + public init(displayDate: Shared) { + self._displayDate = displayDate + } + } + + public enum Action: Equatable { + case onLoad + case onAppear + case refresh + case refreshData + case settingsButtonDidTap + case mealsResponse(TaskResult<[Date: Meal]>) + } + + @Dependency(\.mealClient) var mealClient + @Dependency(\.localDatabaseClient) var localDatabaseClient + @Dependency(\.userDefaultsClient) var userDefaultsClient + @Dependency(\.date) var dateGenerator + + public var body: some ReducerOf { + 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 } + } + ) + + case .onAppear: + 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 fetchWeeklyMeals(displayDate: displayDate, showWeekend: showWeekend) + + case .refresh: + return .send(.refreshData) + + case .refreshData: + state.showWeekend = !(userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false) + state.isLoading = true + + let displayDate = state.displayDate + let showWeekend = state.showWeekend + + return fetchWeeklyMeals(displayDate: displayDate, showWeekend: showWeekend) + + case let .mealsResponse(.success(mealDictionary)): + state.isLoading = false + + let calendar = Calendar.current + let sortedEntries = mealDictionary + .map { (calendar.startOfDay(for: $0.key), $0.value) } + .sorted { $0.0 < $1.0 } + state.weeklyMeals = sortedEntries.map { entry in + State.DayMeal(date: entry.0, meal: entry.1) + } + + let now = dateGenerator.now + state.today = now + let isSkipWeekend = userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false + state.currentTimeMealType = MealType(hour: now, isSkipWeekend: isSkipWeekend) + + case .mealsResponse(.failure): + state.isLoading = false + state.weeklyMeals = [] + + case .settingsButtonDidTap: + break + } + return .none + } + } + + private func fetchWeeklyMeals(displayDate: Date, showWeekend: Bool) -> Effect { + let calendar = Calendar.current + let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: displayDate) + let mondayDate = calendar.date(from: components) ?? displayDate + let dayCount = showWeekend ? 7 : 5 + + return .concatenate( + .cancel(id: CancellableID.fetch), + .run { [mondayDate, dayCount] send in + let response = await Action.mealsResponse( + TaskResult { + try await mealClient.fetchMeals(mondayDate, dayCount) + } + ) + await send(response) + } + .cancellable(id: CancellableID.fetch, cancelInFlight: true) + ) + } +} diff --git a/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift new file mode 100644 index 00000000..0cbb5330 --- /dev/null +++ b/Projects/Feature/MealFeature/Sources/Weekly/WeeklyMealView.swift @@ -0,0 +1,399 @@ +import ComposableArchitecture +import DesignSystem +import Entity +import EnumUtil +import SwiftUI +import TWLog +import UIKit + +public struct WeeklyMealView: View { + let store: StoreOf + @ObservedObject var viewStore: ViewStoreOf + @Environment(\.calendar) private var calendar + @Environment(\.displayScale) var displayScale + @State private var shouldShowTodayButton = false + @State private var todayButtonDirection: TodayButtonDirection = .up + + public init(store: StoreOf) { + self.store = store + self.viewStore = ViewStore(store, observe: { $0 }) + } + + public var body: some View { + GeometryReader { outerGeometry in + ScrollViewReader { proxy in + ZStack(alignment: .bottomTrailing) { + ScrollView { + ZStack(alignment: .top) { + if (viewStore.weeklyMeals.isEmpty || viewStore.weeklyMeals.allSatisfy { $0.isEmpty }) && !viewStore.isLoading { + emptyStateView + } else { + LazyVStack(spacing: 40) { + ForEach(viewStore.weeklyMeals, id: \.date) { dayMeal in + daySection(dayMeal: dayMeal) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: DayFramePreferenceKey.self, + value: [ + calendar.startOfDay(for: dayMeal.date): geometry.frame(in: .global) + ] + ) + } + ) + .id(dayMeal.date) + } + + Spacer() + .frame(height: 64) + } + .padding(.top, 16) + } + + if viewStore.isLoading { + ProgressView() + .progressViewStyle(.automatic) + .padding(.top, 16) + .accessibilityLabel("이번 주 급식 정보를 불러오는 중입니다") + .accessibilitySortPriority(1) + } + } + } + .coordinateSpace(name: "WeeklyMealScroll") + .onLoad { + viewStore.send(.onLoad) + } + .onAppear { + viewStore.send(.onAppear, animation: .default) + scrollToToday(proxy: proxy) + } + .onChange(of: viewStore.weeklyMeals) { _ in + scrollToToday(proxy: proxy) + } + .refreshable { + viewStore.send(.refresh, animation: .default) + } + .onPreferenceChange(DayFramePreferenceKey.self) { frames in + let containerFrame = outerGeometry.frame(in: .global) + updateTodayButtonVisibility(containerFrame: containerFrame, dayFrames: frames) + } + + if shouldShowTodayButton { + todayButton(direction: todayButtonDirection) { + scrollToToday(proxy: proxy) + } + .padding(.trailing, 16) + .padding(.bottom, 16) + .transition( + .opacity.combined(with: .move(edge: .trailing)) + ) + .zIndex(1) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + } + + @ViewBuilder + private var emptyStateView: some View { + VStack(spacing: 14) { + Spacer() + + Text("이번 주 급식을 찾을 수 없어요!") + .twFont(.body1, color: .textSecondary) + .padding(.top, 16) + .foregroundColor(.textSecondary) + .accessibilityLabel("급식을 찾을 수 없습니다") + .accessibilitySortPriority(1) + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .center) + } + + @ViewBuilder + private func daySection(dayMeal: WeeklyMealCore.State.DayMeal) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(dayMeal.date, format: .dateTime.month().day().weekday(.wide)) + .twFont(.body3, color: .textSecondary) + .padding(.horizontal, 28) + + if dayMeal.isEmpty { + Text("급식 없음") + .twFont(.body1, color: .textSecondary) + .padding(.vertical, 16) + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.backgroundMain) + } + .padding(.horizontal, 16) + } else { + ForEach([MealType.breakfast, .lunch, .dinner], id: \.hashValue) { type in + let subMeal = dayMeal.meal.mealByType(type: type) + if !subMeal.meals.isEmpty { + mealCard(dayMeal: dayMeal, type: type, subMeal: subMeal) + } + } + } + } + } + + @ViewBuilder + private func mealCard( + dayMeal: WeeklyMealCore.State.DayMeal, + type: MealType, + subMeal: Meal.SubMeal + ) -> some View { + let isToday = calendar.isDate(dayMeal.date, inSameDayAs: viewStore.today) + let isHighlighted = isToday && viewStore.currentTimeMealType == type + + let mealCardView = VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + Text(relativeTitle(for: dayMeal.date, mealType: type)) + .twFont(.headline4, color: .textPrimary) + + Spacer() + + Text("\(String(format: "%.1f", subMeal.cal)) Kcal") + .twFont(.body2, color: .unselectedPrimary) + } + + Divider() + .foregroundStyle(Color.unselectedSecondary) + + VStack(alignment: .leading, spacing: 14) { + ForEach(subMeal.meals, id: \.hashValue) { meal in + let mealText = mealDisplay(meal: meal) + let containsAllergy = isMealContainsAllergy(meal: meal) + + HStack(alignment: .center, spacing: 8) { + Text(mealText) + .twFont(.headline4, color: containsAllergy ? .point : .textPrimary) + + if containsAllergy { + Image.allergy + .renderingMode(.template) + .foregroundStyle(Color.point) + .accessibilityHidden(true) + } + + Spacer() + } + .accessibilityElement(children: .combine) + .accessibilityLabel(mealText) + .accessibilityHint( + containsAllergy + ? "알레르기 유발 식품이 포함되어 있습니다" + : "" + ) + .contentShape(Rectangle()) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.backgroundMain) + } + + mealCardView + .accessibilityElement(children: .combine) + .accessibilityLabel("\(relativeTitle(for: dayMeal.date, mealType: type)) \(String(format: "%.1f", subMeal.cal)) 칼로리") + .contextMenu { + Button { + let mealToCopy = subMeal.meals + .map { mealDisplay(meal: $0) } + .joined(separator: "\n") + let dateText = "\(dayMeal.date.formatted(.dateTime.month().day().weekday(.wide))) \(type.display)" + UIPasteboard.general.string = "\(dateText)\n\(mealToCopy)" + TWLog.event(ShareMealEventLog()) + } label: { + Label("복사하기", systemImage: "doc.on.doc") + } + + if #available(iOS 16.0, *) { + Button { + let renderer = ImageRenderer(content: mealCardView) + renderer.scale = displayScale + if let image = renderer.uiImage { + UIPasteboard.general.image = image + TWLog.event(ShareMealImageEventLog()) + } + } label: { + Label("이미지로 복사하기", systemImage: "photo") + } + } + } + .onDrag { + let mealToDrag = subMeal.meals + .map { mealDisplay(meal: $0) } + .joined(separator: "\n") + let dateText = "\(dayMeal.date.formatted(.dateTime.month().day().weekday(.wide))) \(type.display)" + return NSItemProvider(object: "\(dateText)\n\(mealToDrag)" as NSString) + } + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(isHighlighted ? Color.extraBlack : Color.clear, lineWidth: 2) + } + .padding(.horizontal, 16) + } + + private func mealDisplay(meal: String) -> String { + meal.replacingOccurrences(of: "[0-9.() ]", with: "", options: [.regularExpression]) + } + + private func isMealContainsAllergy(meal: String) -> Bool { + viewStore.allergyList.first { + meal.contains("(\($0.number)") || meal.contains(".\($0.number)") + } != nil + } + + private func relativeTitle(for date: Date, mealType: MealType) -> String { + let today = calendar.startOfDay(for: viewStore.today) + let target = calendar.startOfDay(for: date) + + let components = calendar.dateComponents([.day], from: today, to: target) + let dayDifference = components.day ?? 0 + + let dayPrefix: String + switch dayDifference { + case 0: + dayPrefix = "오늘" + case 1: + dayPrefix = "내일" + case -1: + dayPrefix = "어제" + default: + dayPrefix = date.formatted(.dateTime.weekday(.wide)) + } + + return "\(dayPrefix) \(mealType.display)" + } + + @ViewBuilder + private func todayButton(direction: TodayButtonDirection, action: @escaping () -> Void) -> some View { + let baseButton = Button(action: action) { + HStack(spacing: 6) { + Image(systemName: direction.systemName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.extraWhite) + + Text("오늘") + .twFont(.headline3, color: .extraWhite) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .accessibilityLabel("오늘 급식으로 이동") + .accessibilityHint("스크롤해서 오늘 날짜 섹션을 보여줍니다") + + if #available(iOS 26.0, *) { + baseButton + .glassEffect(.regular.tint(Color.extraBlack).interactive(), in: .capsule) + } else { + baseButton + .background { + Capsule() + .fill(Color.extraBlack) + } + } + } + + private func updateTodayButtonVisibility(containerFrame: CGRect, dayFrames: [Date: CGRect]) { + guard containerFrame != .zero else { + withAnimation(.easeInOut(duration: 0.2)) { + shouldShowTodayButton = false + } + return + } + + let hasToday = viewStore.weeklyMeals.contains { + calendar.isDate($0.date, inSameDayAs: viewStore.today) + } + guard hasToday else { + withAnimation(.easeInOut(duration: 0.2)) { + shouldShowTodayButton = false + } + return + } + + let todayKey = calendar.startOfDay(for: viewStore.today) + if let todayFrame = dayFrames[todayKey], !todayFrame.isNull { + let isTodayVisible = todayFrame.intersects(containerFrame) + + if isTodayVisible { + withAnimation(.easeInOut(duration: 0.2)) { + shouldShowTodayButton = false + } + } else { + let direction: TodayButtonDirection = todayFrame.maxY < containerFrame.minY ? .up : .down + withAnimation(.easeInOut(duration: 0.2)) { + todayButtonDirection = direction + shouldShowTodayButton = true + } + } + return + } + + let visibleDates = dayFrames.keys.sorted() + guard let firstVisible = visibleDates.first, + let lastVisible = visibleDates.last else { + withAnimation(.easeInOut(duration: 0.2)) { + shouldShowTodayButton = false + } + return + } + + if calendar.compare(lastVisible, to: todayKey, toGranularity: .day) == .orderedAscending { + withAnimation(.easeInOut(duration: 0.2)) { + todayButtonDirection = .down + shouldShowTodayButton = true + } + } else if calendar.compare(firstVisible, to: todayKey, toGranularity: .day) == .orderedDescending { + withAnimation(.easeInOut(duration: 0.2)) { + todayButtonDirection = .up + shouldShowTodayButton = true + } + } else { + withAnimation(.easeInOut(duration: 0.2)) { + shouldShowTodayButton = false + } + } + } + + private func scrollToToday(proxy: ScrollViewProxy) { + guard let todayMeal = viewStore.weeklyMeals.first(where: { + calendar.isDate($0.date, inSameDayAs: viewStore.today) + }) else { return } + + withAnimation(.easeInOut(duration: 0.4)) { + proxy.scrollTo(todayMeal.date, anchor: .top) + } + } +} + +private struct DayFramePreferenceKey: PreferenceKey { + static var defaultValue: [Date: CGRect] { [:] } + + static func reduce(value: inout [Date: CGRect], nextValue: () -> [Date: CGRect]) { + value.merge(nextValue()) { _, new in new } + } +} + +private enum TodayButtonDirection { + case up + case down + + var systemName: String { + switch self { + case .up: + return "arrow.up" + case .down: + return "arrow.down" + } + } +} diff --git a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift index e62896f2..5e247286 100644 --- a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift +++ b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift @@ -87,23 +87,27 @@ public struct WeeklyTimeTableCore: Reducer { Reduce { state, action in switch action { case .onLoad: - return .publisher { - state.$displayDate.publisher - .map { _ in Action.refreshData } - } + return .merge( + .send(.onAppear), + .publisher { + state.$displayDate.publisher + .map { _ in Action.refreshData } + } + ) case .onAppear, .refreshData: state.isLoading = true state.showWeekend = !(userDefaultsClient.getValue(.isSkipWeekend) as? Bool ?? false) - let baseDate = state.displayDate let calendar = Calendar.current - let weekday = calendar.component(.weekday, from: baseDate) + let baseDate = state.displayDate + let normalizedBaseDate = calendar.startOfDay(for: baseDate) + let weekday = calendar.component(.weekday, from: normalizedBaseDate) let daysFromMonday = (weekday + 5) % 7 let mondayDate = - calendar.date(byAdding: .day, value: -daysFromMonday, to: baseDate) - ?? baseDate + calendar.date(byAdding: .day, value: -daysFromMonday, to: normalizedBaseDate) + ?? normalizedBaseDate return .concatenate( .cancel(id: CancellableID.fetch), @@ -138,38 +142,46 @@ public struct WeeklyTimeTableCore: Reducer { } } - private func fetchWeeklyTimeTable(mondayDate: Date, showWeekend: Bool) - async throws -> WeeklyTimeTable { + private func fetchWeeklyTimeTable(mondayDate: Date, showWeekend: Bool) async throws -> WeeklyTimeTable { let calendar = Calendar.current let dayCount = showWeekend ? 7 : 5 var weeklyData: [Int: [TimeTable]] = [:] - - for i in 0.. Date in + let targetDate = table.date ?? mondayDate + return calendar.startOfDay(for: targetDate) + } + ) + + let modifiedRecords = try? localDatabaseClient.readRecords(as: ModifiedTimeTableLocalEntity.self) + + for i in 0.. String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.replacingOccurrences( + of: #"^\*+\s*"#, + with: "", + options: [.regularExpression] + ) + } } diff --git a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift index 4157de28..9b57ed01 100644 --- a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift +++ b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift @@ -60,9 +60,9 @@ public struct WeeklyTimeTableView: View { } public var body: some View { - ScrollView(.vertical) { - VStack { - if viewStore.weeklyTimeTable == nil && !viewStore.isLoading { + ScrollView(.vertical, showsIndicators: false) { + if viewStore.weeklyTimeTable == nil && !viewStore.isLoading { + VStack { Text("이번 주 시간표를 찾을 수 없어요!") .padding(.top, 16) .foregroundColor(.textSecondary) @@ -77,29 +77,30 @@ public struct WeeklyTimeTableView: View { .accessibilityLabel("학기 초에는 정규시간표가 등록되어 있지 않을 수 있습니다") .accessibilitySortPriority(2) } - } else { - ZStack(alignment: .top) { - if viewStore.isLoading { - ProgressView() - .progressViewStyle(.automatic) - .padding(.top, 16) - .accessibilityLabel("시간표를 불러오는 중입니다") - .accessibilitySortPriority(1) - } + } + } else { + ZStack(alignment: .top) { + if viewStore.isLoading { + ProgressView() + .progressViewStyle(.automatic) + .padding(.top, 16) + .accessibilityLabel("시간표를 불러오는 중입니다") + .accessibilitySortPriority(1) + } - if let weeklyTimeTable = viewStore.weeklyTimeTable { - if shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable) { + if let weeklyTimeTable = viewStore.weeklyTimeTable { + if shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable) { + timeTableGrid(weeklyTimeTable: weeklyTimeTable) + .frame(alignment: .top) + } else { + ScrollView(.horizontal, showsIndicators: false) { timeTableGrid(weeklyTimeTable: weeklyTimeTable) - .frame(alignment: .top) - } else { - ScrollView(.horizontal) { - timeTableGrid(weeklyTimeTable: weeklyTimeTable) - .frame(alignment: .top) - } } + .frame(alignment: .top) } } } + .frame(minHeight: 560, alignment: .top) } } .onLoad { @@ -115,14 +116,78 @@ public struct WeeklyTimeTableView: View { @ViewBuilder private func timeTableGrid(weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable) -> some View { + let headerHeight: CGFloat = 40 + let firstColumnWidth: CGFloat = 32 + let columnCount = max(weeklyTimeTable.weekdays.count, 1) + let periodCount = CGFloat(weeklyTimeTable.periods.count) + + if shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable) { + GeometryReader { geometry in + let availableWidth = max(geometry.size.width - firstColumnWidth, 0) + let fallbackWidth = columnWidth(weeklyTimeTable: weeklyTimeTable) + let rawCellSide = availableWidth / CGFloat(columnCount) + let cellSide = rawCellSide > 0 ? rawCellSide : fallbackWidth + + timeTableContent( + weeklyTimeTable: weeklyTimeTable, + cellSide: cellSide, + headerHeight: headerHeight, + firstColumnWidth: firstColumnWidth + ) + .frame( + width: firstColumnWidth + cellSide * CGFloat(columnCount), + alignment: .topLeading + ) + .frame( + height: headerHeight + cellSide * periodCount, + alignment: .top + ) + } + } else { + let cellSide = columnWidth(weeklyTimeTable: weeklyTimeTable) + + timeTableContent( + weeklyTimeTable: weeklyTimeTable, + cellSide: cellSide, + headerHeight: headerHeight, + firstColumnWidth: firstColumnWidth + ) + .frame( + width: firstColumnWidth + cellSide * CGFloat(columnCount), + alignment: .topLeading + ) + .frame( + height: headerHeight + cellSide * periodCount, + alignment: .top + ) + } + } + + @ViewBuilder + private func timeTableContent( + weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable, + cellSide: CGFloat, + headerHeight: CGFloat, + firstColumnWidth: CGFloat + ) -> some View { VStack(spacing: 0) { - headerRow(weeklyTimeTable: weeklyTimeTable) + headerRow( + weeklyTimeTable: weeklyTimeTable, + cellWidth: cellSide, + headerHeight: headerHeight, + firstColumnWidth: firstColumnWidth + ) ForEach(weeklyTimeTable.periods, id: \.self) { period in - timeTableRow(period: period, weeklyTimeTable: weeklyTimeTable) + timeTableRow( + period: period, + periodCount: weeklyTimeTable.periods.count, + weeklyTimeTable: weeklyTimeTable, + cellSide: cellSide, + firstColumnWidth: firstColumnWidth + ) } } - .frame(maxWidth: .infinity) .background(Color.cardBackground) .overlay { if #available(iOS 16.0, *) { @@ -130,59 +195,42 @@ public struct WeeklyTimeTableView: View { borderWidth: 1, cornerRadius: 16, selectedColumnIndex: weeklyTimeTable.todayIndex, - headerHeight: 44, - firstColumnWidth: 40, + headerHeight: headerHeight, + firstColumnWidth: firstColumnWidth, columnWidth: columnWidth(weeklyTimeTable: weeklyTimeTable), useFlexibleWidth: shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable), columnCount: weeklyTimeTable.weekdays.count, - cellHeight: 56, + cellHeight: cellSide, selectedColumnPeriodCount: { - if let todayIndex = weeklyTimeTable - .todayIndex { weeklyTimeTable.actualPeriodCount(for: todayIndex) + if let todayIndex = weeklyTimeTable.todayIndex { + weeklyTimeTable.actualPeriodCount(for: todayIndex) } else { 0 } }() ) - .stroke(Color.extraBlack.opacity(0.8), lineWidth: 2) + .stroke(Color.extraBlack, lineWidth: 1) } } } @ViewBuilder - private func headerRow(weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable) -> some View { + private func headerRow( + weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable, + cellWidth: CGFloat, + headerHeight: CGFloat, + firstColumnWidth: CGFloat + ) -> some View { HStack(spacing: 0) { Text("") - .frame(width: 40, height: 44) - .background(Color.unselectedSecondary.opacity(0.1)) + .frame(width: firstColumnWidth, height: headerHeight) ForEach(0.. CGFloat? in - return shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable) - ? nil - : columnWidth(weeklyTimeTable: weeklyTimeTable) - }() - ) - .background( - weeklyTimeTable.isToday(weekdayIndex: index) - ? Color.unselectedSecondary.opacity(0.2) - : Color.unselectedSecondary.opacity(0.1) - ) - .overlay( - Rectangle() - .fill(Color.unselectedSecondary.opacity(0.3)) - .frame(height: 0.5), - alignment: .bottom - ) + .frame(width: cellWidth, height: headerHeight) } } } @@ -190,13 +238,15 @@ public struct WeeklyTimeTableView: View { @ViewBuilder private func timeTableRow( period: Int, - weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable + periodCount: Int, + weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable, + cellSide: CGFloat, + firstColumnWidth: CGFloat ) -> some View { HStack(spacing: 0) { Text("\(period)") - .twFont(horizontalSizeClass == .regular ? .body2 : .body3, color: .textPrimary) - .frame(width: 40, height: 56) - .background(Color.unselectedSecondary.opacity(0.05)) + .twFont(.body2, color: .textSecondary) + .frame(width: firstColumnWidth, height: cellSide) ForEach(0.. CGFloat? in - shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable) - ? nil - : columnWidth(weeklyTimeTable: weeklyTimeTable) - }() - ) + .minimumScaleFactor(0.3) + .padding(8) + .frame(width: cellSide, height: cellSide) .background(Color.extraWhite) - .overlay( - Rectangle() - .fill(Color.unselectedSecondary.opacity(0.3)) - .frame(height: 0.5), - alignment: .bottom - ) + .overlay(alignment: .bottom) { + if period != periodCount { + Rectangle() + .fill(Color.unselectedSecondary) + .frame(height: 1.0) + } + } } } } @@ -241,14 +283,14 @@ public struct WeeklyTimeTableView: View { } private func columnWidth(weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable) -> CGFloat { - return viewStore.showWeekend ? 55.0 : 65.5 + return viewStore.showWeekend ? 64.0 : 65.5 } private func fontForCell(isToday: Bool, horizontalSizeClass: UserInterfaceSizeClass?) -> Font.TWFontSystem { if horizontalSizeClass == .regular { return isToday ? .body1 : .body2 } else { - return isToday ? .body3 : .body2 + return isToday ? .caption1 : .caption1 } } } diff --git a/Projects/Shared/Entity/Sources/TimeTable.swift b/Projects/Shared/Entity/Sources/TimeTable.swift index fcddae5a..48f42b51 100644 --- a/Projects/Shared/Entity/Sources/TimeTable.swift +++ b/Projects/Shared/Entity/Sources/TimeTable.swift @@ -3,9 +3,11 @@ import Foundation public struct TimeTable: Equatable, Hashable { public let perio: Int public let content: String + public let date: Date? - public init(perio: Int, content: String) { + public init(perio: Int, content: String, date: Date? = nil) { self.perio = perio self.content = content + self.date = date } } diff --git a/Projects/Shared/FeatureFlagClient/Sources/FeatureFlagClient.swift b/Projects/Shared/FeatureFlagClient/Sources/FeatureFlagClient.swift index 942d9b9e..5b3e12ba 100644 --- a/Projects/Shared/FeatureFlagClient/Sources/FeatureFlagClient.swift +++ b/Projects/Shared/FeatureFlagClient/Sources/FeatureFlagClient.swift @@ -4,8 +4,10 @@ import FirebaseWrapper public enum FeatureFlagKey: String, Sendable { case reviewText = "review_text" + case enableWeeklyView = "enable_weekly_view" } +@available(*, deprecated, message: "deprecated") public struct FeatureFlagClient: Sendable { public var getString: @Sendable (FeatureFlagKey) -> String? public var getBool: @Sendable (FeatureFlagKey) -> Bool @@ -18,13 +20,10 @@ public struct FeatureFlagClient: Sendable { getNumber: @Sendable @escaping (FeatureFlagKey) -> NSNumber, getDictionary: @Sendable @escaping (FeatureFlagKey) -> [String: Any]? ) { - do { - try RemoteConfig.remoteConfig().setDefaults(from: [ - FeatureFlagKey.reviewText.rawValue: "오늘뭐임을 더 발전시킬 수 있게 리뷰 부탁드려요!" - ]) - } catch { - print(error) - } + RemoteConfig.remoteConfig().setDefaults([ + FeatureFlagKey.reviewText.rawValue: "오늘뭐임을 더 발전시킬 수 있게 리뷰 부탁드려요!" as NSString, + FeatureFlagKey.enableWeeklyView.rawValue: false as NSNumber + ] as [String: NSObject]) self.getString = getString self.getBool = getBool self.getNumber = getNumber diff --git a/Projects/Shared/MealClient/Sources/MealClient.swift b/Projects/Shared/MealClient/Sources/MealClient.swift index 27498063..30011e8f 100644 --- a/Projects/Shared/MealClient/Sources/MealClient.swift +++ b/Projects/Shared/MealClient/Sources/MealClient.swift @@ -8,6 +8,7 @@ import UserDefaultsClient public struct MealClient: Sendable { public var fetchMeal: @Sendable (_ date: Date) async throws -> Meal + public var fetchMeals: @Sendable (_ startDate: Date, _ dayCount: Int) async throws -> [Date: Meal] } extension MealClient: DependencyKey { @@ -28,11 +29,7 @@ extension MealClient: DependencyKey { let orgCode = userDefaultsClient.getValue(.orgCode) as? String, let code = userDefaultsClient.getValue(.schoolCode) as? String else { - return Meal( - breakfast: .init(meals: [], cal: 0), - lunch: .init(meals: [], cal: 0), - dinner: .init(meals: [], cal: 0) - ) + return Meal.empty } @Dependency(\.neisClient) var neisClient @@ -63,25 +60,109 @@ extension MealClient: DependencyKey { response = [] } - let breakfast = parseMeal(type: .breakfast) - let lunch = parseMeal(type: .lunch) - let dinner = parseMeal(type: .dinner) + 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) + }, + fetchMeals: { startDate, dayCount in + guard dayCount > 0 else { return [:] } + @Dependency(\.userDefaultsClient) var userDefaultsClient: UserDefaultsClient + let calendar = Calendar.current + let normalizedStart = calendar.startOfDay(for: startDate) - func parseMeal(type: MealType) -> Meal.SubMeal { - return response.first { $0.type == type } - .map { dto in - Meal.SubMeal( - meals: dto.info.replacingOccurrences(of: " ", with: "").components(separatedBy: "
"), - cal: Double(dto.calInfo.components(separatedBy: " ").first ?? "0") ?? 0 - ) - } ?? .init(meals: [], cal: 0) + guard + let orgCode = userDefaultsClient.getValue(.orgCode) as? String, + let code = userDefaultsClient.getValue(.schoolCode) as? String + else { + 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 = [] + } + + let groupedByDate = Dictionary(grouping: response, by: \.serviceDate) + var result = populateEmptyMeals(calendar: calendar, start: normalizedStart, dayCount: dayCount) + + for (dateString, entries) in groupedByDate { + guard let date = formatter.date(from: dateString) else { continue } + let normalizedDate = calendar.startOfDay(for: date) + + let breakfast = parseMeal(response: entries, type: .breakfast) + let lunch = parseMeal(response: entries, type: .lunch) + let dinner = parseMeal(response: entries, type: .dinner) + + result[normalizedDate] = Meal( + breakfast: breakfast, + lunch: lunch, + dinner: dinner + ) + } + + return result } ) } +private func parseMeal(response: [SingleMealResponseDTO], type: MealType) -> Meal.SubMeal { + return response.first { $0.type == type } + .map { dto in + Meal.SubMeal( + meals: dto.info + .replacingOccurrences(of: " ", with: "") + .components(separatedBy: "
"), + cal: Double(dto.calInfo.components(separatedBy: " ").first ?? "0") ?? 0 + ) + } ?? .init(meals: [], cal: 0) +} + +private func populateEmptyMeals(calendar: Calendar, start: Date, dayCount: Int) -> [Date: Meal] { + var result: [Date: Meal] = [:] + for offset in 0.. TimeTable { + let date = Self.dateFormatter.date(from: serviceDate) + return .init( perio: Int(perio) ?? 1, - content: content + content: content, + date: date ) } } diff --git a/Projects/Shared/TimeTableClient/Sources/TimeTableClient.swift b/Projects/Shared/TimeTableClient/Sources/TimeTableClient.swift index b662d0c6..c2edf64e 100644 --- a/Projects/Shared/TimeTableClient/Sources/TimeTableClient.swift +++ b/Projects/Shared/TimeTableClient/Sources/TimeTableClient.swift @@ -53,7 +53,7 @@ extension TimeTableClient: DependencyKey { .init(name: "KEY", value: key), .init(name: "Type", value: "json"), .init(name: "pIndex", value: "1"), - .init(name: "pSize", value: "30"), + .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), @@ -72,7 +72,7 @@ extension TimeTableClient: DependencyKey { .init(name: "KEY", value: key), .init(name: "Type", value: "json"), .init(name: "pIndex", value: "1"), - .init(name: "pSize", value: "30"), + .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)"), @@ -126,7 +126,7 @@ extension TimeTableClient: DependencyKey { .init(name: "KEY", value: key), .init(name: "Type", value: "json"), .init(name: "pIndex", value: "1"), - .init(name: "pSize", value: "30"), + .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), @@ -145,7 +145,7 @@ extension TimeTableClient: DependencyKey { .init(name: "KEY", value: key), .init(name: "Type", value: "json"), .init(name: "pIndex", value: "1"), - .init(name: "pSize", value: "30"), + .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)"), diff --git a/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift b/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift index 20a8a145..d48f6461 100644 --- a/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift +++ b/Projects/UserInterface/DesignSystem/Sources/TWButton/View+twBackButton.swift @@ -4,22 +4,27 @@ import SwiftUI @available(watchOS, unavailable) public extension View { + @ViewBuilder func twBackButton(willDismiss: @escaping () -> Void = {}, dismiss: DismissAction) -> some View { - self - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - willDismiss() - dismiss() - } label: { - Image(systemName: "chevron.left") - .resizable() - .frame(width: 9, height: 16) - .foregroundColor(Color.extraBlack) + if #available(iOS 26.0, *) { + self + } else { + self + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + Button { + willDismiss() + dismiss() + } label: { + Image(systemName: "chevron.left") + .resizable() + .frame(width: 9, height: 16) + .foregroundColor(Color.extraBlack) + } } } - } - .navigationBarBackButtonHidden(true) + .navigationBarBackButtonHidden(true) + } } } diff --git a/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift b/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift index b50e3636..7b7a8f63 100644 --- a/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift +++ b/Projects/UserInterface/DesignSystem/Sources/TWFont/Font+tw.swift @@ -39,28 +39,28 @@ public extension Font.TWFontSystem { var font: Font { switch self { case .headline1: - return Font(DesignSystemFontFamily.Suit.bold.font(size: 28)) + return Font.custom(DesignSystemFontFamily.Suit.bold.name, size: 28) case .headline2: - return Font(DesignSystemFontFamily.Suit.bold.font(size: 24)) + return Font.custom(DesignSystemFontFamily.Suit.bold.name, size: 24) case .headline3: - return Font(DesignSystemFontFamily.Suit.bold.font(size: 18)) + return Font.custom(DesignSystemFontFamily.Suit.bold.name, size: 18) case .headline4: - return Font(DesignSystemFontFamily.Suit.bold.font(size: 16)) + return Font.custom(DesignSystemFontFamily.Suit.bold.name, size: 16) case .body1: - return Font(DesignSystemFontFamily.Suit.medium.font(size: 16)) + return Font.custom(DesignSystemFontFamily.Suit.medium.name, size: 16) case .body2: - return Font(DesignSystemFontFamily.Suit.medium.font(size: 14)) + return Font.custom(DesignSystemFontFamily.Suit.medium.name, size: 14) case .body3: - return Font(DesignSystemFontFamily.Suit.bold.font(size: 14)) + return Font.custom(DesignSystemFontFamily.Suit.bold.name, size: 14) case .caption1: - return Font(DesignSystemFontFamily.Suit.medium.font(size: 12)) + return Font.custom(DesignSystemFontFamily.Suit.medium.name, size: 12) } } } diff --git a/Workspace.swift b/Workspace.swift index e33c294b..9e367d21 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -6,4 +6,4 @@ let workspace = Workspace( projects: [ "Projects/App" ] -) \ No newline at end of file +)