diff --git a/Projects/App/iOS/Sources/Application/AppDelegate.swift b/Projects/App/iOS/Sources/Application/AppDelegate.swift index 75cf2f7..dc668b9 100644 --- a/Projects/App/iOS/Sources/Application/AppDelegate.swift +++ b/Projects/App/iOS/Sources/Application/AppDelegate.swift @@ -5,6 +5,7 @@ import FeatureFlagClient import Firebase import FirebaseAnalytics import FirebaseCore +import FirebaseRemoteConfig import FirebaseWrapper import KeychainClient import LocalDatabaseClient diff --git a/Projects/Feature/MainFeature/Sources/MainCore.swift b/Projects/Feature/MainFeature/Sources/MainCore.swift index a236b50..6820f5d 100644 --- a/Projects/Feature/MainFeature/Sources/MainCore.swift +++ b/Projects/Feature/MainFeature/Sources/MainCore.swift @@ -26,6 +26,7 @@ public struct MainCore: Reducer { public var isExistNewVersion: Bool = false public var mealCore: MealCore.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 @@ -67,6 +68,7 @@ public struct MainCore: Reducer { case tabSwiped(Int) case mealCore(MealCore.Action) case timeTableCore(TimeTableCore.Action) + case weeklyTimeTableCore(WeeklyTimeTableCore.Action) case settingButtonDidTap case checkVersion(TaskResult) case noticeButtonDidTap @@ -114,6 +116,9 @@ public struct MainCore: Reducer { 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 { @@ -229,6 +234,9 @@ extension Reducer where State == MainCore.State, Action == MainCore.Action { .ifLet(\.timeTableCore, action: /Action.timeTableCore) { TimeTableCore() } + .ifLet(\.weeklyTimeTableCore, action: /Action.weeklyTimeTableCore) { + WeeklyTimeTableCore() + } .ifLet(\.$settingsCore, action: \.settingsCore) { SettingsCore() } diff --git a/Projects/Feature/MainFeature/Sources/MainView.swift b/Projects/Feature/MainFeature/Sources/MainView.swift index b97aab4..cd74259 100644 --- a/Projects/Feature/MainFeature/Sources/MainView.swift +++ b/Projects/Feature/MainFeature/Sources/MainView.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import DesignSystem +import FirebaseRemoteConfig import MealFeature import NoticeFeature import SettingsFeature @@ -14,6 +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 public init(store: StoreOf) { self.store = store @@ -58,54 +60,63 @@ public struct MainView: View { .tag(0) VStack { - IfLetStore( - store.scope(state: \.timeTableCore, action: MainCore.Action.timeTableCore) - ) { store in - TimeTableView(store: store) + if enableWeeklyTimeTable { + 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) + } } } .tag(1) } .tabViewStyle(.page(indexDisplayMode: .never)) - VStack { - if viewStore.isShowingReviewToast { - ReviewToast { - viewStore.send(.requestReview) - TWLog.event(ClickReviewEventLog()) - } - .frame(maxWidth: .infinity) - .padding(.horizontal, 16) - .padding(.bottom, viewStore.isExistNewVersion ? 72 : 16) - .animation(.default, value: viewStore.isShowingReviewToast) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - viewStore.send(.hideReviewToast) - } + if viewStore.isShowingReviewToast { + ReviewToast { + viewStore.send(.requestReview) + TWLog.event(ClickReviewEventLog()) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.bottom, viewStore.isExistNewVersion ? 72 : 16) + .animation(.default, value: viewStore.isShowingReviewToast) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + viewStore.send(.hideReviewToast) } } + } - 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("앱스토어로 이동하여 새 버전을 설치할 수 있습니다") + 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("앱스토어로 이동하여 새 버전을 설치할 수 있습니다") } } } @@ -208,6 +219,9 @@ public struct MainView: View { .onAppear { viewStore.send(.onAppear, animation: .default) } + .onChange(of: enableWeeklyTimeTable, perform: { _ in + TWLog.setUserProperty(property: .enableWeeklyTimeTable, value: enableWeeklyTimeTable.description) + }) .onLoad { viewStore.send(.onLoad) } diff --git a/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingCore.swift b/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingCore.swift index c9540ee..ebfd0bc 100644 --- a/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingCore.swift +++ b/Projects/Feature/SchoolSettingFeature/Sources/SchoolSettingCore.swift @@ -45,7 +45,7 @@ public struct SchoolSettingCore: Reducer { if major.isEmpty || schoolMajorList.isEmpty { return "이대로하기" } else if !major.isEmpty || schoolMajorList.isEmpty { - return "확인" + return "확인!" } return "다음" } diff --git a/Projects/Feature/TimeTableFeature/Sources/TimeTableCore.swift b/Projects/Feature/TimeTableFeature/Sources/Daily/TimeTableCore.swift similarity index 100% rename from Projects/Feature/TimeTableFeature/Sources/TimeTableCore.swift rename to Projects/Feature/TimeTableFeature/Sources/Daily/TimeTableCore.swift diff --git a/Projects/Feature/TimeTableFeature/Sources/TimeTableView.swift b/Projects/Feature/TimeTableFeature/Sources/Daily/TimeTableView.swift similarity index 90% rename from Projects/Feature/TimeTableFeature/Sources/TimeTableView.swift rename to Projects/Feature/TimeTableFeature/Sources/Daily/TimeTableView.swift index f6bdb32..0553a78 100644 --- a/Projects/Feature/TimeTableFeature/Sources/TimeTableView.swift +++ b/Projects/Feature/TimeTableFeature/Sources/Daily/TimeTableView.swift @@ -21,12 +21,12 @@ public struct TimeTableView: View { .accessibilityLabel("시간표를 찾을 수 없습니다") .accessibilitySortPriority(1) - if Date().month == 3 { - Text("3월 초중반에는 neis에 정규시간표가\n 등록되어있지 않을 수도 있어요.") + if Date().month == 3 || Date().month == 9 { + Text("학기 초에는 neis에 정규시간표가\n 등록되어있지 않을 수도 있어요.") .multilineTextAlignment(.center) .padding(.top, 14) .foregroundColor(.textSecondary) - .accessibilityLabel("3월 초중반에는 정규시간표가 등록되어 있지 않을 수 있습니다") + .accessibilityLabel("학기 초에는 정규시간표가 등록되어 있지 않을 수 있습니다") .accessibilitySortPriority(2) } } diff --git a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift new file mode 100644 index 0000000..e62896f --- /dev/null +++ b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableCore.swift @@ -0,0 +1,269 @@ +import ComposableArchitecture +import Entity +import EnumUtil +import Foundation +import LocalDatabaseClient +import Sharing +import TimeTableClient +import UserDefaultsClient + +public struct WeeklyTimeTableCore: Reducer { + private enum CancellableID: Hashable { + case fetch + } + + public init() {} + + public struct State: Equatable { + public var weeklyTimeTable: WeeklyTimeTable? = nil + public var isLoading = false + public var showWeekend = false + @Shared public var displayDate: Date + + public init(displayDate: Shared) { + self._displayDate = displayDate + } + } + + public struct WeeklyTimeTable: Equatable { + public let weekdays: [String] + public let dates: [String] + public let periods: [Int] + public let subjects: [[String]] + public let todayIndex: Int? + + public init( + weekdays: [String], + dates: [String], + periods: [Int], + subjects: [[String]], + todayIndex: Int? = nil + ) { + self.weekdays = weekdays + self.dates = dates + self.periods = periods + self.subjects = subjects + self.todayIndex = todayIndex + } + + public func subject(for period: Int, weekday: Int) -> String { + guard weekday < subjects.count, + period < subjects[weekday].count + else { + return "" + } + return subjects[weekday][period] + } + + public func isToday(weekdayIndex: Int) -> Bool { + return todayIndex == weekdayIndex + } + + public func actualPeriodCount(for weekdayIndex: Int) -> Int { + guard weekdayIndex < subjects.count else { return 0 } + let daySubjects = subjects[weekdayIndex] + for i in (0..) + } + + @Dependency(\.timeTableClient) var timeTableClient + @Dependency(\.userDefaultsClient) var userDefaultsClient + @Dependency(\.localDatabaseClient) var localDatabaseClient + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onLoad: + return .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 daysFromMonday = (weekday + 5) % 7 + let mondayDate = + calendar.date(byAdding: .day, value: -daysFromMonday, to: baseDate) + ?? baseDate + + return .concatenate( + .cancel(id: CancellableID.fetch), + .run { [mondayDate, showWeekend = state.showWeekend] send in + do { + let weeklyData = try await fetchWeeklyTimeTable( + mondayDate: mondayDate, + showWeekend: showWeekend + ) + await send( + Action.timeTableResponse(TaskResult.success(weeklyData)) + ) + } catch { + await send(Action.timeTableResponse(TaskResult.failure(error))) + } + } + .cancellable(id: CancellableID.fetch) + ) + + case let .timeTableResponse(.success(weeklyTimeTable)): + state.isLoading = false + state.weeklyTimeTable = weeklyTimeTable + + case .timeTableResponse(.failure(_)): + state.weeklyTimeTable = nil + state.isLoading = false + + default: + return .none + } + return .none + } + } + + 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.. WeeklyTimeTable { + let calendar = Calendar.current + let weekdays = + showWeekend + ? ["월", "화", "수", "목", "금", "토", "일"] : ["월", "화", "수", "목", "금"] + var dates: [String] = [] + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "M/d" + + let dayCount = showWeekend ? 7 : 5 + for i in 0.. 0 ? Array(1...maxPeriods) : [] + + let today = Date() + let todayWeekday = calendar.component(.weekday, from: today) + let todayIndex: Int? + + if showWeekend { + switch todayWeekday { + case 2: todayIndex = 0 // 월요일 + case 3: todayIndex = 1 // 화요일 + case 4: todayIndex = 2 // 수요일 + case 5: todayIndex = 3 // 목요일 + case 6: todayIndex = 4 // 금요일 + case 7: todayIndex = 5 // 토요일 + case 1: todayIndex = 6 // 일요일 + default: todayIndex = nil + } + } else { + switch todayWeekday { + case 2: todayIndex = 0 // 월요일 + case 3: todayIndex = 1 // 화요일 + case 4: todayIndex = 2 // 수요일 + case 5: todayIndex = 3 // 목요일 + case 6: todayIndex = 4 // 금요일 + default: todayIndex = nil + } + } + + return WeeklyTimeTable( + weekdays: weekdays, + dates: dates, + periods: periods, + subjects: weeklySubjects, + todayIndex: todayIndex + ) + } +} diff --git a/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift new file mode 100644 index 0000000..4157de2 --- /dev/null +++ b/Projects/Feature/TimeTableFeature/Sources/Weekly/WeeklyTimeTableView.swift @@ -0,0 +1,254 @@ +import ComposableArchitecture +import DesignSystem +import Entity +import SwiftUI + +@available(iOS 16.0, *) +private struct ColumnBorder: Shape { + var borderWidth: CGFloat + var cornerRadius: CGFloat + var selectedColumnIndex: Int? + var headerHeight: CGFloat + var firstColumnWidth: CGFloat + var columnWidth: CGFloat + var useFlexibleWidth: Bool + var columnCount: Int + var cellHeight: CGFloat + var selectedColumnPeriodCount: Int + + func path(in rect: CGRect) -> Path { + var path = Path() + + guard let selectedIndex = selectedColumnIndex else { return path } + + let actualColumnWidth: CGFloat + let columnX: CGFloat + + if useFlexibleWidth { + let availableWidth = rect.width - firstColumnWidth + actualColumnWidth = availableWidth / CGFloat(columnCount) + columnX = firstColumnWidth + CGFloat(selectedIndex) * actualColumnWidth + } else { + actualColumnWidth = columnWidth + columnX = firstColumnWidth + CGFloat(selectedIndex) * columnWidth + } + + let actualHeight = CGFloat(selectedColumnPeriodCount) * cellHeight + + let columnRect = CGRect( + x: columnX, + y: headerHeight, + width: actualColumnWidth, + height: actualHeight + ) + + let roundedRect = RoundedRectangle(cornerRadius: cornerRadius) + path = roundedRect.path(in: columnRect) + + return path + } +} + +public struct WeeklyTimeTableView: View { + let store: StoreOf + @ObservedObject var viewStore: ViewStoreOf + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + public init(store: StoreOf) { + self.store = store + self.viewStore = ViewStore(store, observe: { $0 }) + } + + public var body: some View { + ScrollView(.vertical) { + VStack { + if viewStore.weeklyTimeTable == nil && !viewStore.isLoading { + Text("이번 주 시간표를 찾을 수 없어요!") + .padding(.top, 16) + .foregroundColor(.textSecondary) + .accessibilityLabel("시간표를 찾을 수 없습니다") + .accessibilitySortPriority(1) + + if Date().month == 3 || Date().month == 9 { + Text("학기 초에는 neis에 정규시간표가\n 등록되어있지 않을 수도 있어요.") + .multilineTextAlignment(.center) + .padding(.top, 14) + .foregroundColor(.textSecondary) + .accessibilityLabel("학기 초에는 정규시간표가 등록되어 있지 않을 수 있습니다") + .accessibilitySortPriority(2) + } + } 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) { + timeTableGrid(weeklyTimeTable: weeklyTimeTable) + .frame(alignment: .top) + } else { + ScrollView(.horizontal) { + timeTableGrid(weeklyTimeTable: weeklyTimeTable) + .frame(alignment: .top) + } + } + } + } + } + } + } + .onLoad { + viewStore.send(.onLoad) + } + .onAppear { + viewStore.send(.onAppear, animation: .default) + } + .refreshable { + viewStore.send(.refresh, animation: .default) + } + } + + @ViewBuilder + private func timeTableGrid(weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable) -> some View { + VStack(spacing: 0) { + headerRow(weeklyTimeTable: weeklyTimeTable) + + ForEach(weeklyTimeTable.periods, id: \.self) { period in + timeTableRow(period: period, weeklyTimeTable: weeklyTimeTable) + } + } + .frame(maxWidth: .infinity) + .background(Color.cardBackground) + .overlay { + if #available(iOS 16.0, *) { + ColumnBorder( + borderWidth: 1, + cornerRadius: 16, + selectedColumnIndex: weeklyTimeTable.todayIndex, + headerHeight: 44, + firstColumnWidth: 40, + columnWidth: columnWidth(weeklyTimeTable: weeklyTimeTable), + useFlexibleWidth: shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable), + columnCount: weeklyTimeTable.weekdays.count, + cellHeight: 56, + selectedColumnPeriodCount: { + if let todayIndex = weeklyTimeTable + .todayIndex { weeklyTimeTable.actualPeriodCount(for: todayIndex) + } else { + 0 + } + }() + ) + .stroke(Color.extraBlack.opacity(0.8), lineWidth: 2) + } + } + } + + @ViewBuilder + private func headerRow(weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable) -> some View { + HStack(spacing: 0) { + Text("") + .frame(width: 40, height: 44) + .background(Color.unselectedSecondary.opacity(0.1)) + + 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 + ) + } + } + } + + @ViewBuilder + private func timeTableRow( + period: Int, + weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable + ) -> 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)) + + ForEach(0.. CGFloat? in + shouldUseFlexibleWidth(weeklyTimeTable: weeklyTimeTable) + ? nil + : columnWidth(weeklyTimeTable: weeklyTimeTable) + }() + ) + .background(Color.extraWhite) + .overlay( + Rectangle() + .fill(Color.unselectedSecondary.opacity(0.3)) + .frame(height: 0.5), + alignment: .bottom + ) + } + } + } + + private func shouldUseFlexibleWidth(weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable) -> Bool { + return horizontalSizeClass == .regular || !viewStore.showWeekend + } + + private func columnWidth(weeklyTimeTable: WeeklyTimeTableCore.WeeklyTimeTable) -> CGFloat { + return viewStore.showWeekend ? 55.0 : 65.5 + } + + private func fontForCell(isToday: Bool, horizontalSizeClass: UserInterfaceSizeClass?) -> Font.TWFontSystem { + if horizontalSizeClass == .regular { + return isToday ? .body1 : .body2 + } else { + return isToday ? .body3 : .body2 + } + } +} diff --git a/Projects/Shared/TWLog/Sources/TWUserProperty.swift b/Projects/Shared/TWLog/Sources/TWUserProperty.swift index ef6c128..3458c10 100644 --- a/Projects/Shared/TWLog/Sources/TWUserProperty.swift +++ b/Projects/Shared/TWLog/Sources/TWUserProperty.swift @@ -9,4 +9,5 @@ public enum TWUserProperty: String { case allergies = "allergies" case widget = "widget" case activeWatch = "active_watch" + case enableWeeklyTimeTable = "enable_weekly_time_table" }