From 209d29130c4b6d4fad509cf9087e79773ddbabe5 Mon Sep 17 00:00:00 2001 From: enebin Date: Sun, 10 Sep 2023 20:49:58 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A0=95=EB=A7=90=20=EC=97=AC=EB=9F=AC?= =?UTF-8?q?=EA=B0=80=EC=A7=80=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../XCConfig/App/PROD.xcconfig.encrypted | 2 +- .../ProjectDescriptionHelpers/InfoPlist.swift | 4 +- .../Sources/Utility/Function/ImageSaver.swift | 33 ++ .../Core/Sources/Utility/UI/ReverseMask.swift | 26 ++ .../mypage_empty.imageset/Contents.json | 23 ++ .../mypage_empty.imageset/mypage_empty.png | Bin 0 -> 2583 bytes .../mypage_empty.imageset/mypage_empty@2x.png | Bin 0 -> 5089 bytes .../mypage_empty.imageset/mypage_empty@3x.png | Bin 0 -> 7589 bytes .../Domain/Sources/Model/KeymeWebModel.swift | 2 +- .../Home/StartTest/StartTestFeature.swift | 24 +- .../Home/StartTest/StartTestView.swift | 3 + .../KeymeTests/KeymeTestsFeature.swift | 10 + .../Sources/KeymeTests/KeymeTestsView.swift | 1 + .../KeymeTests/Result/TestResultView.swift | 5 +- .../Sources/MainPage/MainPageFeature.swift | 3 +- .../Sources/MyPage/MyPageFeature.swift | 55 +++- .../Features/Sources/MyPage/MyPageView+.swift | 86 +++-- .../Features/Sources/MyPage/MyPageView.swift | 303 ++++++++++++------ .../Onboarding/OnboardingFeature.swift | 4 +- .../Sources/Onboarding/OnboardingView.swift | 16 +- .../Registration/RegisterFeature.swift | 4 +- .../Registration/RegistrationView.swift | 2 +- .../Features/Sources/Root/RootFeature.swift | 3 +- .../Sources/Setting/SettingFeature.swift | 23 +- .../Sources/ShareSheet/ShareSheetView.swift | 64 +++- .../Sources/DTO/VerifyNicknameDTO.swift | 2 +- .../Sources/Network/API/KeymeAPI.swift | 7 + .../Sources/Network/API/SettingAPI.swift | 48 +++ .../Sources/Network/API/ShortUrlAPI.swift | 2 + .../Network/Manager/ShortUrlAPIManager.swift | 40 ++- 30 files changed, 631 insertions(+), 164 deletions(-) create mode 100644 Projects/Core/Sources/Utility/Function/ImageSaver.swift create mode 100644 Projects/Core/Sources/Utility/UI/ReverseMask.swift create mode 100644 Projects/DSKit/Resources/Image.xcassets/mypage_empty.imageset/Contents.json create mode 100644 Projects/DSKit/Resources/Image.xcassets/mypage_empty.imageset/mypage_empty.png create mode 100644 Projects/DSKit/Resources/Image.xcassets/mypage_empty.imageset/mypage_empty@2x.png create mode 100644 Projects/DSKit/Resources/Image.xcassets/mypage_empty.imageset/mypage_empty@3x.png create mode 100644 Projects/Network/Sources/Network/API/SettingAPI.swift diff --git a/Encrypted/XCConfig/App/PROD.xcconfig.encrypted b/Encrypted/XCConfig/App/PROD.xcconfig.encrypted index 5a5edbbd..7387fc81 100644 --- a/Encrypted/XCConfig/App/PROD.xcconfig.encrypted +++ b/Encrypted/XCConfig/App/PROD.xcconfig.encrypted @@ -1 +1 @@ -dBJؠLH 8v2cmH "?^F1Xno^ |т2x>SFgtmz \ԌuQ&=OpVfazuc)!wW0+SXl>7{mD@Z]增o_{ZEx6 l}5`W.?ho>Hˈ4: SѢC^7L \ No newline at end of file +dBJؠLH 8v2cmH "?^F1Xno^ |т2x>SFgtmz \ԌuQ&=OpVfazuc)!wW0+SXl>7{mD@F5oaP`9#{4AW[i KW5\@Ѭm6C՗ԅ*\6i5LrX=(E Void)? + + public func save(_ image: UIImage, completion: @escaping (Error?) -> Void) { + UIImageWriteToSavedPhotosAlbum(image, self, #selector(saveError), nil) + self.completion = completion + } + + @objc private func saveError( + _ image: UIImage, + didFinishSavingWithError error: Error?, + contextInfo: UnsafeRawPointer + ) { + DispatchQueue.main.async { + if let error { + self.completion?(error) + } else { + self.completion?(nil) + } + } + } +} diff --git a/Projects/Core/Sources/Utility/UI/ReverseMask.swift b/Projects/Core/Sources/Utility/UI/ReverseMask.swift new file mode 100644 index 00000000..acb569a2 --- /dev/null +++ b/Projects/Core/Sources/Utility/UI/ReverseMask.swift @@ -0,0 +1,26 @@ +// +// ReverseMask.swift +// Core +// +// Created by Young Bin on 2023/09/10. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import SwiftUI + +public extension View { + @inlinable func reverseMask( + alignment: Alignment = .center, + @ViewBuilder _ mask: () -> Mask + ) -> some View { + self.mask( + ZStack(alignment: .center) { + Rectangle() + + mask() + .blendMode(.destinationOut) + } + .compositingGroup() + ) + } +} diff --git a/Projects/DSKit/Resources/Image.xcassets/mypage_empty.imageset/Contents.json b/Projects/DSKit/Resources/Image.xcassets/mypage_empty.imageset/Contents.json new file mode 100644 index 00000000..49ac25e5 --- /dev/null +++ b/Projects/DSKit/Resources/Image.xcassets/mypage_empty.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "mypage_empty.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "mypage_empty@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "mypage_empty@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DSKit/Resources/Image.xcassets/mypage_empty.imageset/mypage_empty.png b/Projects/DSKit/Resources/Image.xcassets/mypage_empty.imageset/mypage_empty.png new file mode 100644 index 0000000000000000000000000000000000000000..dbb7c92bf934d279898f34fb34935c7b09e1f1a7 GIT binary patch literal 2583 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 DxM^@g literal 0 HcmV?d00001 diff --git a/Projects/DSKit/Resources/Image.xcassets/mypage_empty.imageset/mypage_empty@3x.png b/Projects/DSKit/Resources/Image.xcassets/mypage_empty.imageset/mypage_empty@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..b455e22648405851eeb341ceabd89ebc9d728333 GIT binary patch literal 7589 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 literal 0 HcmV?d00001 diff --git a/Projects/Domain/Sources/Model/KeymeWebModel.swift b/Projects/Domain/Sources/Model/KeymeWebModel.swift index a5f8ce3c..f981b7be 100644 --- a/Projects/Domain/Sources/Model/KeymeWebModel.swift +++ b/Projects/Domain/Sources/Model/KeymeWebModel.swift @@ -10,6 +10,6 @@ import Foundation public struct KeymeWebViewModel: Codable, Equatable { public let matchRate: Float - public let resultCode: String + public let resultCode: String? public let testResultId: Int } diff --git a/Projects/Features/Sources/Home/StartTest/StartTestFeature.swift b/Projects/Features/Sources/Home/StartTest/StartTestFeature.swift index 9c0773e3..aa9e7fa2 100644 --- a/Projects/Features/Sources/Home/StartTest/StartTestFeature.swift +++ b/Projects/Features/Sources/Home/StartTest/StartTestFeature.swift @@ -12,6 +12,8 @@ import ComposableArchitecture import Domain public struct StartTestFeature: Reducer { + enum CancelID { case startAnimation } + public struct State: Equatable { public let nickname: String public let testData: KeymeTestsModel @@ -32,6 +34,7 @@ public struct StartTestFeature: Reducer { case onAppear case onDisappear case startAnimation([IconModel]) + case stopAnimation case setIcon(IconModel) case startButtonDidTap case keymeTests(PresentationAction) @@ -58,17 +61,24 @@ public struct StartTestFeature: Reducer { return .cancel(id: CancelID.startTest) case .startAnimation(let icons): - return .run { send in - repeat { - for icon in icons { - await send(.toggleAnimation(icon)) - try await self.clock.sleep(for: .seconds(0.85)) - } - } while true + try await withTaskCancellation( + id: CancelID.startAnimation, + cancelInFlight: true + ) { + repeat { + for icon in icons { + await send(.toggleAnimation(icon)) + try await self.clock.sleep(for: .seconds(0.85)) + } + } while true + } } .cancellable(id: CancelID.startTest) + case .stopAnimation: + return .cancel(id: CancelID.startAnimation) + case let .setIcon(icon): state.icon = icon diff --git a/Projects/Features/Sources/Home/StartTest/StartTestView.swift b/Projects/Features/Sources/Home/StartTest/StartTestView.swift index e343f1d3..098b5f11 100644 --- a/Projects/Features/Sources/Home/StartTest/StartTestView.swift +++ b/Projects/Features/Sources/Home/StartTest/StartTestView.swift @@ -54,6 +54,9 @@ public struct StartTestView: View { .onDisappear { store.send(.onDisappear) } + .onDisappear { + store.send(.stopAnimation) + } } } diff --git a/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift b/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift index 0d30fe03..303711bc 100644 --- a/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift +++ b/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift @@ -36,6 +36,8 @@ public struct KeymeTestsFeature: Reducer { case view(View) case alert(PresentationAction) + case showErrorAlert(message: String) + public enum View: Equatable { case showResult(data: KeymeWebViewModel) case closeButtonTapped @@ -78,6 +80,11 @@ public struct KeymeTestsFeature: Reducer { case .view(.showResult(let data)): return .run { [resultCode = data.resultCode] send in + guard let resultCode else { + await send(.showErrorAlert(message: "데이터 조회 중 오류가 발생했어요. 잠시 후 다시 시도해주세요.")) + return + } + await send(.postResult( TaskResult { try await self.keymeTestsClient.postTestResult(resultCode) @@ -91,6 +98,9 @@ public struct KeymeTestsFeature: Reducer { case .submit: return .none + case .showErrorAlert(let message): + state.alertState = AlertState(title: TextState("오류가 발생했어요"), message: TextState(message)) + case .alert(.presented(.closeTest)): return .send(.close) diff --git a/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift b/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift index 2b3e587f..ad26a62c 100644 --- a/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift +++ b/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift @@ -36,5 +36,6 @@ public struct KeymeTestsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } + .alert(store: store.scope(state: \.$alertState, action: KeymeTestsFeature.Action.alert)) } } diff --git a/Projects/Features/Sources/KeymeTests/Result/TestResultView.swift b/Projects/Features/Sources/KeymeTests/Result/TestResultView.swift index 2b21d327..89f689b2 100644 --- a/Projects/Features/Sources/KeymeTests/Result/TestResultView.swift +++ b/Projects/Features/Sources/KeymeTests/Result/TestResultView.swift @@ -16,7 +16,8 @@ import Network public struct TestResultView: View { @State var sharedURL: ActivityViewController.SharedURL? - + @Dependency(\.shortUrlAPIManager) private var shortURLManager + private let store: StoreOf public init(store: StoreOf) { @@ -120,7 +121,7 @@ public struct TestResultView: View { // TODO: url 주석단거로 바꾸기 let url = "https://keyme-frontend.vercel.app/test/\(17)" // let url = "https://keyme-frontend.vercel.app/test/5" - let shortURL = try await ShortUrlAPIManager.shared.request( + let shortURL = try await shortURLManager.request( .shortenURL(longURL: url), object: BitlyResponse.self).link diff --git a/Projects/Features/Sources/MainPage/MainPageFeature.swift b/Projects/Features/Sources/MainPage/MainPageFeature.swift index 03453ae3..b33cc51a 100644 --- a/Projects/Features/Sources/MainPage/MainPageFeature.swift +++ b/Projects/Features/Sources/MainPage/MainPageFeature.swift @@ -19,8 +19,9 @@ public struct MainPageFeature: Reducer { enum View: Equatable { case none } public init(userId: Int, nickname: String) { + // TODO: 테스트 아이디를 더 위에서 조회해서 내려줘야 될 듯 self._home = .init(.init(nickname: nickname)) - self._myPage = .init(.init(userId: userId, nickname: nickname)) + self._myPage = .init(.init(userId: userId, nickname: nickname, testId: 17)) } } diff --git a/Projects/Features/Sources/MyPage/MyPageFeature.swift b/Projects/Features/Sources/MyPage/MyPageFeature.swift index ff33a360..358ed9f4 100644 --- a/Projects/Features/Sources/MyPage/MyPageFeature.swift +++ b/Projects/Features/Sources/MyPage/MyPageFeature.swift @@ -16,6 +16,7 @@ import Network public struct MyPageFeature: Reducer { @Dependency(\.keymeAPIManager) private var network + @Dependency(\.shortUrlAPIManager) private var shortURLManager public struct State: Equatable { var view: View @@ -30,22 +31,30 @@ public struct MyPageFeature: Reducer { view.imageExportMode = imageExportModeState == nil ? false : true } } + @PresentationState var shareSheetState: ShareSheetFeature.State? + @PresentationState var alertState: AlertState? struct View: Equatable { let userId: Int let nickname: String + let testId: Int + var testURL: String { "https://keyme-frontend.vercel.app/test/\(testId)" } var imageExportMode = false - var rotationAngle: Double = 45 var circleShown = false var selectedSegment: MyPageSegment = .similar var shownFirstTime = true var shownCircleDatalist: [CircleData] = [] + + var nowFetching: TargetData? + enum TargetData { + case circleData + } } - init(userId: Int, nickname: String) { - self.view = View(userId: userId, nickname: nickname) + init(userId: Int, nickname: String, testId: Int) { + self.view = View(userId: userId, nickname: nickname, testId: testId) self._scoreListState = .init(.init()) } } @@ -55,9 +64,13 @@ public struct MyPageFeature: Reducer { case showCircle(MyPageSegment) case requestCircle(MatchRate) case view(View) + case showAlert(message: String) + case showShareSheet(URL) case scoreListAction(ScoreListFeature.Action) case setting(PresentationAction) + case shareSheet(PresentationAction) + case alert(PresentationAction) case imageExportModeAction(ImageExportOverlayFeature.Action) public enum View: Equatable { @@ -67,7 +80,11 @@ public struct MyPageFeature: Reducer { case prepareSettingView case selectSegement(MyPageSegment) case enableImageExportMode + case captureImage + case requestTestURL } + + public enum Alert: Equatable {} } // 마이페이지를 사용할 수 없는 케이스 @@ -124,6 +141,12 @@ public struct MyPageFeature: Reducer { } return .none + case .showShareSheet(let url): + state.shareSheetState = ShareSheetFeature.State(url: url) + + case .showAlert(let message): + state.alertState = AlertState(title: TextState("오류가 발생했어요"), message: TextState(message)) + // MARK: - View actions case .view(.selectSegement(let segment)): state.view.selectedSegment = segment @@ -154,6 +177,26 @@ public struct MyPageFeature: Reducer { return .none + case .view(.requestTestURL): + return .run { [testURL = state.view.testURL] send in + do { + + let shortURL = try await shortURLManager.request( + .shortenURL(longURL: testURL), + object: BitlyResponse.self).link + + guard let url = URL(string: shortURL) else { + await send(.showAlert(message: "링크 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")) + return + } + + await send(.showShareSheet(url)) + } catch { + await send(.showAlert( + message: "링크 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.\n\(error.localizedDescription)")) + } + } + // MARK: - Child actions case .scoreListAction: print("score") @@ -162,6 +205,12 @@ public struct MyPageFeature: Reducer { case .imageExportModeAction(.dismissImageExportMode): state.imageExportModeState = nil + case .imageExportModeAction(.captureImage): + break + + case .shareSheet(.dismiss): + state.shareSheetState = nil + default: return .none } diff --git a/Projects/Features/Sources/MyPage/MyPageView+.swift b/Projects/Features/Sources/MyPage/MyPageView+.swift index fdc92001..f12e387a 100644 --- a/Projects/Features/Sources/MyPage/MyPageView+.swift +++ b/Projects/Features/Sources/MyPage/MyPageView+.swift @@ -6,6 +6,7 @@ // Copyright © 2023 team.humanwave. All rights reserved. // +import Core import ComposableArchitecture import DSKit import SwiftUI @@ -30,15 +31,17 @@ public struct ImageExportOverlayFeature: Reducer { extension MyPageView { struct ImageExportOverlayView: View { + private let captureAction: () -> Void @Binding var rotationAngle: Angle - + private typealias Action = () -> Void private let store: StoreOf + private let imageSaver = ImageSaver() - init(store: StoreOf, angle: Binding) { + init(store: StoreOf, angle: Binding, captureAction: @escaping () -> Void) { self.store = store self._rotationAngle = angle - print(angle.wrappedValue) + self.captureAction = captureAction } var body: some View { @@ -49,31 +52,20 @@ extension MyPageView { Spacer() - photoCaptureButton(action: { viewStore.send(.captureImage) }) + photoCaptureButton(action: { + viewStore.send(.captureImage) + captureAction() + }) } .padding(.horizontal, 20) .padding(.top, 28) .background(DSKitAsset.Color.keymeBlack.swiftUIColor) - DSKitAsset.Color.keymeBlack.swiftUIColor - .reverseMask { maskingShape(isFilled: true).padding(32) } - .overlay { - maskingShape(isFilled: false).overlay { - VStack(alignment: .leading, spacing: 8) { - Text.keyme(viewStore.title, font: .body5) - .foregroundColor(.white.opacity(0.3)) - - Text.keyme("친구들이 생각하는\n\(viewStore.nickname)님의 성격은?", font: .heading1) - .foregroundColor(.white) - - Spacer() - horizontalSpacer - } - .padding(20) - } - .padding(32) - } - .allowsHitTesting(false) + imageExportTargetView( + title: viewStore.title, + nickname: viewStore.nickname) + .allowsHitTesting(false) + HStack { Image(systemName: "arrow.clockwise") @@ -128,5 +120,53 @@ extension MyPageView { .padding(.horizontal, 12) .overlay { Capsule().stroke(DSKitAsset.Color.keymeMediumgray.swiftUIColor) } } + + private func imageExportTargetView(title: String, nickname: String) -> some View { + ZStack { + DSKitAsset.Color.keymeBlack.swiftUIColor + .reverseMask { maskingShape(isFilled: true).padding(32) } + + ZStack { + maskingShape(isFilled: false) + + MyPageImageExportView(title: title, nickname: nickname, content: { EmptyView() }) + .padding(20) + } + .padding(32) + } + } + } +} + +public struct MyPageImageExportView: View { + let title: String + let nickname: String + let content: Content + + init(title: String, nickname: String, @ViewBuilder content: () -> Content) { + self.title = title + self.nickname = nickname + self.content = content() + } + + public var body: some View { + ZStack { + content + + VStack(alignment: .leading, spacing: 8) { + Text.keyme(title, font: .body5) + .foregroundColor(.white.opacity(0.3)) + + Text.keyme("친구들이 생각하는\n\(nickname)님의 성격은?", font: .heading1) + .foregroundColor(.white) + + Spacer() + horizontalSpacer + } + } + } + + private var horizontalSpacer: some View { + HStack { Spacer() } } } diff --git a/Projects/Features/Sources/MyPage/MyPageView.swift b/Projects/Features/Sources/MyPage/MyPageView.swift index 5eec7fce..a09885d3 100644 --- a/Projects/Features/Sources/MyPage/MyPageView.swift +++ b/Projects/Features/Sources/MyPage/MyPageView.swift @@ -6,6 +6,7 @@ // Copyright © 2023 team.humanwave. All rights reserved. // +import Core import ComposableArchitecture import Domain import DSKit @@ -13,10 +14,12 @@ import SwiftUI struct MyPageView: View { @Namespace private var namespace - - @State var graphRotationAngle: Angle = .radians(0.018) + @Environment(\.displayScale) var displayScale + @State var graphRotationAngle: Angle = .radians(0.018) + private let store: StoreOf + private let imageSaver = ImageSaver() init(store: StoreOf) { self.store = store @@ -29,109 +32,108 @@ struct MyPageView: View { DSKitAsset.Color.keymeBlack.swiftUIColor .ignoresSafeArea() - CirclePackView( - namespace: namespace, - data: viewStore.shownCircleDatalist, - rotationAngle: graphRotationAngle, - detailViewBuilder: { data in - let scoreListStore = store.scope( - state: \.scoreListState, - action: MyPageFeature.Action.scoreListAction) + switch viewStore.nowFetching { + case .circleData: + ProgressView() + + case .none: + if viewStore.shownCircleDatalist.isEmpty { + topBar(viewStore, showExportImageButton: false) + .padding(.top, 10) + .padding(.horizontal, 24) - ScoreListView( - ownerId: viewStore.userId, - questionId: data.metadata.questionId, - nickname: viewStore.nickname, - keyword: data.metadata.keyword, - store: scoreListStore) - }) - .graphBackgroundColor(DSKitAsset.Color.keymeBlack.swiftUIColor) - .activateCircleBlink(viewStore.state.shownFirstTime) - .onCircleTapped { _ in - viewStore.send(.circleTapped) - } - .onCircleDismissed { _ in - withAnimation(Animation.customInteractiveSpring()) { - viewStore.send(.markViewAsShown) - viewStore.send(.circleDismissed) - } - } - .graphScale(viewStore.imageExportMode ? 0.7 : 1) - .ignoresSafeArea(.container, edges: .bottom) - - // 개별 원이 보이거나 사진 export 모드가 아닌 경우에만 보여주는 부분 - // 탑 바, 탭 바, top5, bottom5 등 - if !viewStore.circleShown && !viewStore.imageExportMode { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 4) { - Button(action: { viewStore.send(.enableImageExportMode) }) { - DSKitAsset.Image.photoExport.swiftUIImage - .resizable() - .frame(width: 35, height: 35) - } - - Spacer() - - Text.keyme("마이", font: .body3Semibold) - Image(systemName: "info.circle") - .resizable() - .frame(width: 16, height: 16) - .scaledToFit() - - Spacer() - - Button(action: { viewStore.send(.prepareSettingView) }) { - DSKitAsset.Image.setting.swiftUIImage - .resizable() - .frame(width: 24, height: 24) + emptyCircleView(shareButtonAction: { + viewStore.send(.requestTestURL) + }) + + } else { + circlePackGraphView(viewStore).ignoresSafeArea(.container, edges: .bottom) + + // 개별 원이 보이거나 사진 export 모드가 아닌 경우에만 보여주는 부분 + // 탑 바, 탭 바, top5, bottom5 등 + if !viewStore.circleShown && !viewStore.imageExportMode { + VStack(alignment: .leading, spacing: 0) { + topBar(viewStore, showExportImageButton: true) + .padding(.top, 10) + .padding(.horizontal, 24) + + SegmentControlView( + segments: MyPageSegment.allCases, + selected: viewStore.binding( + get: \.selectedSegment, + send: { .selectSegement($0) }) + ) { segment in + Text.keyme(segment.title, font: .body3Semibold) + .padding(.horizontal) + .padding(.vertical, 8) + } + .frame(height: 50) + .padding(.horizontal, 17) + .padding(.top, 25) + + Text.keyme("친구들이 생각하는\n\(viewStore.nickname)님의 성격은?", font: .heading1) + .padding(17) + .transition(.opacity) } + .foregroundColor(.white) + .transition(.opacity) } - .padding(.top, 10) - .padding(.horizontal, 24) - SegmentControlView( - segments: MyPageSegment.allCases, - selected: viewStore.binding( - get: \.selectedSegment, - send: { .selectSegement($0) }) - ) { segment in - Text.keyme(segment.title, font: .body3Semibold) - .padding(.horizontal) - .padding(.vertical, 8) + // Export 모드에 진입합니다 + IfLetStore(store.scope( + state: \.imageExportModeState, + action: MyPageFeature.Action.imageExportModeAction) + ) { + ImageExportOverlayView(store: $0, angle: $graphRotationAngle, captureAction: { + let exportTargetView = MyPageImageExportView( + title: viewStore.selectedSegment.title, + nickname: viewStore.nickname + ) { + circlePackGraphView(viewStore) + // .frame(width: 300, height: 600) + // Image(systemName: "person").foregroundColor(.white) + } + .frame(width: 720, height: 1280) + + let renderer = ImageRenderer(content: exportTargetView) + renderer.scale = displayScale + let image = exportTargetView.capture() + + // guard let image = renderer.uiImage else { + guard let image else { + return + } + + imageSaver.save(image) { error in + print(error) + } + + viewStore.send(.captureImage) + }) } - .frame(height: 50) - .padding(.horizontal, 17) - .padding(.top, 25) - - Text.keyme("친구들이 생각하는\n\(viewStore.nickname)님의 성격은?", font: .heading1) - .padding(17) - .transition(.opacity) + .transition( + .opacity.combined(with: .scale(scale: 1.5)) + .animation(Animation.customInteractiveSpring())) + .zIndex(ViewZIndex.high.rawValue) } - .foregroundColor(.white) - .transition(.opacity) - } - - // Export 모드에 진입합니다 - - IfLetStore(store.scope( - state: \.imageExportModeState, - action: MyPageFeature.Action.imageExportModeAction) - ) { - ImageExportOverlayView(store: $0, angle: $graphRotationAngle) } - .transition( - .opacity.combined(with: .scale(scale: 1.5)) - .animation(Animation.customInteractiveSpring())) - .zIndex(ViewZIndex.high.rawValue) } .toolbar(viewStore.imageExportMode ? .hidden : .visible, for: .tabBar) .navigationDestination( store: store.scope(state: \.$settingViewState, action: MyPageFeature.Action.setting), destination: { SettingView(store: $0) }) + .alert(store: store.scope(state: \.$alertState, action: MyPageFeature.Action.alert)) .animation(Animation.customInteractiveSpring(duration: 0.5), value: viewStore.circleShown) .animation(Animation.customInteractiveSpring(), value: viewStore.imageExportMode) .border(DSKitAsset.Color.keymeBlack.swiftUIColor, width: viewStore.imageExportMode ? 5 : 0) } + .sheet( + store: store.scope( + state: \.$shareSheetState, + action: MyPageFeature.Action.shareSheet) + ) { store in + ActivityViewController(store: store) + } .onAppear { store.send(.requestCircle(.top5)) store.send(.requestCircle(.low5)) @@ -146,19 +148,120 @@ struct MyPageView: View { } } -extension View { - @inlinable func reverseMask( - alignment: Alignment = .center, - @ViewBuilder _ mask: () -> Mask +private extension MyPageView { + func circlePackGraphView( + _ viewStore: ViewStore ) -> some View { - self.mask( - ZStack(alignment: .center) { - Rectangle() + CirclePackView( + namespace: namespace, + data: viewStore.shownCircleDatalist, + rotationAngle: graphRotationAngle, + detailViewBuilder: { data in + let scoreListStore = store.scope( + state: \.scoreListState, + action: MyPageFeature.Action.scoreListAction) - mask() - .blendMode(.destinationOut) + ScoreListView( + ownerId: viewStore.userId, + questionId: data.metadata.questionId, + nickname: viewStore.nickname, + keyword: data.metadata.keyword, + store: scoreListStore) + }) + .graphBackgroundColor(DSKitAsset.Color.keymeBlack.swiftUIColor) + .activateCircleBlink(viewStore.state.shownFirstTime) + .onCircleTapped { _ in + viewStore.send(.circleTapped) + } + .onCircleDismissed { _ in + withAnimation(Animation.customInteractiveSpring()) { + viewStore.send(.markViewAsShown) + viewStore.send(.circleDismissed) + } + } + .graphScale(viewStore.imageExportMode ? 0.7 : 1) + } + + func emptyCircleView(shareButtonAction: @escaping () -> Void) -> some View { + VStack(alignment: .center) { + Spacer() + + DSKitAsset.Image.mypageEmpty.swiftUIImage + .padding(.bottom, 28) + + Text.keyme("아직 문제를 푼 친구가 없어요!", font: .body2) + .foregroundColor(.white) + .padding(.bottom, 8) + + Text.keyme("친구들에게 내 성격을 물어볼까요?", font: .body4) + .foregroundColor(DSKitAsset.Color.keymeMediumgray.swiftUIColor) + .padding(.bottom, 45) + + // TODO: change + Button(action: shareButtonAction) { + HStack { + Spacer() + + Text.keyme("친구에게 공유하기", font: .mypage).frame(height: 60) + + Spacer() + } + } + .foregroundColor(.black) + .background(.white) + .cornerRadius(16) + .padding(.horizontal, 16) + + Spacer() + HStack { Spacer() } + } + } + + func topBar( + _ viewStore: ViewStore, + showExportImageButton: Bool + ) -> some View { + HStack(spacing: 4) { + Button(action: { viewStore.send(.enableImageExportMode) }) { + DSKitAsset.Image.photoExport.swiftUIImage + .resizable() + .frame(width: 35, height: 35) + } + .opacity(showExportImageButton ? 1 : 0) + + Spacer() + + Text.keyme("마이", font: .body3Semibold) + Image(systemName: "info.circle") + .resizable() + .frame(width: 16, height: 16) + .scaledToFit() + + Spacer() + + Button(action: { viewStore.send(.prepareSettingView) }) { + DSKitAsset.Image.setting.swiftUIImage + .resizable() + .frame(width: 24, height: 24) } - .compositingGroup() - ) + } + .foregroundColor(.white) + } +} + +extension View { + func capture() -> UIImage? { + let controller = UIHostingController(rootView: self) + let view = controller.view + + let targetSize = UIScreen.main.bounds.size + view?.bounds = CGRect(origin: .zero, size: targetSize) + view?.backgroundColor = .clear + view?.layoutIfNeeded() // Force layout + + let renderer = UIGraphicsImageRenderer(size: targetSize) + return renderer.image { ctx in + view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) + } } } diff --git a/Projects/Features/Sources/Onboarding/OnboardingFeature.swift b/Projects/Features/Sources/Onboarding/OnboardingFeature.swift index 4ff28359..3e31e291 100644 --- a/Projects/Features/Sources/Onboarding/OnboardingFeature.swift +++ b/Projects/Features/Sources/Onboarding/OnboardingFeature.swift @@ -65,8 +65,10 @@ public struct OnboardingFeature: Reducer { public var isShared: Bool = false let authorizationToken: String - public init(authorizationToken: String) { + let nickname: String + public init(authorizationToken: String, nickname: String) { self.authorizationToken = authorizationToken + self.nickname = nickname } } diff --git a/Projects/Features/Sources/Onboarding/OnboardingView.swift b/Projects/Features/Sources/Onboarding/OnboardingView.swift index 5dc5a9aa..ccb62344 100644 --- a/Projects/Features/Sources/Onboarding/OnboardingView.swift +++ b/Projects/Features/Sources/Onboarding/OnboardingView.swift @@ -87,11 +87,17 @@ public struct OnboardingView: View { Spacer() .frame(height: 119) - Text.keyme(viewStore.lottieType.title, font: .heading1) - .foregroundColor(DSKitAsset.Color.keymeWhite.swiftUIColor) - .padding(Padding.insets(leading: 16)) - .frame(maxWidth: .infinity, alignment: .leading) - .animation(Animation.customInteractiveSpring(), value: viewStore.lottieType) + Group { + if case .question = viewStore.lottieType { + Text.keyme("환영해요 \(viewStore.nickname)님!\n이제 문제를 풀어볼까요?", font: .heading1) + } else { + Text.keyme(viewStore.lottieType.title, font: .heading1) + } + } + .foregroundColor(DSKitAsset.Color.keymeWhite.swiftUIColor) + .padding(Padding.insets(leading: 16)) + .frame(maxWidth: .infinity, alignment: .leading) + .animation(Animation.customInteractiveSpring(), value: viewStore.lottieType) Spacer() diff --git a/Projects/Features/Sources/Registration/RegisterFeature.swift b/Projects/Features/Sources/Registration/RegisterFeature.swift index 0965b63f..fa9708c8 100644 --- a/Projects/Features/Sources/Registration/RegisterFeature.swift +++ b/Projects/Features/Sources/Registration/RegisterFeature.swift @@ -66,7 +66,7 @@ public struct RegistrationFeature: Reducer { id: CancelID.debouncedNicknameUpdate, cancelInFlight: true ) { - try await self.clock.sleep(for: .seconds(0.3)) + try await self.clock.sleep(for: .seconds(0.5)) await send(.checkDuplicatedNickname(nicknameString)) } @@ -80,7 +80,7 @@ public struct RegistrationFeature: Reducer { object: VerifyNicknameDTO.self ) - await send(.checkDuplicatedNicknameResponse(result.data.valid)) + await send(.checkDuplicatedNicknameResponse(result.data?.valid ?? false)) } case .checkDuplicatedNicknameResponse(let isNicknameDuplicated): diff --git a/Projects/Features/Sources/Registration/RegistrationView.swift b/Projects/Features/Sources/Registration/RegistrationView.swift index 420b4e84..d67bb65d 100644 --- a/Projects/Features/Sources/Registration/RegistrationView.swift +++ b/Projects/Features/Sources/Registration/RegistrationView.swift @@ -216,7 +216,7 @@ extension RegistrationView { .foregroundColor(isValid ? .green : .red) .frame(width: 10, height: 10) - Text.keyme(isValid ? "사용 가능한 닉네임입니다." : "중복된 닉네임입니다.", font: .caption1) + Text.keyme(isValid ? "사용 가능한 닉네임입니다" : "이미 사용 중인 닉네임입니다", font: .caption1) .foregroundColor(.gray) Spacer() diff --git a/Projects/Features/Sources/Root/RootFeature.swift b/Projects/Features/Sources/Root/RootFeature.swift index c5787217..00deb4d5 100644 --- a/Projects/Features/Sources/Root/RootFeature.swift +++ b/Projects/Features/Sources/Root/RootFeature.swift @@ -90,7 +90,8 @@ public struct RootFeature: Reducer { if memberInformation.isOnboardingClear != true { await send( .updateState( - .needOnboarding(OnboardingFeature.State(authorizationToken: accessToken)))) + .needOnboarding(OnboardingFeature.State( + authorizationToken: accessToken, nickname: nickname)))) } else { await send(.updateState( .canUseApp(MainPageFeature.State(userId: userId, nickname: nickname)))) diff --git a/Projects/Features/Sources/Setting/SettingFeature.swift b/Projects/Features/Sources/Setting/SettingFeature.swift index aa3e0c10..d55e62b0 100644 --- a/Projects/Features/Sources/Setting/SettingFeature.swift +++ b/Projects/Features/Sources/Setting/SettingFeature.swift @@ -13,8 +13,10 @@ import Network public struct SettingFeature: Reducer { @Dependency(\.notificationManager) var notificationManager + @Dependency(\.keymeAPIManager) var network public struct State: Equatable { + @PresentationState var alerState: AlertState? var isPushNotificationEnabled: Bool init() { @@ -31,7 +33,11 @@ public struct SettingFeature: Reducer { case togglePushNotification } + public enum Alert: Equatable {} + case view(View) + case alert(Alert) + case showAlert(message: String) case setPushNotificationStatus(Bool) } @@ -45,8 +51,14 @@ public struct SettingFeature: Reducer { return .none case .view(.withdrawal): - // TODO: Call api - return .none + return .run { send in + do { + try await network.request(.setting(.withdrawal)) + await send(.view(.logout)) + } catch { + await send(.showAlert(message: "작업을 실행할 수 없습니다. 잠시 후 다시 시도해주세요.")) + } + } case .view(.togglePushNotification): if state.isPushNotificationEnabled == false { @@ -63,6 +75,13 @@ public struct SettingFeature: Reducer { return .send(.setPushNotificationStatus(false)) } + case .showAlert(let message): + state.alerState = AlertState( + title: TextState("에러가 발생했습니다"), + message: TextState(message)) + + return .none + case .setPushNotificationStatus(let value): state.isPushNotificationEnabled = value print("@@", value) diff --git a/Projects/Features/Sources/ShareSheet/ShareSheetView.swift b/Projects/Features/Sources/ShareSheet/ShareSheetView.swift index e861b521..193789f2 100644 --- a/Projects/Features/Sources/ShareSheet/ShareSheetView.swift +++ b/Projects/Features/Sources/ShareSheet/ShareSheetView.swift @@ -8,28 +8,72 @@ import Foundation +import ComposableArchitecture import SwiftUI import UIKit +public struct ShareSheetFeature: Reducer { + public struct State: Equatable { + public let url: URL + } + + public enum Action: Equatable {} + + public var body: some ReducerOf { + Reduce { _, _ in + return .none + } + } +} + struct ActivityViewController: UIViewControllerRepresentable { @Binding var isPresented: Bool var activityItems: [Any] + let store: StoreOf? + + init(isPresented: Binding, activityItems: [Any]) { + self._isPresented = isPresented + self.activityItems = activityItems + store = nil + } + + init(store: StoreOf) { + self._isPresented = .constant(false) + self.activityItems = [] + self.store = store + } + var applicationActivities: [UIActivity]? - func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIActivityViewController { - let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities) - - // Set the completion handler - controller.completionWithItemsHandler = { (_, _, _, _) in - // Dismiss the share sheet when the share action completes (whether successful or not) - self.isPresented = false + func makeUIViewController( + context: UIViewControllerRepresentableContext + ) -> UIActivityViewController { + if let store { + let viewStore = ViewStore(store, observe: { $0 }) + let controller = UIActivityViewController( + activityItems: [viewStore.state.url], + applicationActivities: applicationActivities) + + return controller + } else { + let controller = UIActivityViewController( + activityItems: activityItems, + applicationActivities: applicationActivities) + + // Set the completion handler + controller.completionWithItemsHandler = { (_, _, _, _) in + // Dismiss the share sheet when the share action completes (whether successful or not) + self.isPresented = false + } + + return controller } - - return controller } - func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext) { + func updateUIViewController( + _ uiViewController: UIActivityViewController, + context: UIViewControllerRepresentableContext) { // Update code here if needed } } diff --git a/Projects/Network/Sources/DTO/VerifyNicknameDTO.swift b/Projects/Network/Sources/DTO/VerifyNicknameDTO.swift index 085d8068..92a2045b 100644 --- a/Projects/Network/Sources/DTO/VerifyNicknameDTO.swift +++ b/Projects/Network/Sources/DTO/VerifyNicknameDTO.swift @@ -11,7 +11,7 @@ import Foundation public struct VerifyNicknameDTO: Decodable { let code: Int let message: String - public let data: NicknameData + public let data: NicknameData? public struct NicknameData: Decodable { let description: String diff --git a/Projects/Network/Sources/Network/API/KeymeAPI.swift b/Projects/Network/Sources/Network/API/KeymeAPI.swift index 314aeeb7..21edec37 100644 --- a/Projects/Network/Sources/Network/API/KeymeAPI.swift +++ b/Projects/Network/Sources/Network/API/KeymeAPI.swift @@ -17,6 +17,7 @@ public enum KeymeAPI { case member(MemberAPI) case test(KeymeTestsAPI) case question(QuestionAPI) + case setting(SettingAPI) } extension KeymeAPI: BaseAPI { @@ -36,6 +37,8 @@ extension KeymeAPI: BaseAPI { return api.path case .question(let api): return api.path + case .setting(let api): + return api.path } } @@ -55,6 +58,8 @@ extension KeymeAPI: BaseAPI { return api.method case .question(let api): return api.method + case .setting(let api): + return api.method } } @@ -74,6 +79,8 @@ extension KeymeAPI: BaseAPI { return api.task case .question(let api): return api.task + case .setting(let api): + return api.task } } diff --git a/Projects/Network/Sources/Network/API/SettingAPI.swift b/Projects/Network/Sources/Network/API/SettingAPI.swift new file mode 100644 index 00000000..e4291db1 --- /dev/null +++ b/Projects/Network/Sources/Network/API/SettingAPI.swift @@ -0,0 +1,48 @@ +// +// SettingAPI.swift +// Network +// +// Created by Young Bin on 2023/09/10. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import Foundation +import Moya + +public enum SettingAPI { + case withdrawal +} + +extension SettingAPI: BaseAPI { + public var path: String { + switch self { + case .withdrawal: + return "members" + } + } + + public var method: Moya.Method { + switch self { + case .withdrawal: + return .delete + } + } + + public var task: Moya.Task { + switch self { + case .withdrawal: + return .requestPlain + } + } + + public var sampleData: Data { + """ + { + "code": 200, + "data": {}, + "message": "SUCCESS" + } + """ + .data(using: .utf8)! + } +} diff --git a/Projects/Network/Sources/Network/API/ShortUrlAPI.swift b/Projects/Network/Sources/Network/API/ShortUrlAPI.swift index 2a30eb9d..9a3cc698 100644 --- a/Projects/Network/Sources/Network/API/ShortUrlAPI.swift +++ b/Projects/Network/Sources/Network/API/ShortUrlAPI.swift @@ -48,6 +48,8 @@ extension ShortUrlAPI: TargetType { if let accessToken = Bundle.main.object(forInfoDictionaryKey: "BITLY_API_KEY") as? String { header["Authorization"] = "Bearer \(accessToken)" + } else { + print("ERROR: Bitly 토큰 설정에 실패했습니다.") } return header diff --git a/Projects/Network/Sources/Network/Manager/ShortUrlAPIManager.swift b/Projects/Network/Sources/Network/Manager/ShortUrlAPIManager.swift index 3b64996d..c0b57321 100644 --- a/Projects/Network/Sources/Network/Manager/ShortUrlAPIManager.swift +++ b/Projects/Network/Sources/Network/Manager/ShortUrlAPIManager.swift @@ -15,6 +15,9 @@ public class ShortUrlAPIManager { private var core: CoreNetworkService private let decoder = JSONDecoder() + // 캐싱 + private var lastRequestedURLs: [String: String] = [:] + init() { let loggerConfig = NetworkLoggerPlugin.Configuration(logOptions: .verbose) let networkLogger = NetworkLoggerPlugin(configuration: loggerConfig) @@ -41,8 +44,41 @@ extension ShortUrlAPIManager { return decoded } + + public func request(_ api: APIType, object: String) async throws -> String { + if + case .shortenURL(longURL: let url) = api, + let lastResponsedURL = lastRequestedURLs[url] + { + return lastResponsedURL + } + + let response = try await core.request(api) + let decoded = try decoder.decode(String.self, from: response.data) + + if case .shortenURL(longURL: let url) = api { + lastRequestedURLs[url] = decoded + } + + return decoded + } +} + +// MARK: Dependency 설정 +import ComposableArchitecture + +extension ShortUrlAPIManager: DependencyKey { + public static var liveValue = ShortUrlAPIManager() + public static var testValue: ShortUrlAPIManager { + let stubbingClosure = MoyaProvider.immediatelyStub + let stubbingCoreService = CoreNetworkService(provider: .init(stubClosure: stubbingClosure)) + return ShortUrlAPIManager(core: stubbingCoreService) + } } -public extension ShortUrlAPIManager { - static let shared = ShortUrlAPIManager() +extension DependencyValues { + public var shortUrlAPIManager: ShortUrlAPIManager { + get { self[ShortUrlAPIManager.self] } + set { self[ShortUrlAPIManager.self] = newValue } + } }