diff --git a/Projects/DSKit/Resources/Image.xcassets/person.imageset/Contents.json b/Projects/DSKit/Resources/Image.xcassets/person.imageset/Contents.json new file mode 100644 index 00000000..a550affe --- /dev/null +++ b/Projects/DSKit/Resources/Image.xcassets/person.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "profile.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "profile@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "profile@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DSKit/Resources/Image.xcassets/person.imageset/profile.png b/Projects/DSKit/Resources/Image.xcassets/person.imageset/profile.png new file mode 100644 index 00000000..1e640af3 Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/person.imageset/profile.png differ diff --git a/Projects/DSKit/Resources/Image.xcassets/person.imageset/profile@2x.png b/Projects/DSKit/Resources/Image.xcassets/person.imageset/profile@2x.png new file mode 100644 index 00000000..5ab35231 Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/person.imageset/profile@2x.png differ diff --git a/Projects/DSKit/Resources/Image.xcassets/person.imageset/profile@3x.png b/Projects/DSKit/Resources/Image.xcassets/person.imageset/profile@3x.png new file mode 100644 index 00000000..3c6a1e8d Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/person.imageset/profile@3x.png differ diff --git a/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Contents.json b/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Contents.json new file mode 100644 index 00000000..3b04c1bd --- /dev/null +++ b/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1000012460.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1000012460@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1000012460@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Group 1000012460.png b/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Group 1000012460.png new file mode 100644 index 00000000..ff6b3b25 Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Group 1000012460.png differ diff --git a/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Group 1000012460@2x.png b/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Group 1000012460@2x.png new file mode 100644 index 00000000..13bac5c4 Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Group 1000012460@2x.png differ diff --git a/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Group 1000012460@3x.png b/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Group 1000012460@3x.png new file mode 100644 index 00000000..c4e31c2f Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/speech-bubble.imageset/Group 1000012460@3x.png differ diff --git a/Projects/Domain/Sources/Model/DailyStatisticsModel.swift b/Projects/Domain/Sources/Model/DailyStatisticsModel.swift new file mode 100644 index 00000000..fbba6cdf --- /dev/null +++ b/Projects/Domain/Sources/Model/DailyStatisticsModel.swift @@ -0,0 +1,43 @@ +// +// DailyStatisticsModel.swift +// Domain +// +// Created by 김영인 on 2023/09/06. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import SwiftUI + +import Network + +public struct DailyStatisticsModel: Equatable { + public let solvedCount: Int + public let testsStatistics: [TestsStatisticsModel] +} + +public struct TestsStatisticsModel: Hashable, Equatable { + public let keymeTests: KeymeTestsInfoModel + public let avarageScore: Double? +} + +public extension StatisticsDTO { + func toDailyStatisticsModel() -> DailyStatisticsModel { + let testsStatistics = data.questionsStatistics.map { + TestsStatisticsModel( + keymeTests: KeymeTestsInfoModel( + keyword: $0.keyword, + icon: IconModel( + imageURL: $0.category.iconUrl, + color: Color.hex($0.category.color) + ) + ), + avarageScore: $0.avgScore + ) + } + + return DailyStatisticsModel( + solvedCount: data.solvedCount, + testsStatistics: testsStatistics + ) + } +} diff --git a/Projects/Domain/Sources/Model/EmptyModel.swift b/Projects/Domain/Sources/Model/EmptyModel.swift new file mode 100644 index 00000000..e6a37909 --- /dev/null +++ b/Projects/Domain/Sources/Model/EmptyModel.swift @@ -0,0 +1,16 @@ +// +// EmptyModel.swift +// Domain +// +// Created by 김영인 on 2023/09/06. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import Foundation + +public extension DailyStatisticsModel { + static var EMPTY: Self = .init( + solvedCount: 0, + testsStatistics: [] + ) +} diff --git a/Projects/Domain/Sources/Model/KeymeTestsModel.swift b/Projects/Domain/Sources/Model/KeymeTestsModel.swift index 348baf68..9af430ba 100644 --- a/Projects/Domain/Sources/Model/KeymeTestsModel.swift +++ b/Projects/Domain/Sources/Model/KeymeTestsModel.swift @@ -13,8 +13,14 @@ import Network import Kingfisher public struct KeymeTestsModel: Equatable { + public let nickname: String public let testId: Int - public let icons: [IconModel] + public let tests: [KeymeTestsInfoModel] +} + +public struct KeymeTestsInfoModel: Hashable, Equatable { + public let keyword: String + public let icon: IconModel } public struct IconModel: Equatable, Hashable { @@ -26,11 +32,18 @@ public struct IconModel: Equatable, Hashable { public extension KeymeTestsDTO { func toKeymeTestsModel() -> KeymeTestsModel { - let icons = data.questions.map { - IconModel(imageURL: $0.category.iconUrl, - color: Color.hex($0.category.color)) + let tests = data.questions.map { + KeymeTestsInfoModel( + keyword: $0.keyword, + icon: IconModel( + imageURL: $0.category.iconUrl, + color: Color.hex($0.category.color) + ) + ) } - return KeymeTestsModel(testId: data.testId, icons: icons) + return KeymeTestsModel(nickname: data.owner.nickname, + testId: data.testId, + tests: tests) } } diff --git a/Projects/Features/Sources/Home/DailyTestList/DailyTestListFeature.swift b/Projects/Features/Sources/Home/DailyTestList/DailyTestListFeature.swift new file mode 100644 index 00000000..191d0a67 --- /dev/null +++ b/Projects/Features/Sources/Home/DailyTestList/DailyTestListFeature.swift @@ -0,0 +1,66 @@ +// +// DailyTestFeature.swift +// Features +// +// Created by 김영인 on 2023/09/06. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import ComposableArchitecture + +import Domain +import Network + +public struct DailyTestListFeature: Reducer { + @Dependency(\.keymeAPIManager) private var network + + public struct State: Equatable { + let testData: KeymeTestsModel + var dailyStatistics: DailyStatisticsModel = .EMPTY + + init(testData: KeymeTestsModel) { + self.testData = testData + } + } + + public enum Action { + case onAppear + case onDisappear + case fetchDailyStatistics + case saveDailyStatistics(DailyStatisticsModel) + case shareButtonDidTap + } + + enum CancelID { + case dailyTestList + } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .onAppear: + return .send(.fetchDailyStatistics) + + case .onDisappear: + return .cancel(id: CancelID.dailyTestList) + + case .fetchDailyStatistics: + return .run { [testId = state.testData.testId] send in + let dailyStatisticsData = try await network.request(.test(.statistics(testId)), object: StatisticsDTO.self) +// let dailyStatisticsData = try await network.requestWithSampleData(.test(.statistics(testId)), object: StatisticsDTO.self) + let dailyStatistics = dailyStatisticsData.toDailyStatisticsModel() + await send(.saveDailyStatistics(dailyStatistics)) + } + .cancellable(id: CancelID.dailyTestList) + + case let .saveDailyStatistics(dailyStatistics): + state.dailyStatistics = dailyStatistics + + default: + return .none + } + + return .none + } + } +} diff --git a/Projects/Features/Sources/Home/DailyTestList/DailyTestListView.swift b/Projects/Features/Sources/Home/DailyTestList/DailyTestListView.swift new file mode 100644 index 00000000..31083275 --- /dev/null +++ b/Projects/Features/Sources/Home/DailyTestList/DailyTestListView.swift @@ -0,0 +1,116 @@ +// +// DailyTestListView.swift +// Features +// +// Created by 김영인 on 2023/09/06. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import SwiftUI + +import ComposableArchitecture + +import Core +import DSKit +import Util + +struct DailyTestListView: View { + var store: StoreOf + typealias DailyTestStore = ViewStore + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + VStack(alignment: .leading) { + Spacer() + .frame(height: 75) + + welcomeText(nickname: viewStore.testData.nickname) + + Spacer() + + HStack(spacing: 4) { + Image(uiImage: DSKitAsset.Image.person.image) + .resizable() + .frame(width: 16, height: 16) + + Text.keyme( + "\(viewStore.dailyStatistics.solvedCount)명의 친구가 문제를 풀었어요", + font: .body4 + ) + .foregroundColor(.white) + } + + dailyTestList(viewStore) + + Spacer() + } + .onAppear { + viewStore.send(.onAppear) + } + .onDisappear { + viewStore.send(.onDisappear) + } + .fullFrame() + .padding([.leading, .trailing], 16) + } + } +} + +extension DailyTestListView { + func welcomeText(nickname: String) -> some View { + Text.keyme( + "\(nickname)님 친구들의\n답변이 쌓이고 있어요!", + font: .heading1) + .foregroundColor(DSKitAsset.Color.keymeWhite.swiftUIColor) + .frame(maxWidth: .infinity, alignment: .leading) + } + + func dailyTestList(_ viewStore: DailyTestStore) -> some View { + LazyVStack(spacing: 12) { + ForEach(viewStore.dailyStatistics.testsStatistics, id: \.self) { testStatistics in + ZStack(alignment: .leading) { + Rectangle() + .foregroundColor(.white.opacity(0.05)) + .frame(height: 86) + .cornerRadius(14) + + HStack(spacing: 12) { + Spacer() + .frame(width: 14) + + ZStack { + Circle() + .foregroundColor(testStatistics.keymeTests.icon.color) + + KFImageManager.shared.toImage(url: testStatistics.keymeTests.icon.imageURL) + .scaledToFit() + .frame(width: 20) + } + .frame(width: 40, height: 40) + + VStack(alignment: .leading, spacing: 7) { + Text.keyme("\(viewStore.testData.nickname)님의 \(testStatistics.keymeTests.keyword)정도는?", + font: .body3Semibold) + .foregroundColor(.white) + + statisticsScoreText(score: testStatistics.avarageScore) + } + } + } + } + } + } +} + +extension DailyTestListView { + func statisticsScoreText(score: Double?) -> some View { + Text.keyme(score != nil ? "평균점수 | \(String(describing: score))점" : "아직 아무도 풀지 않았어요", + font: .body4) + .foregroundColor(.white.opacity(0.5)) + } +} diff --git a/Projects/Features/Sources/Home/KeymeTestHomeFeature.swift b/Projects/Features/Sources/Home/HomeFeature.swift similarity index 68% rename from Projects/Features/Sources/Home/KeymeTestHomeFeature.swift rename to Projects/Features/Sources/Home/HomeFeature.swift index 08e6a2c0..509396ca 100644 --- a/Projects/Features/Sources/Home/KeymeTestHomeFeature.swift +++ b/Projects/Features/Sources/Home/HomeFeature.swift @@ -1,5 +1,5 @@ // -// KeymeTestHomeFeature.swift +// HomeFeature.swift // Features // // Created by 이영빈 on 2023/08/30. @@ -11,13 +11,14 @@ import Domain import Network import Foundation -public struct KeymeTestsHomeFeature: Reducer { +public struct HomeFeature: Reducer { @Dependency(\.keymeAPIManager) private var network // 테스트를 아직 풀지 않았거나, 풀었거나 2가지 케이스만 존재 public struct State: Equatable { @PresentationState var alertState: AlertState? - @PresentationState var testStartViewState: KeymeTestsStartFeature.State? + @PresentationState var startTestState: StartTestFeature.State? + @PresentationState var dailyTestListState: DailyTestListFeature.State? var authorizationToken: String? { @Dependency(\.keymeAPIManager.authorizationToken) var authorizationToken return authorizationToken @@ -27,22 +28,28 @@ public struct KeymeTestsHomeFeature: Reducer { struct View: Equatable { let nickname: String var dailyTestId: Int? + var isSolvedDailyTest: Bool = false + var testId: Int? } - init(nickname: String) { + public init(nickname: String) { self.view = View(nickname: nickname) } } public enum Action { + case onDisappear case requestLogout case fetchDailyTests + case saveIsSolved(Bool) + case saveTestId(Int) case showTestStartView(testData: KeymeTestsModel) case showErrorAlert(HomeFeatureError) - + case alert(PresentationAction) - case startTest(PresentationAction) + case startTest(PresentationAction) + case dailyTestList(PresentationAction) enum View {} @@ -62,28 +69,43 @@ public struct KeymeTestsHomeFeature: Reducer { } } + public init() { } + public var body: some ReducerOf { Reduce { state, action in switch action { case .fetchDailyTests: return .run { send in let fetchedTest = try await network.request(.test(.daily), object: KeymeTestsDTO.self) +// let fetchedTest = try await network.requestWithSampleData(.test(.onboarding), object: KeymeTestsDTO.self) let testData = fetchedTest.toKeymeTestsModel() - + await send(.saveIsSolved(fetchedTest.data.testResultId != nil)) + await send(.saveTestId(testData.testId)) await send(.showTestStartView(testData: testData)) } + case let .saveIsSolved(isSolved): + state.view.isSolvedDailyTest = isSolved + + case let .saveTestId(testId): + state.view.testId = testId + case .showTestStartView(let testData): state.view.dailyTestId = testData.testId guard let authorizationToken = state.authorizationToken else { return .send(.showErrorAlert(.cannotGetAuthorizationInformation)) } - state.testStartViewState = KeymeTestsStartFeature.State( + state.startTestState = StartTestFeature.State( nickname: state.view.nickname, testData: testData, - authorizationToken: authorizationToken) + authorizationToken: authorizationToken + ) + state.dailyTestListState = + DailyTestListFeature.State( + testData: testData + ) case .showErrorAlert(let error): if case .cannotGetAuthorizationInformation = error { state.alertState = AlertState( @@ -108,8 +130,11 @@ public struct KeymeTestsHomeFeature: Reducer { return .none } - .ifLet(\.$testStartViewState, action: /Action.startTest) { - KeymeTestsStartFeature() + .ifLet(\.$startTestState, action: /Action.startTest) { + StartTestFeature() + } + .ifLet(\.$dailyTestListState, action: /Action.dailyTestList) { + DailyTestListFeature() } } } diff --git a/Projects/Features/Sources/Home/HomeView.swift b/Projects/Features/Sources/Home/HomeView.swift new file mode 100644 index 00000000..c452a923 --- /dev/null +++ b/Projects/Features/Sources/Home/HomeView.swift @@ -0,0 +1,124 @@ +// +// HomeView.swift +// Features +// +// Created by 이영빈 on 2023/08/30. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import SwiftUI + +import ComposableArchitecture + +import DSKit +import Domain +import Network + +public struct HomeView: View { + @State var sharedURL: ActivityViewController.SharedURL? + + public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithViewStore(store, observe: { $0.view }) { viewStore in + ZStack(alignment: .center) { + DSKitAsset.Color.keymeBlack.swiftUIColor.ignoresSafeArea() + + if(viewStore.dailyTestId != nil) { + VStack { + if(viewStore.isSolvedDailyTest) { + dailyTestListView + } else { + startTestView + } + + Spacer() + + bottomButton(viewStore) + + Spacer() + .frame(height: 26) + } + } + } + .onAppear { + if viewStore.dailyTestId == nil { + viewStore.send(.fetchDailyTests) + } + } + } + .alert(store: store.scope(state: \.$alertState, action: HomeFeature.Action.alert)) + } +} + +extension HomeView { + var startTestView: some View { + let startTestStore = store.scope( + state: \.$startTestState, + action: HomeFeature.Action.startTest + ) + + return IfLetStore(startTestStore) { store in + StartTestView(store: store) + } else: { + Circle() + .strokeBorder(.white.opacity(0.3), lineWidth: 1) + .background(Circle().foregroundColor(.white.opacity(0.3))) + .frame(width: 280 * 0.8, height: 280 * 0.8) + } + } + + var dailyTestListView: some View { + let dailyTestListStore = store.scope( + state: \.$dailyTestListState, + action: HomeFeature.Action.dailyTestList + ) + + return IfLetStore(dailyTestListStore) { store in + DailyTestListView(store: store) + } + } +} + +extension HomeView { + // 하단 버튼 (시작하기 / 공유하기) + func bottomButton(_ viewStore: ViewStore) -> some View { + ZStack { + Rectangle() + .cornerRadius(16) + .foregroundColor(DSKitAsset.Color.keymeWhite.swiftUIColor) + + Text(viewStore.isSolvedDailyTest ? "친구에게 공유하기" : "시작하기") + .font(Font(DSKitFontFamily.Pretendard.bold.font(size: 18))) + .foregroundColor(.black) + } + .padding([.leading, .trailing], 16) + .frame(height: 60) + .onTapGesture { + Task { + if viewStore.isSolvedDailyTest { +// let url = "https://keyme-frontend.vercel.app/test/17" + let url = "https://keyme-frontend.vercel.app/test/\(viewStore.testId)" + let shortURL = try await ShortUrlAPIManager.shared.request( + .shortenURL(longURL: url), + object: BitlyResponse.self).link + + sharedURL = ActivityViewController.SharedURL("www.example.com") + } else { + viewStore.send(.startTest(.presented(.startButtonDidTap))) + } + } + } + .sheet(item: $sharedURL) { item in + ActivityViewController( + isPresented: Binding( + get: { sharedURL != nil }, + set: { if !$0 { sharedURL = nil } }), + activityItems: [item.sharedURL]) + } + } +} diff --git a/Projects/Features/Sources/Home/KeymeTestHomeView.swift b/Projects/Features/Sources/Home/KeymeTestHomeView.swift deleted file mode 100644 index 6f5db17c..00000000 --- a/Projects/Features/Sources/Home/KeymeTestHomeView.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// KeymeTestHomeView.swift -// Features -// -// Created by 이영빈 on 2023/08/30. -// Copyright © 2023 team.humanwave. All rights reserved. -// - -import ComposableArchitecture -import DSKit -import SwiftUI - -struct KeymeTestsHomeView: View { - var store: StoreOf - - init(store: StoreOf) { - self.store = store - } - - var body: some View { - WithViewStore(store, observe: { $0.view }) { viewStore in - ZStack(alignment: .center) { - DSKitAsset.Color.keymeBlack.swiftUIColor.ignoresSafeArea() - - VStack(alignment: .leading) { - // Filler - Spacer().frame(height: 75) - - welcomeText(nickname: viewStore.nickname) - .foregroundColor(DSKitAsset.Color.keymeWhite.swiftUIColor) - - Spacer() - } - .fullFrame() - .padding(.horizontal, 16) - - // 테스트 뷰 - testView - - // 결과 화면 표시도 생각 - - } - .onAppear { - if viewStore.dailyTestId == nil { - viewStore.send(.fetchDailyTests) - } - } - } - .alert(store: store.scope(state: \.$alertState, action: KeymeTestsHomeFeature.Action.alert)) - } -} - -extension KeymeTestsHomeView { - var testView: some View { - let startTestStore = store.scope( - state: \.$testStartViewState, - action: KeymeTestsHomeFeature.Action.startTest) - - return IfLetStore(startTestStore) { store in - KeymeTestsStartView(store: store) - } else: { - Circle() - .strokeBorder(.white.opacity(0.3), lineWidth: 1) - .background(Circle().foregroundColor(.white.opacity(0.3))) - .frame(width: 280, height: 280) - } - } - - func welcomeText(nickname: String) -> some View { - Text.keyme( - "환영해요 \(nickname)님!", -// "환영해요 \(viewStore.nickname)님!\n이제 문제를 풀어볼까요?", - font: .heading1) - } - -} diff --git a/Projects/Features/Sources/Home/KeymeTestsStartView.swift b/Projects/Features/Sources/Home/KeymeTestsStartView.swift deleted file mode 100644 index 1276df28..00000000 --- a/Projects/Features/Sources/Home/KeymeTestsStartView.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// KeymeTestsStartView.swift -// Features -// -// Created by 김영인 on 2023/08/12. -// Copyright © 2023 team.humanwave. All rights reserved. -// - -import SwiftUI - -import ComposableArchitecture - -import Core -import Util -import DSKit - -public struct KeymeTestsStartView: View { - public var store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - startTestsButton(viewStore) - .onTapGesture { - viewStore.send(.startButtonDidTap) - } - .navigationDestination( - store: store.scope( - state: \.$keymeTests, - action: KeymeTestsStartFeature.Action.keymeTests - ), destination: { store in - KeymeTestsView(store: store) - .ignoresSafeArea(.all) - .transition(.scale.animation(.easeIn)) - }) - } - .onAppear { - store.send(.viewWillAppear) - } - } - - func startTestsButton(_ viewStore: ViewStore) -> some View { - ZStack { - Circle() - .strokeBorder(.white.opacity(0.3), lineWidth: 1) - .background(Circle().foregroundColor(.white.opacity(0.3))) - .frame(width: 280, height: 280) - .scaleEffect(viewStore.isAnimating ? 1.0 : 0.8) - .shadow(color: .white.opacity(0.3), radius: 30, x: 0, y: 10) - .animation(.spring(response: 0.8).repeatForever(), value: viewStore.isAnimating) - - Circle() - .foregroundColor(viewStore.icon.color) - .frame(width: 110, height: 110) - .scaleEffect(viewStore.isAnimating ? 1.0 : 0.001) - .animation(.spring(response: 0.8).repeatForever(), value: viewStore.isAnimating) - - KFImageManager.shared.toImage(url: viewStore.icon.imageURL) - .frame(width: 30, height: 30) - .scaledToFit() - .scaleEffect(viewStore.isAnimating ? 1.0 : 0.001) - .animation(.spring(response: 0.8).repeatForever(), value: viewStore.isAnimating) - } - } -} diff --git a/Projects/Features/Sources/Home/KeymeTestsStartFeature.swift b/Projects/Features/Sources/Home/StartTest/StartTestFeature.swift similarity index 61% rename from Projects/Features/Sources/Home/KeymeTestsStartFeature.swift rename to Projects/Features/Sources/Home/StartTest/StartTestFeature.swift index 86bae609..9c0773e3 100644 --- a/Projects/Features/Sources/Home/KeymeTestsStartFeature.swift +++ b/Projects/Features/Sources/Home/StartTest/StartTestFeature.swift @@ -1,5 +1,5 @@ // -// KeymeTestsStartFeature.swift +// StartTestFeature.swift // Features // // Created by 김영인 on 2023/08/12. @@ -11,14 +11,14 @@ import ComposableArchitecture import Domain -public struct KeymeTestsStartFeature: Reducer { +public struct StartTestFeature: Reducer { public struct State: Equatable { public let nickname: String public let testData: KeymeTestsModel let authorizationToken: String public var icon: IconModel = .EMPTY - @PresentationState public var keymeTests: KeymeTestsFeature.State? + @PresentationState public var keymeTestsState: KeymeTestsFeature.State? public var isAnimating: Bool = false public init(nickname: String, testData: KeymeTestsModel, authorizationToken: String) { @@ -29,11 +29,17 @@ public struct KeymeTestsStartFeature: Reducer { } public enum Action { - case viewWillAppear + case onAppear + case onDisappear case startAnimation([IconModel]) case setIcon(IconModel) case startButtonDidTap case keymeTests(PresentationAction) + case toggleAnimation(IconModel) + } + + enum CancelID { + case startTest } @Dependency(\.continuousClock) var clock @@ -44,33 +50,45 @@ public struct KeymeTestsStartFeature: Reducer { public var body: some Reducer { Reduce { state, action in switch action { - case .viewWillAppear: + case .onAppear: + return .send(.startAnimation(state.testData.tests.map { $0.icon })) + + case .onDisappear: state.isAnimating = true - return .send(.startAnimation(state.testData.icons)) + return .cancel(id: CancelID.startTest) case .startAnimation(let icons): - guard state.isAnimating == false else { - return .none - } return .run { send in repeat { for icon in icons { - await send(.setIcon(icon)) - try await self.clock.sleep(for: .seconds(1.595)) + await send(.toggleAnimation(icon)) + try await self.clock.sleep(for: .seconds(0.85)) } } while true } + .cancellable(id: CancelID.startTest) case let .setIcon(icon): state.icon = icon case .startButtonDidTap: let url = "https://keyme-frontend.vercel.app/test/\(state.testData.testId)" - state.keymeTests = KeymeTestsFeature.State(url: url, authorizationToken: state.authorizationToken) + state.keymeTestsState = KeymeTestsFeature.State(url: url, authorizationToken: state.authorizationToken) case .keymeTests(.presented(.close)): - state.keymeTests = nil + state.keymeTestsState = nil + + case let .toggleAnimation(icon): + state.isAnimating.toggle() + + return .run {[isAnimating = state.isAnimating] send in + while !isAnimating { + try await self.clock.sleep(for: .seconds(1)) + } + await send(.setIcon(icon)) + } + .cancellable(id: CancelID.startTest) default: break @@ -78,7 +96,7 @@ public struct KeymeTestsStartFeature: Reducer { return .none } - .ifLet(\.$keymeTests, action: /Action.keymeTests) { + .ifLet(\.$keymeTestsState, action: /Action.keymeTests) { KeymeTestsFeature() } } diff --git a/Projects/Features/Sources/Home/StartTest/StartTestView.swift b/Projects/Features/Sources/Home/StartTest/StartTestView.swift new file mode 100644 index 00000000..40997c50 --- /dev/null +++ b/Projects/Features/Sources/Home/StartTest/StartTestView.swift @@ -0,0 +1,95 @@ +// +// StartTestView.swift +// Features +// +// Created by 김영인 on 2023/08/12. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import SwiftUI + +import ComposableArchitecture + +import Core +import Util +import DSKit + +public struct StartTestView: View { + public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + VStack { + Spacer() + .frame(height: 75) + + welcomeText(nickname: viewStore.nickname) + + Spacer() + + startTestsButton(viewStore) + .onTapGesture { + viewStore.send(.startButtonDidTap) + } + .navigationDestination( + store: store.scope( + state: \.$keymeTestsState, + action: StartTestFeature.Action.keymeTests + ), destination: { store in + KeymeTestsView(store: store) + .ignoresSafeArea(.all) + .transition(.scale.animation(.easeIn)) + }) + + Spacer() + Spacer() + } + } + .onAppear { + store.send(.onAppear) + } + .onDisappear { + store.send(.onDisappear) + } + } +} + +extension StartTestView { + func startTestsButton(_ viewStore: ViewStore) -> some View { + ZStack { + Circle() + .strokeBorder(.white.opacity(0.3), lineWidth: 1) + .background(Circle().foregroundColor(.white.opacity(0.3))) + .frame(width: 280, height: 280) + .scaleEffect(viewStore.isAnimating ? 1.0 : 0.8) + .shadow(color: .white.opacity(0.3), radius: 30, x: 0, y: 10) + .animation(.spring(response: 0.85), value: viewStore.isAnimating) + + Circle() + .foregroundColor(viewStore.icon.color) + .frame(width: 110, height: 110) + .scaleEffect(viewStore.isAnimating ? 1.0 : 0.001) + .animation(.spring(response: 0.8), value: viewStore.isAnimating) + + KFImageManager.shared.toImage(url: viewStore.icon.imageURL) + .frame(width: 30, height: 30) + .scaledToFit() + .scaleEffect(viewStore.isAnimating ? 1.0 : 0.001) + .animation(.spring(response: 0.8), value: viewStore.isAnimating) + } + } + + func welcomeText(nickname: String) -> some View { + Text.keyme( + "환영해요 \(nickname)님!\n이제 문제를 풀어볼까요?", + font: .heading1) + .foregroundColor(DSKitAsset.Color.keymeWhite.swiftUIColor) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 16) + } +} diff --git a/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift b/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift index 0f916aab..0d30fe03 100644 --- a/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift +++ b/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift @@ -39,6 +39,7 @@ public struct KeymeTestsFeature: Reducer { public enum View: Equatable { case showResult(data: KeymeWebViewModel) case closeButtonTapped + case closeWebView } public enum Alert: Equatable { @@ -83,6 +84,9 @@ public struct KeymeTestsFeature: Reducer { } )) } + + case .view(.closeWebView): + return .send(.close) case .submit: return .none diff --git a/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift b/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift index a506f2fd..2b3e587f 100644 --- a/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift +++ b/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift @@ -29,6 +29,9 @@ public struct KeymeTestsView: View { .onTestSubmitted { testResult in viewStore.send(.showResult(data: testResult)) } + .onCloseWebView { + viewStore.send(.closeWebView) + } .toolbar(.hidden, for: .navigationBar) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Projects/Features/Sources/MainPage/MainPageFeature.swift b/Projects/Features/Sources/MainPage/MainPageFeature.swift index 68409405..c262e549 100644 --- a/Projects/Features/Sources/MainPage/MainPageFeature.swift +++ b/Projects/Features/Sources/MainPage/MainPageFeature.swift @@ -12,7 +12,7 @@ import Core public struct MainPageFeature: Reducer { public struct State: Equatable { - @Box var home: KeymeTestsHomeFeature.State + @Box var home: HomeFeature.State @Box var myPage: MyPageFeature.State public init(userId: Int, nickname: String) { @@ -22,13 +22,13 @@ public struct MainPageFeature: Reducer { } public enum Action { - case home(KeymeTestsHomeFeature.Action) + case home(HomeFeature.Action) case myPage(MyPageFeature.Action) } public var body: some Reducer { Scope(state: \.home, action: /Action.home) { - KeymeTestsHomeFeature() + HomeFeature() } Scope(state: \.myPage, action: /Action.myPage) { diff --git a/Projects/Features/Sources/MainPage/MainPageView.swift b/Projects/Features/Sources/MainPage/MainPageView.swift index d218d917..173c0cc4 100644 --- a/Projects/Features/Sources/MainPage/MainPageView.swift +++ b/Projects/Features/Sources/MainPage/MainPageView.swift @@ -28,7 +28,7 @@ struct KeymeMainView: View { NavigationStack { WithViewStore(store, observe: { $0 }) { _ in TabView(selection: $selectedTab) { - KeymeTestsHomeView(store: store.scope(state: \.home, action: MainPageFeature.Action.home)) + HomeView(store: store.scope(state: \.home, action: MainPageFeature.Action.home)) .tabItem { homeTabImage } diff --git a/Projects/Keyme/Sources/KeymeApp.swift b/Projects/Keyme/Sources/KeymeApp.swift index 5adfa321..4c3dd5d7 100644 --- a/Projects/Keyme/Sources/KeymeApp.swift +++ b/Projects/Keyme/Sources/KeymeApp.swift @@ -19,12 +19,17 @@ struct KeymeApp: App { var body: some Scene { WindowGroup { RootView() - .onOpenURL { url in - print(url) + .onOpenURL(perform: { url in if (AuthApi.isKakaoTalkLoginUrl(url)) { - _ = AuthController.handleOpenUrl(url: url) + AuthController.handleOpenUrl(url: url) } - } + }) + +// // 홈 뷰 테스트용 +// let keymeStore = Store(initialState: HomeFeature.State(nickname: "영인")) { +// HomeFeature() +// } +// HomeView(store: keymeStore) } } } diff --git a/Projects/Network/Sources/DTO/KeymeTestsDTO.swift b/Projects/Network/Sources/DTO/KeymeTestsDTO.swift index 0aca7b0b..f13cabf4 100644 --- a/Projects/Network/Sources/DTO/KeymeTestsDTO.swift +++ b/Projects/Network/Sources/DTO/KeymeTestsDTO.swift @@ -17,7 +17,7 @@ public struct KeymeTestsDTO: Codable { public let owner: Owner public let questions: [Question] public let testId: Int - let testResultId: Int? + public let testResultId: Int? public let title: String } @@ -29,14 +29,14 @@ public struct KeymeTestsDTO: Codable { public struct Question: Codable { public let category: Category - let keyword: String + public let keyword: String let questionId: Int let title: String } +} - public struct Category: Codable { - public let color: String - public let iconUrl: String - let name: String - } +public struct Category: Codable { + public let color: String + public let iconUrl: String + let name: String } diff --git a/Projects/Network/Sources/DTO/StatisticsDTO.swift b/Projects/Network/Sources/DTO/StatisticsDTO.swift new file mode 100644 index 00000000..4d2a94a3 --- /dev/null +++ b/Projects/Network/Sources/DTO/StatisticsDTO.swift @@ -0,0 +1,29 @@ +// +// StatisticsDTO.swift +// Network +// +// Created by 김영인 on 2023/09/06. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import Foundation + +public struct StatisticsDTO: Codable { + let code: Int + let message: String + public let data: StatisticsData + + public struct StatisticsData: Codable { + public let averageRate: Int? + public let questionsStatistics: [QuestionsStatisticsData] + public let solvedCount: Int + } + + public struct QuestionsStatisticsData: Codable { + public let category: Category + public let keyword, title: String + public let avgScore: Double? + public let questionId: Int + public let myScore: Int? + } +} diff --git a/Projects/Network/Sources/Network/API/KeymeAPI.swift b/Projects/Network/Sources/Network/API/KeymeAPI.swift index ef87a755..314aeeb7 100644 --- a/Projects/Network/Sources/Network/API/KeymeAPI.swift +++ b/Projects/Network/Sources/Network/API/KeymeAPI.swift @@ -82,12 +82,11 @@ extension KeymeAPI: BaseAPI { } public var sampleData: Data { - """ - { - "id": 1, - "name": "Test Item" + switch self { + case let .test(testAPI): + return testAPI.sampleData + default: + return Data() } - """ - .data(using: .utf8)! } } diff --git a/Projects/Network/Sources/Network/API/KeymeTestsAPI.swift b/Projects/Network/Sources/Network/API/KeymeTestsAPI.swift index e5308205..1c0905af 100644 --- a/Projects/Network/Sources/Network/API/KeymeTestsAPI.swift +++ b/Projects/Network/Sources/Network/API/KeymeTestsAPI.swift @@ -13,6 +13,7 @@ import Moya public enum KeymeTestsAPI { case onboarding case daily + case statistics(Int) case result(Int) case register(String) } @@ -24,6 +25,8 @@ extension KeymeTestsAPI: BaseAPI { return "tests/onboarding" case .daily: return "tests/daily" + case let .statistics(testId): + return "tests/\(testId)/statistics" case let .result(testResultId): return "tests/result/\(testResultId)" case .register: @@ -93,14 +96,65 @@ extension KeymeTestsAPI: BaseAPI { "title": "님은 길에서 도를 아십니까와 마주쳤을때 무시하고 지나간다", "keyword": "마이웨이", "category": { - "iconUrl": "https://keyme-ec2-access-s3.s3.ap-northeast-2.amazonaws.com/icon/passion.png", + "iconUrl": "https://keyme-ec2-access-s3.s3.ap-northeast-2.amazonaws.com/icon/intelligence.png", "name": "PASSION", - "color": "F37952" + "color": "D6EC63" } } ] } } + """ + .data(using: .utf8)! + case .statistics: + return + """ + { + "code": 200, + "data": { + "averageRate": 0, + "questionsStatistics": [ + { + "avgScore": 3.5, + "category": { + "iconUrl": "https://keyme-ec2-access-s3.s3.ap-northeast-2.amazonaws.com/icon/money.png", + "name": "MONEY", + "color": "568049" + }, + "keyword": "돈관리 마스터", + "myScore": 0, + "questionId": 0, + "title": "불의를 보면 참지 않는다" + }, + { + "avgScore": 4.5, + "category": { + "iconUrl": "https://keyme-ec2-access-s3.s3.ap-northeast-2.amazonaws.com/icon/passion.png", + "name": "PASSION", + "color": "F37952" + }, + "keyword": "참군인", + "myScore": 0, + "questionId": 0, + "title": "불의를 보면 참지 않는다" + }, + { + "avgScore": 5.5, + "category": { + "iconUrl": "https://keyme-ec2-access-s3.s3.ap-northeast-2.amazonaws.com/icon/intelligence.png", + "name": "INTELLIGENCE", + "color": "D6EC63" + }, + "keyword": "무념무상", + "myScore": 0, + "questionId": 0, + "title": "불의를 보면 참지 않는다" + } + ], + "solvedCount": 0 + }, + "message": "SUCCESS" + } """ .data(using: .utf8)! case .result: diff --git a/Projects/Network/Sources/Network/Manager/KeymeAPIManager.swift b/Projects/Network/Sources/Network/Manager/KeymeAPIManager.swift index f9c44f23..b7a1b702 100644 --- a/Projects/Network/Sources/Network/Manager/KeymeAPIManager.swift +++ b/Projects/Network/Sources/Network/Manager/KeymeAPIManager.swift @@ -56,6 +56,13 @@ extension KeymeAPIManager: APIRequestable { return decoded } + + public func requestWithSampleData(_ api: KeymeAPI, object: T.Type) async throws -> T { + let response = api.sampleData + let decoded = try decoder.decode(T.self, from: response) + + return decoded + } public func request(_ api: KeymeAPI, object: T.Type) -> AnyPublisher { core.request(api).map(T.self)