diff --git a/Soomsil-USaint/Application/AppReducer.swift b/Soomsil-USaint/Application/AppReducer.swift index c670f72..5bf9fb4 100644 --- a/Soomsil-USaint/Application/AppReducer.swift +++ b/Soomsil-USaint/Application/AppReducer.swift @@ -24,13 +24,14 @@ struct AppReducer { enum Action { case initialize - case initResponse(Result) + case initResponse(Result<(StudentInfo, TotalReportCard), Error>) case backgroundTask case login(LoginReducer.Action) case home(HomeReducer.Action) } @Dependency(\.localNotificationClient) var localNotificationClient + @Dependency(\.gradeClient) var gradeClient @Dependency(\.studentClient) var studentClient var body: some Reducer { @@ -40,10 +41,17 @@ struct AppReducer { return .run { send in await send(.initResponse(Result { let _ = try await studentClient.getSaintInfo() + try await gradeClient.deleteTotalReportCard() + let rusaintReport = try await gradeClient.fetchTotalReportCard() + try await gradeClient.updateTotalReportCard(rusaintReport) + + let info = try await studentClient.getStudentInfo() + let report = try await gradeClient.getTotalReportCard() + return (info, report) })) } - case .initResponse(.success): - state = .loggedIn(HomeReducer.State()) + case .initResponse(.success(let (info, report))): + state = .loggedIn(HomeReducer.State(studentInfo: info, totalReportCard: report)) return .none case .initResponse(.failure(let error)): debugPrint(error) @@ -55,8 +63,8 @@ struct AppReducer { @Shared(.appStorage("isFirst")) var isFirst = true try await localNotificationClient.setLecturePushNotification("\(isFirst)") } - case .login(.loginResponse(.success)): - state = .loggedIn(HomeReducer.State()) + case .login(.loginResponse(.success(let (info, report)))): + state = .loggedIn(HomeReducer.State(studentInfo: info, totalReportCard: report)) return .none default: return .none diff --git a/Soomsil-USaint/Application/AppView.swift b/Soomsil-USaint/Application/AppView.swift index 2e1f036..adaa07a 100644 --- a/Soomsil-USaint/Application/AppView.swift +++ b/Soomsil-USaint/Application/AppView.swift @@ -28,7 +28,7 @@ struct AppView: View { } case .loggedIn: if let store = store.scope(state: \.loggedIn, action: \.home) { - HomeView(store: store, viewModel: DefaultSaintHomeViewModel(), isLoggedIn: $isLoggedIn) + HomeView(store: store) } } } diff --git a/Soomsil-USaint/Application/Feature/Home/Core/HomeReducer.swift b/Soomsil-USaint/Application/Feature/Home/Core/HomeReducer.swift index 3024eb2..1d6e852 100644 --- a/Soomsil-USaint/Application/Feature/Home/Core/HomeReducer.swift +++ b/Soomsil-USaint/Application/Feature/Home/Core/HomeReducer.swift @@ -15,24 +15,30 @@ struct HomeReducer { struct State { @Shared(.appStorage("isFirst")) var isFirst = true @Shared(.appStorage("permission")) var permission = false + + var studentInfo: StudentInfo + var totalReportCard: TotalReportCard } - enum Action { + enum Action: BindableAction { + case binding(BindingAction) case onAppear case checkPushAuthorizationResponse(Result) - case sendTestPushResponse(Result) + case settingPressed + case semesterListPressed } @Dependency(\.localNotificationClient) var localNotificationClient + @Dependency(\.studentClient) var studentClient + @Dependency(\.gradeClient) var gradeClient var body: some Reducer { + BindingReducer() Reduce { state, action in switch action { case .onAppear: let isFirst = state.isFirst - debugPrint("Home - Before: \(state.isFirst)") state.$isFirst.withLock { $0 = false } - debugPrint("Home - After: \(state.isFirst)") return .run { send in await send(.checkPushAuthorizationResponse(Result { if (isFirst) { @@ -44,15 +50,17 @@ struct HomeReducer { } case .checkPushAuthorizationResponse(.success(let granted)): state.$permission.withLock { $0 = granted } - return .run { send in - await send(.sendTestPushResponse(Result { - try await localNotificationClient.setLecturePushNotification("Test") - })) - } + return .none case .checkPushAuthorizationResponse(.failure(let error)): debugPrint("Home Reducer: CheckPushAuthorization Error - \(error)") return .none - case .sendTestPushResponse: + case .settingPressed: + debugPrint("== SettingView로 이동 ==") + return .none + case .semesterListPressed: + debugPrint("== SemesterList로 이동 ==") + return .none + case .binding(_): return .none } } diff --git a/Soomsil-USaint/Application/Feature/Home/View/GradeInfo.swift b/Soomsil-USaint/Application/Feature/Home/View/GradeInfo.swift new file mode 100644 index 0000000..d8cbfb4 --- /dev/null +++ b/Soomsil-USaint/Application/Feature/Home/View/GradeInfo.swift @@ -0,0 +1,106 @@ +// +// GradeInfoView.swift +// Soomsil-USaint +// +// Created by 이조은 on 2/2/25. +// + +import SwiftUI + +import YDS_SwiftUI + +struct GradeInfo: View { + var reportCard: TotalReportCard + + let onSemesterListPressed: () -> Void + + var body: some View { + Button(action: { + onSemesterListPressed() + }, label: { + VStack { + VStack(alignment: .leading, spacing: 0) { + Text("내 성적") + .font(YDSFont.title3) + .padding(.leading, 4) + .padding(.top, 20) + + HStack(spacing: 14) { + Image("ppussung") + .resizable() + .clipShape(Circle()) + .frame(width: 48, height: 48) + VStack(alignment: .leading) { + Text("전체 학기") + .font(YDSFont.body2) + .padding(.bottom, -2.0) + Text("성적 확인하기") + .font(YDSFont.subtitle1) + } + Spacer() + YDSIcon.arrowRightLine + .renderingMode(.template) + .foregroundColor(YDSColor.buttonNormal) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(YDSColor.bgElevated) + .frame(height: 72) + + VStack(spacing: 0) { + CreditLine(title: "평균학점", earned: reportCard.gpa, graduated: 4.50, isInt: false) + Divider() + .padding(.horizontal, 13) + CreditLine(title: "취득학점", earned: reportCard.earnedCredit, graduated: reportCard.graduateCredit, isInt: true) + + Button(action: { + onSemesterListPressed() + }, label: { + Text("전체 학기 성적 보기") + .font(Font.custom("Apple SD Gothic Neo", size: 15)) + .foregroundColor(.white) + .frame(height: 39, alignment: .center) + .frame(maxWidth: .infinity) + .background(Color(red: 0.51, green: 0.43, blue: 0.93)) + .cornerRadius(4) + }) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .padding(.bottom, 4) + } + } + .padding(.horizontal, 16) + } + .foregroundStyle(.black) + .background(YDSColor.bgElevated) + .cornerRadius(8) + }) + } +} + +struct CreditLine: View { + let title: String + let earned: Float + let graduated: Float + let isInt: Bool + + var body: some View { + HStack { + Text(title).font(YDSFont.body1) + Spacer() + Text(isInt ? String(Int(earned)) : String(format: "%.2f", earned)) + .font(YDSFont.subtitle2) + .foregroundColor(Color(red: 0.51, green: 0.43, blue: 0.93)) + Text("/ \(isInt ? String(Int(graduated)) : String(format: "%.2f", graduated))") + .font(YDSFont.subtitle3) + .foregroundColor(Color(red: 0.56, green: 0.58, blue: 0.6)) + } + .frame(height: 23) + .padding(.vertical, 8) + .padding(.horizontal, 24) + } +} + +#Preview { + GradeInfo(reportCard: TotalReportCard(gpa: 4.5, earnedCredit: 123, graduateCredit: 188), onSemesterListPressed: {}) +} diff --git a/Soomsil-USaint/Application/Feature/Home/View/HomeView.swift b/Soomsil-USaint/Application/Feature/Home/View/HomeView.swift index 659d855..43f2599 100644 --- a/Soomsil-USaint/Application/Feature/Home/View/HomeView.swift +++ b/Soomsil-USaint/Application/Feature/Home/View/HomeView.swift @@ -1,335 +1,93 @@ // -// HomeView.swift -// Soomsil-USaint-iOS +// newHomeView.swift +// Soomsil-USaint // -// Created by 이조은 on 12/16/24. +// Created by 이조은 on 1/23/25. // import SwiftUI import ComposableArchitecture -import Rusaint import YDS_SwiftUI -// swiftlint:disable identifier_name - -struct HomeView: View { +struct HomeView: View { @Perception.Bindable var store: StoreOf - - @State var path: [StackView] = [] - @StateObject var viewModel: VM - -// @State var isLaunching: Bool = true - @Binding var isLoggedIn: Bool - @State var isFirst: Bool = LocalNotificationManager.shared.getIsFirst() - @State private var session: USaintSession? - @State private var totalReportCard: TotalReportCard = HomeRepository.shared.getTotalReportCard() - @State private var isLatestSemesterNotYetConfirmed: Bool = true + // MARK: - Home var body: some View { -// if isLaunching { -// SplashView() -// .onAppear() { -// DispatchQueue.main.asyncAfter(deadline: .now() + 2) { -// isLaunching = false -// } -// } -// } -// else if !isLoggedIn { -// NavigationStack { -// LoginView(isLoggedIn: $isLoggedIn) -// } -// } else { - NavigationStack(path: $path) { + WithPerceptionTracking { + VStack { + title VStack { - HStack { - Text("유세인트") - .font(YDSFont.title2) - Spacer() - } - .frame(maxWidth: .infinity, alignment: .topLeading) - .frame(height: 31) - .padding(.vertical, 6) - .padding(.leading, 16) - - VStack(spacing: Dimension.MainSpacing.vertical) { - userInformationView() - GradeItemGroup(reportCard: totalReportCard) - Spacer() + Student(student: store.studentInfo) { + store.send(.settingPressed) } - .background(Color(red: 0.95, green: 0.96, blue: 0.97)) - } - .background(.white) - .onAppear { - store.send(.onAppear) -// if !isFirst { -// LocalNotificationManager().requestAuthorization(completion: { _ in -// }) -// LocalNotificationManager.shared.saveIsFirst(true) -// } - } -// .task { -// await loadUserInfoAndTotalReposrtCard() -// } - .registerYDSToast() - .animation(.easeInOut, value: viewModel.isLogedIn()) - .navigationDestination(for: StackView.self) { stackView in - switch stackView.type { - case .Setting: - SettingView(path: $path, isLoggedIn: $isLoggedIn) - case .SemesterList: - SemesterListView(path: $path, semesterListViewModel: DefaultSemesterListViewModel()) - case .SemesterDetail(let gradeSummary): - SemesterDetailView(path: $path, semesterDetailViewModel: DefaultSemesterDetailViewModel(gradeSummary: gradeSummary)) - case .WebViewTerm: - WebViewContainer(path: $path, urlToLoad: "https://auth.yourssu.com/terms/service.html") - case .WebViewPrivacy: - WebViewContainer(path: $path, urlToLoad: "https://auth.yourssu.com/terms/information.html") + GradeInfo(reportCard: store.totalReportCard) { + store.send(.semesterListPressed) } + Spacer() } + .padding(.horizontal, 16) } -// } - } - - private func loadUserInfoAndTotalReposrtCard() async { - if viewModel.hasCachedUserInformation() { - isLoggedIn = viewModel.hasCachedUserInformation() - viewModel.syncCachedUserInformation() - } - - totalReportCard = HomeRepository.shared.getTotalReportCard() - - let userInfo = HomeRepository.shared.getUserLoginInformation() - do { - self.session = try await USaintSessionBuilder().withPassword(id: userInfo[0], password: userInfo[1]) - if self.session != nil { - await saveReportCard(session: session!) - DispatchQueue.main.async { - self.totalReportCard = HomeRepository.shared.getTotalReportCard() - } + .background(Color(red: 0.95, green: 0.96, blue: 0.97)) + .onAppear { + store.send(.onAppear) } - } catch { - print("Failed to load user info: \(error)") } } - @ViewBuilder - private func userInformationView() -> some View { + struct Student: View { + var student: StudentInfo - let person = viewModel.person + let onSettingPressed: () -> Void - - ZStack { - Rectangle() - .frame(height: 89) - .foregroundColor(.clear) + var body: some View { HStack { Image("DefaultProfileImage") .resizable() .cornerRadius(16) .frame(width: 48, height: 48) VStack(alignment: .leading) { - Text(person?.name ?? "") + Text(student.name) .font(YDSFont.subtitle1) .padding(.bottom, 1.0) - Text("\(person?.major ?? "") \(person?.schoolYear ?? "")") + Text("\(student.major) \(student.schoolYear)") .font(YDSFont.body1) } .padding(.leading) Spacer() Button(action: { - path.append(StackView(type: .Setting)) + onSettingPressed() }, label: { Image("ic_setting_fill") }) - .padding(.trailing, 8) } .padding(.horizontal, 16.0) .padding(.vertical, 20.0) } - .cornerRadius(16.0) - .padding([.top, .leading, .trailing], 16) - - } - - @ViewBuilder - private func GradeItemGroup(reportCard: TotalReportCard) -> some View { - Button(action: { - path.append(StackView(type: .SemesterList)) - }, label: { - SaintItemGroupView(listType: .grade) { - SaintItemView(.grade) - detailGradeListView( - average: reportCard.gpa, - credit: reportCard.earnedCredit, - graduateCredit: reportCard.graduateCredit - ) - } - .foregroundColor(Color(red: 0.06, green: 0.07, blue: 0.07)) - .padding(.horizontal, Dimension.MainSpacing.horizontal) - }) - } - - private func detailGradeListView(average: Float, credit: Float, graduateCredit: Float) -> some View { - VStack(spacing: Dimension.DetailSpacing.vertical) { - HStack { - Text("평균학점").font(YDSFont.body1) - Spacer() - Text(String(format: "%.2f", average)).font(YDSFont.subtitle2) - .foregroundColor(Color(red: 0.51, green: 0.43, blue: 0.93)) - Text("/ \(String(format: "%.2f", 4.50))").font(YDSFont.subtitle3) - .foregroundColor(Color(red: 0.56, green: 0.58, blue: 0.6)) - .padding(.leading, -4) - } - .frame(height: 23) - .padding(.vertical, Dimension.DetailPadding.vertical) - .padding(.horizontal, Dimension.DetailPadding.horizontal) - - Divider() - .padding(.horizontal, 13) - - HStack { - Text("취득학점").font(YDSFont.body1) - Spacer() - Text(String(format: "%.1f", credit)).font(YDSFont.subtitle2) - .foregroundColor(Color(red: 0.51, green: 0.43, blue: 0.93)) - Text("/ \(String(Int(graduateCredit)))").font(YDSFont.subtitle3) - .foregroundColor(Color(red: 0.56, green: 0.58, blue: 0.6)) - .padding(.leading, -4) - } - .frame(height: 23) - .padding(.vertical, Dimension.DetailPadding.vertical) - .padding(.horizontal, Dimension.DetailPadding.horizontal) - - // MARK: - FIX - Button(action: { - path.append(StackView(type: .SemesterList)) - }, label: { - Text("전체 학기 성적 보기") - .font(Font.custom("Apple SD Gothic Neo", size: 15)) - .foregroundColor(isLatestSemesterNotYetConfirmed ? .white : Color(red: 0.15, green: 0.15, blue: 0.16)) - .frame(height: 39, alignment: .center) - .frame(maxWidth: .infinity) - .background(isLatestSemesterNotYetConfirmed ? Color(red: 0.51, green: 0.43, blue: 0.93) : Color(red: 0.95, green: 0.96, blue: 0.97)) - .cornerRadius(4) - }) - .padding(.horizontal, Dimension.MainPadding.horizontal) - .padding(.vertical, Dimension.DetailPadding.vertical) - .padding(.bottom, 4) - } - .background(YDSColor.bgElevated) - } - - private func saveReportCard(session: USaintSession) async { - do { - let courseGrades = try await CourseGradesApplicationBuilder().build(session: self.session!).certificatedSummary(courseType: .bachelor) - let graduationRequirement = try await GraduationRequirementsApplicationBuilder().build(session: self.session!).requirements() - let requirements = graduationRequirement.requirements.filter { $0.value.name.hasPrefix("학부-졸업학점") } - .compactMap { $0.value.requirement ?? 0} - - if let graduateCredit = requirements.first { - HomeRepository.shared.updateTotalReportCard(gpa: courseGrades.gradePointsAvarage, earnedCredit: courseGrades.earnedCredits, graduateCredit: Float(graduateCredit)) - } - - // DispatchQueue.main.async { - // self.totalReportCard = HomeRepository.shared.getTotalReportCard() - // } - self.totalReportCard = HomeRepository.shared.getTotalReportCard() - - - } catch { - print("Failed to save reportCard: \(error)") - } - } -} - -private enum Dimension { - enum MainSpacing { - static let horizontal: CGFloat = 16 - static let vertical: CGFloat = 12 - } - - enum DetailSpacing { - static let vertical: CGFloat = 0 - } - - enum MainPadding { - static let horizontal: CGFloat = 16 - static let vertical: CGFloat = 12 - } - - enum DetailPadding { - static let horizontal: CGFloat = 28 - static let vertical: CGFloat = 8 } } -private struct SaintItemGroupView: View where Content: View { - let content: () -> Content - let listTitle: String - - init(listType: HomeItem, @ViewBuilder content: @escaping () -> Content) { - self.listTitle = listType.listTitle - self.content = content - } - - var body: some View { - VStack(spacing: Dimension.DetailSpacing.vertical) { - HStack { - Text(listTitle).font(YDSFont.title3) - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(YDSColor.bgElevated) - content() - } - .cornerRadius(8) - } -} - -private struct SaintItemView: View { - let title: String - let subTitle: String - init(_ listType: HomeItem) { - self.title = listType.title - self.subTitle = listType.subTitle - } - var body: some View { +private extension HomeView { + var title: some View { HStack { - Image("ppussung") - .resizable() - .clipShape(Circle()) - .frame(width: 48, height: 48) - .padding(.leading, 2) - VStack(alignment: .leading) { - Text(subTitle) - .font(YDSFont.subtitle3) - .padding(.bottom, -2.0) - Text(title) - .font(YDSFont.subtitle1) - } - .padding(.leading) + Text("유세인트") + .font(YDSFont.title2) + .padding(.horizontal, 16) + .padding(.bottom, 8) Spacer() - YDSIcon.arrowRightLine - .renderingMode(.template) - .foregroundColor(YDSColor.buttonNormal) - .padding(.trailing, 8) } - .padding(.horizontal, Dimension.MainPadding.horizontal) - .padding(.vertical, Dimension.MainPadding.vertical) - .background(YDSColor.bgElevated) - .frame(height: 72) + .background(.white) } } -//struct SaintMainHomeView_Previews: PreviewProvider { -// @State var isLoggedIn: Bool = true -// -// static var previews: some View { -// NavigationStack { -// HomeView(viewModel: TestSaintMainHomeViewModel(), isLoggedIn: $isLoggedIn) -// } -// } -//} - +#Preview { + HomeView(store: Store( + initialState: HomeReducer.State( + studentInfo: StudentInfo(name: "000", major: "글로벌미디어학부", schoolYear: "6학년"), + totalReportCard: TotalReportCard(gpa: 3.4, earnedCredit: 34.5, graduateCredit: 124.0) + ) + ) { + HomeReducer() + }) +} diff --git a/Soomsil-USaint/Application/Feature/Home/View/OldHomeView.swift b/Soomsil-USaint/Application/Feature/Home/View/OldHomeView.swift new file mode 100644 index 0000000..02187b6 --- /dev/null +++ b/Soomsil-USaint/Application/Feature/Home/View/OldHomeView.swift @@ -0,0 +1,335 @@ +// +// HomeView.swift +// Soomsil-USaint-iOS +// +// Created by 이조은 on 12/16/24. +// + +import SwiftUI + +import ComposableArchitecture +import Rusaint +import YDS_SwiftUI + +// swiftlint:disable identifier_name + +struct OldHomeView: View { + @Perception.Bindable var store: StoreOf + + @State var path: [StackView] = [] + @StateObject var viewModel: VM + +// @State var isLaunching: Bool = true + @Binding var isLoggedIn: Bool + @State var isFirst: Bool = LocalNotificationManager.shared.getIsFirst() + @State private var session: USaintSession? + @State private var totalReportCard: TotalReportCard = HomeRepository.shared.getTotalReportCard() + @State private var isLatestSemesterNotYetConfirmed: Bool = true + + var body: some View { +// if isLaunching { +// SplashView() +// .onAppear() { +// DispatchQueue.main.asyncAfter(deadline: .now() + 2) { +// isLaunching = false +// } +// } +// } +// else if !isLoggedIn { +// NavigationStack { +// LoginView(isLoggedIn: $isLoggedIn) +// } +// } else { + NavigationStack(path: $path) { + VStack { + HStack { + Text("유세인트") + .font(YDSFont.title2) + Spacer() + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .frame(height: 31) + .padding(.vertical, 6) + .padding(.leading, 16) + + VStack(spacing: Dimension.MainSpacing.vertical) { + userInformationView() + GradeItemGroup(reportCard: totalReportCard) + Spacer() + } + .background(Color(red: 0.95, green: 0.96, blue: 0.97)) + } + .background(.white) + .onAppear { + store.send(.onAppear) +// if !isFirst { +// LocalNotificationManager().requestAuthorization(completion: { _ in +// }) +// LocalNotificationManager.shared.saveIsFirst(true) +// } + } +// .task { +// await loadUserInfoAndTotalReposrtCard() +// } + .registerYDSToast() + .animation(.easeInOut, value: viewModel.isLogedIn()) + .navigationDestination(for: StackView.self) { stackView in + switch stackView.type { + case .Setting: + SettingView(path: $path, isLoggedIn: $isLoggedIn) + case .SemesterList: + SemesterListView(path: $path, semesterListViewModel: DefaultSemesterListViewModel()) + case .SemesterDetail(let gradeSummary): + SemesterDetailView(path: $path, semesterDetailViewModel: DefaultSemesterDetailViewModel(gradeSummary: gradeSummary)) + case .WebViewTerm: + WebViewContainer(path: $path, urlToLoad: "https://auth.yourssu.com/terms/service.html") + case .WebViewPrivacy: + WebViewContainer(path: $path, urlToLoad: "https://auth.yourssu.com/terms/information.html") + } + } + } +// } + } + + private func loadUserInfoAndTotalReposrtCard() async { + if viewModel.hasCachedUserInformation() { + isLoggedIn = viewModel.hasCachedUserInformation() + viewModel.syncCachedUserInformation() + } + + totalReportCard = HomeRepository.shared.getTotalReportCard() + + let userInfo = HomeRepository.shared.getUserLoginInformation() + do { + self.session = try await USaintSessionBuilder().withPassword(id: userInfo[0], password: userInfo[1]) + if self.session != nil { + await saveReportCard(session: session!) + DispatchQueue.main.async { + self.totalReportCard = HomeRepository.shared.getTotalReportCard() + } + } + } catch { + print("Failed to load user info: \(error)") + } + } + + @ViewBuilder + private func userInformationView() -> some View { + + let person = viewModel.person + + + ZStack { + Rectangle() + .frame(height: 89) + .foregroundColor(.clear) + HStack { + Image("DefaultProfileImage") + .resizable() + .cornerRadius(16) + .frame(width: 48, height: 48) + VStack(alignment: .leading) { + Text(person?.name ?? "") + .font(YDSFont.subtitle1) + .padding(.bottom, 1.0) + Text("\(person?.major ?? "") \(person?.schoolYear ?? "")") + .font(YDSFont.body1) + } + .padding(.leading) + Spacer() + Button(action: { + path.append(StackView(type: .Setting)) + }, label: { + Image("ic_setting_fill") + }) + .padding(.trailing, 8) + } + .padding(.horizontal, 16.0) + .padding(.vertical, 20.0) + } + .cornerRadius(16.0) + .padding([.top, .leading, .trailing], 16) + + } + + @ViewBuilder + private func GradeItemGroup(reportCard: TotalReportCard) -> some View { + Button(action: { + path.append(StackView(type: .SemesterList)) + }, label: { + SaintItemGroupView(listType: .grade) { + SaintItemView(.grade) + detailGradeListView( + average: reportCard.gpa, + credit: reportCard.earnedCredit, + graduateCredit: reportCard.graduateCredit + ) + } + .foregroundColor(Color(red: 0.06, green: 0.07, blue: 0.07)) + .padding(.horizontal, Dimension.MainSpacing.horizontal) + }) + } + + private func detailGradeListView(average: Float, credit: Float, graduateCredit: Float) -> some View { + VStack(spacing: Dimension.DetailSpacing.vertical) { + HStack { + Text("평균학점").font(YDSFont.body1) + Spacer() + Text(String(format: "%.2f", average)).font(YDSFont.subtitle2) + .foregroundColor(Color(red: 0.51, green: 0.43, blue: 0.93)) + Text("/ \(String(format: "%.2f", 4.50))").font(YDSFont.subtitle3) + .foregroundColor(Color(red: 0.56, green: 0.58, blue: 0.6)) + .padding(.leading, -4) + } + .frame(height: 23) + .padding(.vertical, Dimension.DetailPadding.vertical) + .padding(.horizontal, Dimension.DetailPadding.horizontal) + + Divider() + .padding(.horizontal, 13) + + HStack { + Text("취득학점").font(YDSFont.body1) + Spacer() + Text(String(format: "%.1f", credit)).font(YDSFont.subtitle2) + .foregroundColor(Color(red: 0.51, green: 0.43, blue: 0.93)) + Text("/ \(String(Int(graduateCredit)))").font(YDSFont.subtitle3) + .foregroundColor(Color(red: 0.56, green: 0.58, blue: 0.6)) + .padding(.leading, -4) + } + .frame(height: 23) + .padding(.vertical, Dimension.DetailPadding.vertical) + .padding(.horizontal, Dimension.DetailPadding.horizontal) + + // MARK: - FIX + Button(action: { + path.append(StackView(type: .SemesterList)) + }, label: { + Text("전체 학기 성적 보기") + .font(Font.custom("Apple SD Gothic Neo", size: 15)) + .foregroundColor(isLatestSemesterNotYetConfirmed ? .white : Color(red: 0.15, green: 0.15, blue: 0.16)) + .frame(height: 39, alignment: .center) + .frame(maxWidth: .infinity) + .background(isLatestSemesterNotYetConfirmed ? Color(red: 0.51, green: 0.43, blue: 0.93) : Color(red: 0.95, green: 0.96, blue: 0.97)) + .cornerRadius(4) + }) + .padding(.horizontal, Dimension.MainPadding.horizontal) + .padding(.vertical, Dimension.DetailPadding.vertical) + .padding(.bottom, 4) + } + .background(YDSColor.bgElevated) + } + + private func saveReportCard(session: USaintSession) async { + do { + let courseGrades = try await CourseGradesApplicationBuilder().build(session: self.session!).certificatedSummary(courseType: .bachelor) + let graduationRequirement = try await GraduationRequirementsApplicationBuilder().build(session: self.session!).requirements() + let requirements = graduationRequirement.requirements.filter { $0.value.name.hasPrefix("학부-졸업학점") } + .compactMap { $0.value.requirement ?? 0} + + if let graduateCredit = requirements.first { + HomeRepository.shared.updateTotalReportCard(gpa: courseGrades.gradePointsAvarage, earnedCredit: courseGrades.earnedCredits, graduateCredit: Float(graduateCredit)) + } + + // DispatchQueue.main.async { + // self.totalReportCard = HomeRepository.shared.getTotalReportCard() + // } + self.totalReportCard = HomeRepository.shared.getTotalReportCard() + + + } catch { + print("Failed to save reportCard: \(error)") + } + } +} + +private enum Dimension { + enum MainSpacing { + static let horizontal: CGFloat = 16 + static let vertical: CGFloat = 12 + } + + enum DetailSpacing { + static let vertical: CGFloat = 0 + } + + enum MainPadding { + static let horizontal: CGFloat = 16 + static let vertical: CGFloat = 12 + } + + enum DetailPadding { + static let horizontal: CGFloat = 28 + static let vertical: CGFloat = 8 + } +} + +private struct SaintItemGroupView: View where Content: View { + let content: () -> Content + let listTitle: String + + init(listType: HomeItem, @ViewBuilder content: @escaping () -> Content) { + self.listTitle = listType.listTitle + self.content = content + } + + var body: some View { + VStack(spacing: Dimension.DetailSpacing.vertical) { + HStack { + Text(listTitle).font(YDSFont.title3) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(YDSColor.bgElevated) + content() + } + .cornerRadius(8) + } +} + +private struct SaintItemView: View { + let title: String + let subTitle: String + init(_ listType: HomeItem) { + self.title = listType.title + self.subTitle = listType.subTitle + } + var body: some View { + HStack { + Image("ppussung") + .resizable() + .clipShape(Circle()) + .frame(width: 48, height: 48) + .padding(.leading, 2) + VStack(alignment: .leading) { + Text(subTitle) + .font(YDSFont.subtitle3) + .padding(.bottom, -2.0) + Text(title) + .font(YDSFont.subtitle1) + } + .padding(.leading) + Spacer() + YDSIcon.arrowRightLine + .renderingMode(.template) + .foregroundColor(YDSColor.buttonNormal) + .padding(.trailing, 8) + } + .padding(.horizontal, Dimension.MainPadding.horizontal) + .padding(.vertical, Dimension.MainPadding.vertical) + .background(YDSColor.bgElevated) + .frame(height: 72) + } +} + +//struct SaintMainHomeView_Previews: PreviewProvider { +// @State var isLoggedIn: Bool = true +// +// static var previews: some View { +// NavigationStack { +// HomeView(viewModel: TestSaintMainHomeViewModel(), isLoggedIn: $isLoggedIn) +// } +// } +//} + diff --git a/Soomsil-USaint/Application/Feature/Login/Core/LoginReducer.swift b/Soomsil-USaint/Application/Feature/Login/Core/LoginReducer.swift index c373f39..465b596 100644 --- a/Soomsil-USaint/Application/Feature/Login/Core/LoginReducer.swift +++ b/Soomsil-USaint/Application/Feature/Login/Core/LoginReducer.swift @@ -24,10 +24,11 @@ struct LoginReducer { case onAppear case initResponse(Result) case loginPressed - case loginResponse(Result) + case loginResponse(Result<(StudentInfo, TotalReportCard), Error>) case deleteResponse(Result) } + @Dependency(\.gradeClient) var gradeClient @Dependency(\.studentClient) var studentClient var body: some Reducer { @@ -51,7 +52,13 @@ struct LoginReducer { await send(.loginResponse(Result { try await studentClient.setSaintInfo(saintInfo: saintInfo) try await studentClient.setStudentInfo() - // TODO: ReportCard 정보 저장 (saveReportCard(session: session)) + let rusaintReport = try await gradeClient.fetchTotalReportCard() + try await gradeClient.updateTotalReportCard(rusaintReport) + + let studentInfo = try await studentClient.getStudentInfo() + let report = try await gradeClient.getTotalReportCard() + + return (studentInfo, report) })) } case .loginResponse(.success): diff --git a/Soomsil-USaint/Data/Client/RuSaint/GradeClient.swift b/Soomsil-USaint/Data/Client/RuSaint/GradeClient.swift index eff826a..23a93a4 100644 --- a/Soomsil-USaint/Data/Client/RuSaint/GradeClient.swift +++ b/Soomsil-USaint/Data/Client/RuSaint/GradeClient.swift @@ -12,16 +12,20 @@ import Rusaint @DependencyClient struct GradeClient { static let coreDataStack: CoreDataStack = .shared - + + var fetchTotalReportCard: @Sendable () async throws -> TotalReportCard var fetchAllSemesterGrades: @Sendable () async throws -> [SemesterGrade] var fetchGrades: @Sendable (_ year: Int, _ semester: SemesterType) async throws -> [ClassGrade] - + + var getTotalReportCard: @Sendable () async throws -> TotalReportCard var getAllSemesterGrades: () async throws -> [CDSemester] var getGrades: (_ year: Int, _ semester: String) async throws -> CDSemester? + var updateTotalReportCard: @Sendable (_ totalReportCard: TotalReportCard) async throws -> Void var updateAllSemesterGrades: (_ rusaintSemesterGrades: [GradeSummary]) async throws -> Void var updateGrades: (_ year: Int, _ semester: String, _ newLectures: [LectureDetail]) async throws -> Void var updateGPA: (_ year: Int, _ semester: String, _ gpa: Float) async throws -> Void var addGrades: (_ newSemester: GradeSummary) async throws -> Void + var deleteTotalReportCard: @Sendable () async throws -> Void var deleteAllSemesterGrades: () async throws -> Void var deleteGrades: (_ year: Int, _ semester: String) async throws -> Void } @@ -36,9 +40,23 @@ extension DependencyValues { extension GradeClient: DependencyKey { static var liveValue: GradeClient = { @Dependency(\.studentClient) var studentClient: StudentClient - + return GradeClient( - fetchAllSemesterGrades: { + fetchTotalReportCard: { + let session = try await studentClient.createSaintSession() + let courseGrades = try await CourseGradesApplicationBuilder().build(session: session).certificatedSummary(courseType: .bachelor) + let graduationRequirement = try await GraduationRequirementsApplicationBuilder().build(session: session).requirements() + let requirements = graduationRequirement.requirements.filter { $0.value.name.hasPrefix("학부-졸업학점") } + .compactMap { $0.value.requirement ?? 0} + guard let graduateCredit = requirements.first else { + throw RusaintError.invalidClientError + } + return TotalReportCard( + gpa: courseGrades.gradePointsAvarage, + earnedCredit: courseGrades.earnedCredits, + graduateCredit: Float(graduateCredit) + ) + }, fetchAllSemesterGrades: { let session = try await studentClient.createSaintSession() let response = try await CourseGradesApplicationBuilder() .build(session: session) @@ -55,10 +73,22 @@ extension GradeClient: DependencyKey { includeDetails: false) return response }, + getTotalReportCard: { + let context = coreDataStack.taskContext() + let fetchRequest: NSFetchRequest = CDTotalReportCard.fetchRequest() + + do { + let data = try context.fetch(fetchRequest) + return data.toTotalReportCard() + } catch { + print(error.localizedDescription) + return TotalReportCard(gpa: 0.00, earnedCredit: 0, graduateCredit: 0) + } + }, getAllSemesterGrades: { let context = coreDataStack.taskContext() let fetchRequest: NSFetchRequest = CDSemester.fetchRequest() - + let fetchedEntity = try context.fetch(fetchRequest) return fetchedEntity }, @@ -69,18 +99,29 @@ extension GradeClient: DependencyKey { NSPredicate(format: "year == %d", year), NSPredicate(format: "semester == %@", semester) ]) - + if let fetchedEntity = try? context.fetch(fetchRequest).first { return fetchedEntity } else { return nil } + }, updateTotalReportCard: { totalReportCard in + let context = coreDataStack.taskContext() + createTotalReportCard(gpa: totalReportCard.gpa, earnedCredit: totalReportCard.earnedCredit, graduateCredit: Float(totalReportCard.graduateCredit), in: context) + + context.performAndWait { + do { + try context.save() + } catch { + print("update [TotalReportCard] error : \(error)") + } + } }, updateAllSemesterGrades: { grades in let context = coreDataStack.taskContext() let deleteRequest = NSBatchDeleteRequest(fetchRequest: CDSemester.fetchRequest()) try context.execute(deleteRequest) - + for grade in grades { createSemester(year: grade.year, semester: grade.semester, @@ -98,15 +139,15 @@ extension GradeClient: DependencyKey { updateGrades: { year, semester, newLectures in let context = coreDataStack.taskContext() let fetchRequest: NSFetchRequest = CDSemester.fetchRequest() - + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ NSPredicate(format: "year == %d", year), NSPredicate(format: "semester == %@", semester) ]) - + if let semesterEntity = try context.fetch(fetchRequest).first { semesterEntity.removeFromLectures(semesterEntity.lectures ?? []) - + let newLectureEntities = newLectures.map { lecture in let cdLecture = CDLecture(context: context) cdLecture.code = lecture.code @@ -117,7 +158,7 @@ extension GradeClient: DependencyKey { cdLecture.professorName = lecture.professorName return cdLecture } - + newLectureEntities.forEach { semesterEntity.addToLectures($0) } context.performAndWait { do { @@ -135,7 +176,7 @@ extension GradeClient: DependencyKey { NSPredicate(format: "year == %d", year), NSPredicate(format: "semester == %@", semester) ]) - + if let fetchedEntity = try context.fetch(fetchRequest).first { fetchedEntity.gpa = gpa try context.save() @@ -143,7 +184,7 @@ extension GradeClient: DependencyKey { }, addGrades: { newSemester in let context = coreDataStack.taskContext() - + createSemester(year: newSemester.year, semester: newSemester.semester, gpa: newSemester.gpa, @@ -154,13 +195,20 @@ extension GradeClient: DependencyKey { overallStudentCount: newSemester.overallStudentCount, lectures: newSemester.lectures ?? nil, in: context) - + + try context.save() + }, + deleteTotalReportCard: { + let context = coreDataStack.taskContext() + let deleteRequest = NSBatchDeleteRequest(fetchRequest: CDTotalReportCard.fetchRequest()) + + try context.execute(deleteRequest) try context.save() }, deleteAllSemesterGrades: { let context = coreDataStack.taskContext() let deleteRequest = NSBatchDeleteRequest(fetchRequest: CDSemester.fetchRequest()) - + try context.execute(deleteRequest) try context.save() }, @@ -171,7 +219,7 @@ extension GradeClient: DependencyKey { NSPredicate(format: "year == %d", year), NSPredicate(format: "semester == %@", semester) ]) - + let fetchedEntity = try context.fetch(fetchRequest) for entity in fetchedEntity { context.delete(entity) @@ -179,6 +227,7 @@ extension GradeClient: DependencyKey { try context.save() } ) + func createSemester( year: Int, semester: String, @@ -200,7 +249,7 @@ extension GradeClient: DependencyKey { semesterEntity.semesterStudentCount = Int16(semesterStudentCount) semesterEntity.overallRank = Int16(overallRank) semesterEntity.overallStudentCount = Int16(overallStudentCount) - + let lectureEntities = lectures?.compactMap { lecture -> CDLecture? in let cdLecture = CDLecture(context: context) cdLecture.code = lecture.code @@ -213,47 +262,71 @@ extension GradeClient: DependencyKey { } lectureEntities?.forEach { semesterEntity.addToLectures($0) } } + + func createTotalReportCard( + gpa: Float, + earnedCredit: Float, + graduateCredit: Float, + in context: NSManagedObjectContext + ) { + let detail = CDTotalReportCard(context: context) + detail.gpa = gpa + detail.earnedCredit = earnedCredit + detail.graduateCredit = graduateCredit + } }() - - static let previewValue: GradeClient = GradeClient { - [ - Rusaint.SemesterGrade(year: 2024, - semester: "2 학기", - attemptedCredits: 2.0, - earnedCredits: 2.0, - pfEarnedCredits: 2.0, - gradePointsAvarage: 0.0, - gradePointsSum: 0.0, - arithmeticMean: 0.0, - semesterRank: Rusaint.UnsignedIntPair(first: 0, second: 0), - generalRank: Rusaint.UnsignedIntPair(first: 12, second: 94), - academicProbation: false, - consult: false, - flunked: false) - ] - } fetchGrades: { year, semester in - [ - Rusaint.ClassGrade(year: "2024", semester: "2 학기", code: "", className: "", gradePoints: 0.0, score: .empty, rank: "", professor: "", detail: nil) - ] - } getAllSemesterGrades: { - [ + + static let previewValue: GradeClient = Self( + fetchTotalReportCard: { + return TotalReportCard(gpa: 4.11, earnedCredit: 112, graduateCredit: 188) + }, fetchAllSemesterGrades: { + [ + SemesterGrade( + year: 2024, + semester: "", + attemptedCredits: 22.0, + earnedCredits: 4.3, + pfEarnedCredits: 3.0, + gradePointsAvarage: 3.0, + gradePointsSum: 2.0, + arithmeticMean: 2.0, + semesterRank: U32Pair(first: 57, second: 100), + generalRank: U32Pair(first: 38, second: 200), + academicProbation: false, + consult: false, + flunked: false + ) + ] + }, fetchGrades: { year, semester in + [ + Rusaint.ClassGrade(year: "2024", semester: "2 학기", code: "", className: "", gradePoints: 0.0, score: .empty, rank: "", professor: "", detail: nil) + ] + }, getTotalReportCard: { + TotalReportCard(gpa: 4.34, earnedCredit: 108, graduateCredit: 133) + }, getAllSemesterGrades: { + [ + CDSemester() + ] + }, getGrades: { year, semester in CDSemester() - ] - } getGrades: { year, semester in - CDSemester() - } updateAllSemesterGrades: { rusaintSemesterGrades in - return - } updateGrades: { year, semester, newLectures in - return - } updateGPA: { year, semester, gpa in - return - } addGrades: { newSemester in - return - } deleteAllSemesterGrades: { - return - } deleteGrades: { year, semester in - return - } + }, updateTotalReportCard: { totalReportCard in + return + }, updateAllSemesterGrades: { rusaintSemesterGrades in + return + }, updateGrades: { year, semester, newLectures in + return + }, updateGPA: { year, semester, gpa in + return + }, addGrades: { newSemester in + return + }, deleteTotalReportCard: { + return + }, deleteAllSemesterGrades: { + return + }, deleteGrades: { year, semester in + return + } + ) static let testValue: GradeClient = previewValue }