diff --git a/Encrypted/Secrets/GoogleService-Info.plist.encrypted b/Encrypted/Secrets/GoogleService-Info.plist.encrypted index 58ede7d0..dbe4cb73 100644 Binary files a/Encrypted/Secrets/GoogleService-Info.plist.encrypted and b/Encrypted/Secrets/GoogleService-Info.plist.encrypted differ diff --git a/Encrypted/XCConfig/App/DEV.xcconfig.encrypted b/Encrypted/XCConfig/App/DEV.xcconfig.encrypted index da673e05..44249a24 100644 Binary files a/Encrypted/XCConfig/App/DEV.xcconfig.encrypted and b/Encrypted/XCConfig/App/DEV.xcconfig.encrypted differ diff --git a/Encrypted/XCConfig/App/PROD.xcconfig.encrypted b/Encrypted/XCConfig/App/PROD.xcconfig.encrypted index 5a5edbbd..702bced4 100644 --- a/Encrypted/XCConfig/App/PROD.xcconfig.encrypted +++ b/Encrypted/XCConfig/App/PROD.xcconfig.encrypted @@ -1 +1 @@ -ÉdœðªBJØ Ž£”LH 8Ëv2c¦Œ§Ï÷mH š"?^F1XnËo^ñ |“Ñ‚2x>åSFgtmz \ø׬Ԍ¦î»Ïå“uQ&=O¶±pVßàfaµzuc)ó„¤!¥Ô¼ªwW¬0+SXl>7{ÅmD@«‹¸ZÜ]¼å¢žÒoè_{¨Z£“šExþ6 l}Ö5`Ùî¦Öä×ëW½Óäµ.?ìÑhšüo>Hˈ4ãî: SÑ¢èòì“C^7L¡ \ No newline at end of file +ÉdœðªBJØ Ž£”LH 8Ëv2c¦Œ§Ï÷mH š"?^F1XnËo^ñ |“Ñ‚2x>åSFgtmz \ø׬Ԍ¦î»Ïå“uQ&uDÛ0žp>y­ú˜æÃ|/ˆN&UcÅžº;RVÕ“$ùHŸKŒ¦ÊôX6W]¶nu.¡…¶·i}üDÒÅŸ?ŠÀ~Jod >éw—ü—R \ No newline at end of file diff --git a/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift b/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift index 49b02f63..154fa3a6 100644 --- a/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift +++ b/Plugins/EnvPlugin/ProjectDescriptionHelpers/InfoPlist.swift @@ -16,11 +16,24 @@ public extension Project { "CFBundleURLTypes": [ [ "CFBundleTypeRole": "Editor", - "CFBundleURLSchemes": ["keyme"] + "CFBundleURLSchemes": [ + "keyme", + "kakao$(KAKAO_API_KEY)" + ] ] ], "API_BASE_URL": "$(API_BASE_URL)", - "UIUserInterfaceStyle": "Light" + "KAKAO_API_KEY": "$(KAKAO_API_KEY)", + "UIUserInterfaceStyle": "Light", + "NSAppTransportSecurity": [ + "NSExceptionDomains": [ + "api.keyme.space": [ + "NSIncludesSubdomains": true, + "NSExceptionMinimumTLSVersion": "TLSv1.2", + ], + ] + ], + "LSApplicationQueriesSchemes": ["kakaokompassauth", "kakaolink"] ] static let baseUrlInfoPlist: [String: InfoPlist.Value] = [ diff --git a/Projects/Core/Sources/Example.swift b/Projects/Core/Sources/Example.swift deleted file mode 100644 index fecc4ab4..00000000 --- a/Projects/Core/Sources/Example.swift +++ /dev/null @@ -1 +0,0 @@ -import Foundation diff --git a/Projects/Core/Sources/Utility/Function/TCA+.swift b/Projects/Core/Sources/Utility/Function/TCA+.swift new file mode 100644 index 00000000..43e9a293 --- /dev/null +++ b/Projects/Core/Sources/Utility/Function/TCA+.swift @@ -0,0 +1,46 @@ +// +// TCA+.swift +// Core +// +// Created by Young Bin on 2023/09/03. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import ComposableArchitecture + +private final class Ref: Equatable { + var val: T + init(_ v: T) { + self.val = v + } + + static func == (lhs: Ref, rhs: Ref) -> Bool { + lhs.val == rhs.val + } +} + +/// ìŠ¤íƒ ì˜¤ë²„í”Œë¡œìš° 방지용 ëž˜í¼ +/// 참고: https://github.com/pointfreeco/swift-composable-architecture/discussions/488 +@propertyWrapper +public struct Box: Equatable { + private var ref: Ref + + public init(_ x: T) { + self.ref = Ref(x) + } + + public var wrappedValue: T { + get { ref.val } + set { + if !isKnownUniquelyReferenced(&ref) { + ref = Ref(newValue) + return + } + ref.val = newValue + } + } + + public var projectedValue: Box { + self + } +} diff --git a/Projects/Core/Sources/Utility/Interact/Haptic/HapticManager.swift b/Projects/Core/Sources/Utility/Interact/Haptic/HapticManager.swift index b3d65ace..a19e943b 100644 --- a/Projects/Core/Sources/Utility/Interact/Haptic/HapticManager.swift +++ b/Projects/Core/Sources/Utility/Interact/Haptic/HapticManager.swift @@ -54,6 +54,13 @@ public class HapticManager { generator.impactOccurred() } + /// 붕 + public func boong() { + let generator = UIImpactFeedbackGenerator(style: .soft) + generator.prepare() + generator.impactOccurred() + } + /// 패턴 재ìƒ(íŒ¨í„´ì€ ì¶”í›„ í˜‘ì˜ í›„ 개발해서 추가) public func playHapticPattern() { do { diff --git a/Projects/Core/Sources/Utility/UI/TextField/TextFieldPlaceholder.swift b/Projects/Core/Sources/Utility/UI/TextField/TextFieldPlaceholder.swift new file mode 100644 index 00000000..ab1e87bc --- /dev/null +++ b/Projects/Core/Sources/Utility/UI/TextField/TextFieldPlaceholder.swift @@ -0,0 +1,22 @@ +// +// TextFieldPlaceholder.swift +// Core +// +// Created by ì´ì˜ë¹ˆ on 2023/08/31. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import SwiftUI + +public extension View { + func placeholder( + when shouldShow: Bool, + alignment: Alignment = .leading, + @ViewBuilder placeholder: () -> Content + ) -> some View { + ZStack(alignment: alignment) { + placeholder().opacity(shouldShow ? 1 : 0) + self + } + } +} diff --git a/Projects/Core/Sources/Utility/UI/WebView/WebViewWarmUper.swift b/Projects/Core/Sources/Utility/UI/WebView/WebViewWarmUper.swift new file mode 100644 index 00000000..e794909f --- /dev/null +++ b/Projects/Core/Sources/Utility/UI/WebView/WebViewWarmUper.swift @@ -0,0 +1,68 @@ +// https://github.com/bernikovich/WebViewWarmUper +// +// Created by Timur Bernikovich on 07/03/2019. +// Copyright © 2019 Timur Bernikovich. All rights reserved. +// + +import WebKit +import UIKit + +public protocol WarmUpable { + func warmUp() +} + +public class WarmUper { + + private let creationClosure: () -> Object + private var warmedUpObjects: [Object] = [] + public var numberOfWamedUpObjects: Int = 2 { + didSet { + prepare() + } + } + + public init(creationClosure: @escaping () -> Object) { + self.creationClosure = creationClosure + prepare() + } + + public func prepare() { + while warmedUpObjects.count < numberOfWamedUpObjects { + let object = creationClosure() + object.warmUp() + warmedUpObjects.append(object) + } + } + + private func createObjectAndWarmUp() -> Object { + let object = creationClosure() + object.warmUp() + return object + } + + public func dequeue() -> Object { + let warmedUpObject: Object + if let object = warmedUpObjects.first { + warmedUpObjects.removeFirst() + warmedUpObject = object + } else { + warmedUpObject = createObjectAndWarmUp() + } + prepare() + return warmedUpObject + } + +} + +extension WKWebView: WarmUpable { + public func warmUp() { + loadHTMLString("", baseURL: nil) + } +} + +public typealias WKWebViewWarmUper = WarmUper +public extension WarmUper where Object == WKWebView { + static let shared = WKWebViewWarmUper(creationClosure: { + WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + }) +} diff --git a/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/Contents.json b/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/Contents.json new file mode 100644 index 00000000..cf4072a4 --- /dev/null +++ b/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "photo_export.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "photo_export@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "photo_export@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/photo_export.png b/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/photo_export.png new file mode 100644 index 00000000..427ff510 Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/photo_export.png differ diff --git a/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/photo_export@2x.png b/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/photo_export@2x.png new file mode 100644 index 00000000..fc58052e Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/photo_export@2x.png differ diff --git a/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/photo_export@3x.png b/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/photo_export@3x.png new file mode 100644 index 00000000..a177f09b Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/photo_export.imageset/photo_export@3x.png differ diff --git a/Projects/DSKit/Resources/Image.xcassets/setting.imageset/Contents.json b/Projects/DSKit/Resources/Image.xcassets/setting.imageset/Contents.json new file mode 100644 index 00000000..8b4c5e69 --- /dev/null +++ b/Projects/DSKit/Resources/Image.xcassets/setting.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "setting.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "setting@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "setting@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/DSKit/Resources/Image.xcassets/setting.imageset/setting.png b/Projects/DSKit/Resources/Image.xcassets/setting.imageset/setting.png new file mode 100644 index 00000000..9a7d2cab Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/setting.imageset/setting.png differ diff --git a/Projects/DSKit/Resources/Image.xcassets/setting.imageset/setting@2x.png b/Projects/DSKit/Resources/Image.xcassets/setting.imageset/setting@2x.png new file mode 100644 index 00000000..7077297b Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/setting.imageset/setting@2x.png differ diff --git a/Projects/DSKit/Resources/Image.xcassets/setting.imageset/setting@3x.png b/Projects/DSKit/Resources/Image.xcassets/setting.imageset/setting@3x.png new file mode 100644 index 00000000..02c526b0 Binary files /dev/null and b/Projects/DSKit/Resources/Image.xcassets/setting.imageset/setting@3x.png differ diff --git a/Projects/DSKit/Sources/Lottie/KeymeLottie.swift b/Projects/DSKit/Sources/Lottie/KeymeLottie.swift index 24c3660e..169f09a7 100644 --- a/Projects/DSKit/Sources/Lottie/KeymeLottie.swift +++ b/Projects/DSKit/Sources/Lottie/KeymeLottie.swift @@ -27,7 +27,7 @@ public struct KeymeLottieView: UIViewRepresentable { lottieView.translatesAutoresizingMaskIntoConstraints = false lottieView.loopMode = loopMode - lottieView.contentMode = .scaleToFill + lottieView.contentMode = .scaleAspectFill lottieView.play(completion: { finished in if finished { completion?() diff --git a/Projects/Features/Sources/Home/KeymeTestHomeFeature.swift b/Projects/Features/Sources/Home/KeymeTestHomeFeature.swift index 2a80651c..08e6a2c0 100644 --- a/Projects/Features/Sources/Home/KeymeTestHomeFeature.swift +++ b/Projects/Features/Sources/Home/KeymeTestHomeFeature.swift @@ -9,13 +9,19 @@ import ComposableArchitecture import Domain import Network +import Foundation -struct KeymeTestsHomeFeature: Reducer { +public struct KeymeTestsHomeFeature: Reducer { @Dependency(\.keymeAPIManager) private var network // 테스트를 ì•„ì§ í’€ì§€ 않았거나, 풀었거나 2가지 ì¼€ì´ìŠ¤ë§Œ 존재 - struct State: Equatable { + public struct State: Equatable { + @PresentationState var alertState: AlertState? @PresentationState var testStartViewState: KeymeTestsStartFeature.State? + var authorizationToken: String? { + @Dependency(\.keymeAPIManager.authorizationToken) var authorizationToken + return authorizationToken + } var view: View struct View: Equatable { @@ -28,15 +34,35 @@ struct KeymeTestsHomeFeature: Reducer { } } - enum Action { + public enum Action { + case requestLogout + case fetchDailyTests case showTestStartView(testData: KeymeTestsModel) + case showErrorAlert(HomeFeatureError) + + case alert(PresentationAction) case startTest(PresentationAction) enum View {} + + public enum Alert: Equatable { + case error(HomeFeatureError) + } + + public enum HomeFeatureError: LocalizedError { + case cannotGetAuthorizationInformation + + public var errorDescription: String? { + switch self { + case .cannotGetAuthorizationInformation: + return "ë¡œê·¸ì¸ ì •ë³´ë¥¼ 불러올 수 없습니다. 다시 로그ì¸ì„ 진행해주세요." + } + } + } } - var body: some ReducerOf { + public var body: some ReducerOf { Reduce { state, action in switch action { case .fetchDailyTests: @@ -49,7 +75,32 @@ struct KeymeTestsHomeFeature: Reducer { case .showTestStartView(let testData): state.view.dailyTestId = testData.testId - state.testStartViewState = .init(nickname: state.view.nickname, testData: testData) + guard let authorizationToken = state.authorizationToken else { + return .send(.showErrorAlert(.cannotGetAuthorizationInformation)) + } + + state.testStartViewState = KeymeTestsStartFeature.State( + nickname: state.view.nickname, + testData: testData, + authorizationToken: authorizationToken) + + case .showErrorAlert(let error): + if case .cannotGetAuthorizationInformation = error { + state.alertState = AlertState( + title: { TextState("ì—러 ë°œìƒ") }, + actions: { ButtonState( + action: .error(.cannotGetAuthorizationInformation), + label: { TextState("닫기") } + ) }, + message: { TextState(error.localizedDescription) }) + } + return .none + + case .alert(.presented(.error(let error))): + if case .cannotGetAuthorizationInformation = error { + return .send(.requestLogout) + } + return .none default: break @@ -57,5 +108,8 @@ struct KeymeTestsHomeFeature: Reducer { return .none } + .ifLet(\.$testStartViewState, action: /Action.startTest) { + KeymeTestsStartFeature() + } } } diff --git a/Projects/Features/Sources/Home/KeymeTestHomeView.swift b/Projects/Features/Sources/Home/KeymeTestHomeView.swift index a4bae6ab..6f5db17c 100644 --- a/Projects/Features/Sources/Home/KeymeTestHomeView.swift +++ b/Projects/Features/Sources/Home/KeymeTestHomeView.swift @@ -15,7 +15,6 @@ struct KeymeTestsHomeView: View { init(store: StoreOf) { self.store = store - store.send(.fetchDailyTests) } var body: some View { @@ -41,7 +40,13 @@ struct KeymeTestsHomeView: View { // ê²°ê³¼ 화면 í‘œì‹œë„ ìƒê° } + .onAppear { + if viewStore.dailyTestId == nil { + viewStore.send(.fetchDailyTests) + } + } } + .alert(store: store.scope(state: \.$alertState, action: KeymeTestsHomeFeature.Action.alert)) } } diff --git a/Projects/Features/Sources/Home/KeymeTestsStartFeature.swift b/Projects/Features/Sources/Home/KeymeTestsStartFeature.swift index 3f74eed0..86bae609 100644 --- a/Projects/Features/Sources/Home/KeymeTestsStartFeature.swift +++ b/Projects/Features/Sources/Home/KeymeTestsStartFeature.swift @@ -15,14 +15,16 @@ public struct KeymeTestsStartFeature: Reducer { public struct State: Equatable { public let nickname: String public let testData: KeymeTestsModel + let authorizationToken: String public var icon: IconModel = .EMPTY - public var keymeTests: KeymeTestsFeature.State? + @PresentationState public var keymeTests: KeymeTestsFeature.State? public var isAnimating: Bool = false - public init(nickname: String, testData: KeymeTestsModel) { + public init(nickname: String, testData: KeymeTestsModel, authorizationToken: String) { self.nickname = nickname self.testData = testData + self.authorizationToken = authorizationToken } } @@ -31,7 +33,7 @@ public struct KeymeTestsStartFeature: Reducer { case startAnimation([IconModel]) case setIcon(IconModel) case startButtonDidTap - case keymeTests(KeymeTestsFeature.Action) + case keymeTests(PresentationAction) } @Dependency(\.continuousClock) var clock @@ -47,6 +49,10 @@ public struct KeymeTestsStartFeature: Reducer { return .send(.startAnimation(state.testData.icons)) case .startAnimation(let icons): + guard state.isAnimating == false else { + return .none + } + return .run { send in repeat { for icon in icons { @@ -58,19 +64,21 @@ public struct KeymeTestsStartFeature: Reducer { case let .setIcon(icon): state.icon = icon - state.isAnimating = true case .startButtonDidTap: let url = "https://keyme-frontend.vercel.app/test/\(state.testData.testId)" - state.keymeTests = KeymeTestsFeature.State(url: url) + state.keymeTests = KeymeTestsFeature.State(url: url, authorizationToken: state.authorizationToken) + + case .keymeTests(.presented(.close)): + state.keymeTests = nil - case .keymeTests: - return .none + default: + break } return .none } - .ifLet(\.keymeTests, action: /Action.keymeTests) { + .ifLet(\.$keymeTests, action: /Action.keymeTests) { KeymeTestsFeature() } } diff --git a/Projects/Features/Sources/Home/KeymeTestsStartView.swift b/Projects/Features/Sources/Home/KeymeTestsStartView.swift index f69d9742..1276df28 100644 --- a/Projects/Features/Sources/Home/KeymeTestsStartView.swift +++ b/Projects/Features/Sources/Home/KeymeTestsStartView.swift @@ -19,28 +19,26 @@ public struct KeymeTestsStartView: View { public init(store: StoreOf) { self.store = store - store.send(.viewWillAppear) } public var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in - IfLetStore( - self.store.scope( - state: \.keymeTests, - action: KeymeTestsStartFeature.Action.keymeTests - ), - then: { store in - KeymeTestsView(store: store) - .ignoresSafeArea(.all) - .transition(.scale.animation(.easeIn)) - }, - else: { - startTestsButton(viewStore) - .onTapGesture { - viewStore.send(.startButtonDidTap) - } + 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) } } @@ -53,7 +51,7 @@ public struct KeymeTestsStartView: View { .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).repeatForever(), value: viewStore.isAnimating) + .animation(.spring(response: 0.8).repeatForever(), value: viewStore.isAnimating) Circle() .foregroundColor(viewStore.icon.color) diff --git a/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift b/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift index 4813a52e..0f916aab 100644 --- a/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift +++ b/Projects/Features/Sources/KeymeTests/KeymeTestsFeature.swift @@ -7,6 +7,7 @@ // import ComposableArchitecture +import Foundation import Domain import Network @@ -15,18 +16,34 @@ public struct KeymeTestsFeature: Reducer { public struct State: Equatable { let url: String + let authorizationToken: String + @PresentationState var alertState: AlertState? - public init(url: String) { + public init(url: String, authorizationToken: String) { self.url = url + self.authorizationToken = authorizationToken + self.alertState = alertState } } public enum Action: Equatable { case transition case close + case submit(resultCode: String, testResultId: Int) - case showResult(data: KeymeWebViewModel) case postResult(TaskResult) + + case view(View) + case alert(PresentationAction) + + public enum View: Equatable { + case showResult(data: KeymeWebViewModel) + case closeButtonTapped + } + + public enum Alert: Equatable { + case closeTest + } } @Dependency(\.keymeTestsClient) var keymeTestsClient @@ -38,11 +55,27 @@ public struct KeymeTestsFeature: Reducer { switch action { case .transition: return .none + case .close: return .none - case .submit(let code, let id): - return .none - case .showResult(let data): + + // MARK: - View actions + case .view(.closeButtonTapped): + state.alertState = AlertState( + title: { TextState("") }, + actions: { + ButtonState( + role: .cancel, + label: { TextState("취소") } + ) + ButtonState( + action: .closeTest, + label: { TextState("종료") } + ) + }, + message: { TextState("테스트를 종료하시겠어요?") }) + + case .view(.showResult(let data)): return .run { [resultCode = data.resultCode] send in await send(.postResult( TaskResult { try await @@ -50,9 +83,18 @@ public struct KeymeTestsFeature: Reducer { } )) } + + case .submit: + return .none + + case .alert(.presented(.closeTest)): + return .send(.close) + default: return .none } + + return .none } } } diff --git a/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift b/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift index 63859764..a506f2fd 100644 --- a/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift +++ b/Projects/Features/Sources/KeymeTests/KeymeTestsView.swift @@ -20,15 +20,16 @@ public struct KeymeTestsView: View { } public var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in + WithViewStore(store, observe: { $0 }, send: KeymeTestsFeature.Action.view) { viewStore in ZStack { - KeymeWebView(url: viewStore.url) + KeymeWebView(url: viewStore.url, accessToken: viewStore.authorizationToken) .onCloseWebView { - print("close") + viewStore.send(.closeButtonTapped) } .onTestSubmitted { testResult in viewStore.send(.showResult(data: testResult)) } + .toolbar(.hidden, for: .navigationBar) } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/Projects/Features/Sources/KeymeTests/WebView/KeymeWebView.swift b/Projects/Features/Sources/KeymeTests/WebView/KeymeWebView.swift index b1ea058a..b0cbc1dd 100644 --- a/Projects/Features/Sources/KeymeTests/WebView/KeymeWebView.swift +++ b/Projects/Features/Sources/KeymeTests/WebView/KeymeWebView.swift @@ -9,6 +9,7 @@ import SwiftUI import WebKit +import Core import Domain import DSKit @@ -30,17 +31,18 @@ public struct KeymeWebView: UIViewRepresentable { private let webView: WKWebView public let url: String - init(url: String) { + init(url: String, accessToken: String) { self.option = .init() self.url = url - self.webView = WKWebView(frame: CGRect.zero, configuration: WKWebViewConfiguration()) + self.webView = WKWebViewWarmUper.shared.dequeue() if let encodedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: encodedUrl) { - webView.load(URLRequest(url: url)) - + var request = URLRequest(url: url) + request.setValue(accessToken, forHTTPHeaderField: "Authorization") + webView.load(request) } } diff --git a/Projects/Features/Sources/MainPage/MainPageFeature.swift b/Projects/Features/Sources/MainPage/MainPageFeature.swift index 80fd4e0b..68409405 100644 --- a/Projects/Features/Sources/MainPage/MainPageFeature.swift +++ b/Projects/Features/Sources/MainPage/MainPageFeature.swift @@ -8,24 +8,33 @@ import Foundation import ComposableArchitecture +import Core public struct MainPageFeature: Reducer { public struct State: Equatable { - let userId: Int - let nickname: String + @Box var home: KeymeTestsHomeFeature.State + @Box var myPage: MyPageFeature.State public init(userId: Int, nickname: String) { - self.userId = userId - self.nickname = nickname + self._home = .init(.init(nickname: nickname)) + self._myPage = .init(.init(userId: userId, nickname: nickname)) } } public enum Action { - case logout - case changeNickname(String) + case home(KeymeTestsHomeFeature.Action) + case myPage(MyPageFeature.Action) } public var body: some Reducer { + Scope(state: \.home, action: /Action.home) { + KeymeTestsHomeFeature() + } + + Scope(state: \.myPage, action: /Action.myPage) { + MyPageFeature() + } + Reduce { _, _ in return .none } diff --git a/Projects/Features/Sources/MainPage/MainPageView.swift b/Projects/Features/Sources/MainPage/MainPageView.swift index e8d2b9c8..d218d917 100644 --- a/Projects/Features/Sources/MainPage/MainPageView.swift +++ b/Projects/Features/Sources/MainPage/MainPageView.swift @@ -25,50 +25,73 @@ struct KeymeMainView: View { } var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - TabView(selection: $selectedTab) { - KeymeTestsHomeView(store: Store( - initialState: KeymeTestsHomeFeature.State( - nickname: viewStore.nickname) - ) { - KeymeTestsHomeFeature() - }) - .tabItem { - homeTabImage + NavigationStack { + WithViewStore(store, observe: { $0 }) { _ in + TabView(selection: $selectedTab) { + KeymeTestsHomeView(store: store.scope(state: \.home, action: MainPageFeature.Action.home)) + .tabItem { + homeTabImage + } + .tag(Tab.home) + + MyPageView(store: store.scope(state: \.myPage, action: MainPageFeature.Action.myPage)) + .tabItem { + myPageTabImage + } + .tag(Tab.myPage) } - .tag(Tab.home) - - MyPageView(store: Store( - initialState: MyPageFeature.State( - userId: viewStore.state.userId, - nickname: viewStore.state.nickname) - ) { - MyPageFeature() - }) - .tabItem { - myPageTabImage + .introspect(.tabView, on: .iOS(.v16, .v17)) { tabViewController in + let tabBar = tabViewController.tabBar + + let barAppearance = UITabBarAppearance() + barAppearance.configureWithOpaqueBackground() + barAppearance.backgroundColor = UIColor(Color.hex("232323")) + + let itemAppearance = UITabBarItemAppearance() + itemAppearance.selected.iconColor = .white + itemAppearance.selected.titleTextAttributes = [.foregroundColor: UIColor.white] + itemAppearance.normal.iconColor = .gray + itemAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.gray] + + tabBar.standardAppearance = barAppearance + tabBar.standardAppearance.inlineLayoutAppearance = itemAppearance + tabBar.standardAppearance.stackedLayoutAppearance = itemAppearance + tabBar.standardAppearance.compactInlineLayoutAppearance = itemAppearance + tabBar.scrollEdgeAppearance = tabBar.standardAppearance } - .tag(Tab.myPage) - } - .introspect(.tabView, on: .iOS(.v16, .v17)) { tabViewController in - let tabBar = tabViewController.tabBar - - let barAppearance = UITabBarAppearance() - barAppearance.configureWithOpaqueBackground() - barAppearance.backgroundColor = UIColor(Color.hex("232323")) - - let itemAppearance = UITabBarItemAppearance() - itemAppearance.selected.iconColor = .white - itemAppearance.selected.titleTextAttributes = [.foregroundColor: UIColor.white] - itemAppearance.normal.iconColor = .gray - itemAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.gray] - - tabBar.standardAppearance = barAppearance - tabBar.standardAppearance.inlineLayoutAppearance = itemAppearance - tabBar.standardAppearance.stackedLayoutAppearance = itemAppearance - tabBar.standardAppearance.compactInlineLayoutAppearance = itemAppearance - tabBar.scrollEdgeAppearance = tabBar.standardAppearance } + .navigationBarHidden(true) + } + .introspect(.navigationStack, on: .iOS(.v16, .v17)) { navigationController in + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundColor = UIColor(DSKitAsset.Color.keymeBlack.swiftUIColor) + appearance.titleTextAttributes = [ + NSAttributedString.Key.foregroundColor: UIColor.white + ] + + let backbuttonImage = UIImage(systemName: "chevron.left")? + .withRenderingMode(.alwaysOriginal) + .withTintColor(.white) + .withAlignmentRectInsets(UIEdgeInsets(top: 0.0, left: -12.0, bottom: 0, right: 0.0)) + + appearance.setBackIndicatorImage(backbuttonImage, transitionMaskImage: backbuttonImage) + + let backButtonAppearance = UIBarButtonItemAppearance() + // backButtoní•˜ë‹¨ì— í‘œì¶œë˜ëŠ” text를 안보ì´ê²Œ 설정 + backButtonAppearance.normal.titleTextAttributes = [ + .foregroundColor: UIColor.clear, + .font: UIFont.systemFont(ofSize: 0.0) + ] + + appearance.backButtonAppearance = backButtonAppearance + + navigationController.navigationBar.barTintColor = .white + navigationController.navigationBar.tintColor = .white + navigationController.navigationBar.standardAppearance = appearance + navigationController.navigationBar.compactAppearance = appearance + navigationController.navigationBar.scrollEdgeAppearance = appearance + navigationController.navigationBar.isTranslucent = false } } } diff --git a/Projects/Features/Sources/MyPage/MyPageFeature.swift b/Projects/Features/Sources/MyPage/MyPageFeature.swift index 23a3b48c..e31051fc 100644 --- a/Projects/Features/Sources/MyPage/MyPageFeature.swift +++ b/Projects/Features/Sources/MyPage/MyPageFeature.swift @@ -14,21 +14,15 @@ import Foundation import SwiftUI import Network -struct Coordinate { - var x: Double - var y: Double - var r: Double - var color: Color -} - -struct MyPageFeature: Reducer { +public struct MyPageFeature: Reducer { @Dependency(\.keymeAPIManager) private var network - struct State: Equatable { + public struct State: Equatable { var similarCircleDataList: [CircleData] = [] var differentCircleDataList: [CircleData] = [] var view: View - var scoreListState: ScoreListFeature.State = .init() + @Box var scoreListState: ScoreListFeature.State + @PresentationState var settingViewState: SettingFeature.State? struct View: Equatable { let userId: Int @@ -42,20 +36,23 @@ struct MyPageFeature: Reducer { init(userId: Int, nickname: String) { self.view = View(userId: userId, nickname: nickname) + self._scoreListState = .init(.init()) } } - enum Action: Equatable { + public enum Action: Equatable { case saveCircle([CircleData], MatchRate) case showCircle(MyPageSegment) case requestCircle(MatchRate) case view(View) case scoreListAction(ScoreListFeature.Action) + case setting(PresentationAction) - enum View: Equatable { + public enum View: Equatable { case markViewAsShown case circleTapped case circleDismissed + case prepareSettingView case selectSegement(MyPageSegment) } } @@ -130,17 +127,35 @@ struct MyPageFeature: Reducer { state.view.circleShown = false return .none + case .view(.prepareSettingView): + print("@@ init from mypage") + state.settingViewState = SettingFeature.State() + return .none + case .scoreListAction: print("score") return .none + + default: + return .none } } + .ifLet(\.$settingViewState, action: /Action.setting) { + SettingFeature() + } } } -extension MyPageFeature { +public extension MyPageFeature { enum MatchRate { case top5 case low5 } + + struct Coordinate { + var x: Double + var y: Double + var r: Double + var color: Color + } } diff --git a/Projects/Features/Sources/MyPage/MyPageView.swift b/Projects/Features/Sources/MyPage/MyPageView.swift index c630bc45..d1441192 100644 --- a/Projects/Features/Sources/MyPage/MyPageView.swift +++ b/Projects/Features/Sources/MyPage/MyPageView.swift @@ -18,11 +18,6 @@ struct MyPageView: View { init(store: StoreOf) { self.store = store - - store.send(.requestCircle(.top5)) - store.send(.requestCircle(.low5)) - - store.send(.view(.selectSegement(.similar))) } public var body: some View { @@ -54,19 +49,34 @@ struct MyPageView: View { viewStore.send(.circleDismissed) } } - + if !viewStore.state.circleShown { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 4) { + Button(action: { viewStore.send(.prepareSettingView) }) { + 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) + } } .padding(.top, 10) + .padding(.horizontal, 24) SegmentControlView( segments: MyPageSegment.allCases, @@ -89,6 +99,15 @@ struct MyPageView: View { .foregroundColor(.white) } } + .navigationDestination( + store: store.scope(state: \.$settingViewState, action: MyPageFeature.Action.setting), + destination: { SettingView(store: $0) }) + } + .onAppear { + store.send(.requestCircle(.top5)) + store.send(.requestCircle(.low5)) + + store.send(.view(.selectSegement(.similar))) } } } diff --git a/Projects/Features/Sources/MyPage/ScoreList/ScoreListFeature.swift b/Projects/Features/Sources/MyPage/ScoreList/ScoreListFeature.swift index ff3c7dd6..20265277 100644 --- a/Projects/Features/Sources/MyPage/ScoreList/ScoreListFeature.swift +++ b/Projects/Features/Sources/MyPage/ScoreList/ScoreListFeature.swift @@ -11,7 +11,7 @@ import ComposableArchitecture import Domain import Network -struct ScoreListFeature: Reducer { +public struct ScoreListFeature: Reducer { @Dependency(\.keymeAPIManager) private var network public struct State: Equatable { diff --git a/Projects/Features/Sources/Onboarding/OnboardingFeature.swift b/Projects/Features/Sources/Onboarding/OnboardingFeature.swift index 98fd5893..4ff28359 100644 --- a/Projects/Features/Sources/Onboarding/OnboardingFeature.swift +++ b/Projects/Features/Sources/Onboarding/OnboardingFeature.swift @@ -63,8 +63,11 @@ public struct OnboardingFeature: Reducer { public var isLoop: Bool = false public var isBlackBackground: Bool = false public var isShared: Bool = false - - public init() { } + + let authorizationToken: String + public init(authorizationToken: String) { + self.authorizationToken = authorizationToken + } } public enum Action: Equatable { @@ -117,12 +120,10 @@ public struct OnboardingFeature: Reducer { return .none case .startButtonDidTap: - // TODO: url 주ì„단거로 바꾸기 let url = "https://keyme-frontend.vercel.app/test/\(state.testId)" -// let url = "https://keyme-frontend.vercel.app/test/5" - state.keymeTestsState = KeymeTestsFeature.State(url: url) + state.keymeTestsState = KeymeTestsFeature.State(url: url, authorizationToken: state.authorizationToken) - case .keymeTests(.presented(.showResult(let data))): + case .keymeTests(.presented(.view(.showResult(let data)))): return .send(.showResult(data: data)) case .showResult(data: let data): @@ -140,6 +141,9 @@ public struct OnboardingFeature: Reducer { case .testResult(.closeButtonDidTap): state.status = .completed + case .keymeTests(.presented(.close)): + state.keymeTestsState = nil + default: break } diff --git a/Projects/Features/Sources/Onboarding/OnboardingView.swift b/Projects/Features/Sources/Onboarding/OnboardingView.swift index 2a9fcf71..5dc5a9aa 100644 --- a/Projects/Features/Sources/Onboarding/OnboardingView.swift +++ b/Projects/Features/Sources/Onboarding/OnboardingView.swift @@ -99,7 +99,7 @@ public struct OnboardingView: View { Color.clear .contentShape(Circle()) .onTapGesture { - HapticManager.shared.tongtong() + HapticManager.shared.boong() viewStore.send(.startButtonDidTap) } } @@ -116,7 +116,7 @@ public struct OnboardingView: View { .foregroundColor(.black) } .onTapGesture { - HapticManager.shared.tok() + HapticManager.shared.boong() viewStore.send(.nextButtonDidTap) } .padding(Padding.insets(leading: 16, trailing: 16)) diff --git a/Projects/Features/Sources/PushNotificationManager.swift b/Projects/Features/Sources/PushNotificationManager.swift new file mode 100644 index 00000000..702036c0 --- /dev/null +++ b/Projects/Features/Sources/PushNotificationManager.swift @@ -0,0 +1,104 @@ +// +// PushNotificationManager.swift +// Feature +// +// Created by ì´ì˜ë¹ˆ on 2023/09/04. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import ComposableArchitecture +import Foundation +import SwiftUI + +import FirebaseCore +import FirebaseMessaging + +import UserNotifications +import Network + +public final class PushNotificationManager: NSObject { + public private(set) var isPushNotificationGranted: Bool = false + private let userNotificationCenter = UNUserNotificationCenter.current() + private var application: UIApplication = .shared + + private var fcmToken: String? + private let tokenSemaphore = DispatchSemaphore(value: 0) + + /// 쓰레드 블로킹ì´ë¼ 웬만하면 비ë™ê¸°ë¡œ 처리하세요. 까딱하다 앱 작살남 + public func registerPushNotification() async -> String? { + userNotificationCenter.delegate = self + Messaging.messaging().delegate = self + + do { + isPushNotificationGranted = try await userNotificationCenter.requestAuthorization( + options: [.alert, .badge, .sound]) + + guard isPushNotificationGranted else { + return nil + } + + // í‘¸ì‹œí† í° ì• í”Œ ì„œë²„ì— ë“±ë¡í•˜ê¸° + let settings = await userNotificationCenter.notificationSettings() + guard settings.authorizationStatus == .authorized else { + isPushNotificationGranted = false + return nil + } + + return await waitForToken(for: application) + } catch { + return nil + } + } + + public func unregisterPushNotification() { + DispatchQueue.main.async { + self.application.unregisterForRemoteNotifications() + } + } + + private func waitForToken(for application: UIApplication) async -> String? { + await application.registerForRemoteNotifications() + + return await withCheckedContinuation { continuation in + // If the token has already been received before this method was called + if let token = self.fcmToken { + continuation.resume(returning: token) + return + } + + // Wait for the token to be received + DispatchQueue.global().async { + _ = self.tokenSemaphore.wait(timeout: .now() + 10) + if let token = self.fcmToken { + continuation.resume(returning: token) + } else { + continuation.resume(returning: nil) + } + } + } + } +} + +extension PushNotificationManager: UNUserNotificationCenterDelegate {} + +extension PushNotificationManager: MessagingDelegate { + public func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + guard let token = fcmToken else { + return + } + + self.fcmToken = token + tokenSemaphore.signal() + } +} + +extension PushNotificationManager: DependencyKey { + public static var liveValue = PushNotificationManager() +} + +extension DependencyValues { + public var notificationManager: PushNotificationManager { + get { self[PushNotificationManager.self] } + set { self[PushNotificationManager.self] = newValue } + } +} diff --git a/Projects/Features/Sources/Registration/RegisterFeature.swift b/Projects/Features/Sources/Registration/RegisterFeature.swift index 9f34c452..0965b63f 100644 --- a/Projects/Features/Sources/Registration/RegisterFeature.swift +++ b/Projects/Features/Sources/Registration/RegisterFeature.swift @@ -47,20 +47,26 @@ public struct RegistrationFeature: Reducer { case registerProfileImageResponse(thumbnailURL: URL, originalImageURL: URL) case finishRegister(nickname: String, thumbnailURL: URL?, originalImageURL: URL?) - case finishRegisterResponse(id: Int, friendCode: String) + case finishRegisterResponse(MemberUpdateDTO) } public var body: some Reducer { Reduce { state, action in switch action { case .debouncedNicknameUpdate(let nicknameString): + guard !nicknameString.isEmpty else { + return .none + } + state.nicknameTextFieldString = nicknameString + state.isNicknameAvailable = nil + return .run { send in try await withTaskCancellation( id: CancelID.debouncedNicknameUpdate, cancelInFlight: true ) { - try await self.clock.sleep(for: .seconds(0.7)) + try await self.clock.sleep(for: .seconds(0.3)) await send(.checkDuplicatedNickname(nicknameString)) } @@ -112,10 +118,7 @@ public struct RegistrationFeature: Reducer { profileThumbnail: originalImageURL?.absoluteString)), object: MemberUpdateDTO.self) - await send( - .finishRegisterResponse( - id: result.data.id, - friendCode: result.data.friendCode ?? "")) // TODO: ë‚˜ì¤‘ì— non-null ê°’ í•„ìš” + await send(.finishRegisterResponse(result)) } case .finishRegisterResponse: diff --git a/Projects/Features/Sources/Registration/RegistrationView.swift b/Projects/Features/Sources/Registration/RegistrationView.swift index 377b654c..420b4e84 100644 --- a/Projects/Features/Sources/Registration/RegistrationView.swift +++ b/Projects/Features/Sources/Registration/RegistrationView.swift @@ -7,6 +7,7 @@ // import Core +import DSKit import SwiftUI import PhotosUI @@ -22,6 +23,7 @@ public struct RegistrationView: View { } // 닉네임 관련 프로í¼í‹° + @FocusState private var isTextFieldFocused: Bool @State private var nickname = "" // 사용ìžê°€ 새롭게 입력한 닉네임 @State private var beforeNickname = "" // ê¸°ì¡´ì— ìž…ë ¥í–ˆë˜ ë‹‰ë„¤ìž„ @State private var isShake = false // 최대 ê¸€ìž ìˆ˜ë¥¼ 넘긴 경우 좌, ìš°ë¡œ 떨리는 애니메ì´ì…˜ @@ -32,7 +34,12 @@ public struct RegistrationView: View { public var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in - VStack(spacing: 12) { + VStack(spacing: 0) { + Text.keyme("회ì›ê°€ìž…", font: .body3Semibold) + .foregroundColor(.white) + .padding(.top, 16) + .padding(.bottom, 24) + // 프로필 ì´ë¯¸ì§€ë¥¼ 등ë¡í•˜ëŠ” Circle PhotosPicker(selection: $selectedImage, matching: .images, photoLibrary: .shared()) { profileImage(imageData: selectedImageData) @@ -49,57 +56,72 @@ public struct RegistrationView: View { } } - // 닉네임 관련 안내메세지 + Spacer().frame(height: 59) + HStack(alignment: .center, spacing: 4) { - Text("닉네임") - .font(.system(size: 14)) + Text.keyme("닉네임", font: .body3Regular) + .foregroundColor(.white) - Text("(\(nickname.count)/6)") - .font(.system(size: 12)) - .foregroundColor(.gray) + Text.keyme("(\(nickname.count)/6)", font: .caption1) + .foregroundColor(DSKitAsset.Color.keymeMediumgray.swiftUIColor) Spacer() - Text("2~6ìžë¦¬ 한글, ì˜ì–´, 숫ìž") - .font(.system(size: 12)) + Text.keyme("2~6ìžë¦¬ 한글, ì˜ì–´, 숫ìž", font: .caption1) + .foregroundColor(.white) } - .padding(.horizontal, 2) + + Spacer().frame(height: 12) // ë‹‰ë„¤ìž„ì„ ìž…ë ¥í•˜ëŠ” TextField - TextField("ë‹‰ë„¤ìž„ì„ ìž…ë ¥í•´ì£¼ì„¸ìš”.", text: $nickname) - .font(.system(size: 16)) + TextField("Nickname", text: $nickname) + .focused($isTextFieldFocused) + .placeholder(when: nickname.isEmpty, placeholder: { + Text.keyme("ë‹‰ë„¤ìž„ì„ ìž…ë ¥í•´ì£¼ì„¸ìš”.", font: .body3Regular) + .foregroundColor(.white.opacity(0.4)) + }) + .foregroundColor(.white) + .padding(.horizontal, 10) .frame(height: 50) - .padding(.horizontal) .overlay( RoundedRectangle(cornerRadius: 4) - .stroke(.gray, lineWidth: 1) + .stroke(.white.opacity(0.3), lineWidth: 1) + ) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.black.opacity(0.4)) ) .modifier(Shake(isShake: $isShake)) - if !nickname.isEmpty, let isValid = viewStore.isNicknameAvailable { + if + !nickname.isEmpty, + let isValid = viewStore.state.isNicknameAvailable + { ValidateNicknameView(isValid: isValid) + .padding(.top, 12) } + Spacer(minLength: 50) + // 닉네임 관련 안내메세지 Rectangle() .frame(height: 80) - .foregroundColor(.gray) + .foregroundColor( + DSKitAsset.Color.keymeBlack.swiftUIColor.opacity(0.8)) .cornerRadius(8) .overlay( - Text("ì¹œêµ¬ë“¤ì´ ì›í• í•˜ê²Œ 문제를 í’€ 수 있ë„ë¡, 나를 가장 잘 나타내는 닉네임으로 설정해주세요. \(viewStore.canRegister.description)") - .font(.system(size: 14)) - .fontWeight(.semibold) + Text.keyme("ì¹œêµ¬ë“¤ì´ ì›í™œí•˜ê²Œ 문제를 í’€ 수 있ë„ë¡, 나를 가장 잘 나타내는 닉네임으로 설정해주세요.", font: .body4) + .lineSpacing(10) .foregroundColor(.white) - .padding(.horizontal, 8) ) - - Spacer() + .padding(.bottom, 64) // ë‹¤ìŒ íŽ˜ì´ì§€ë¡œ 넘어가기 위한 Button Button(action: { + HapticManager.shared.boong() viewStore.send( .finishRegister( - nickname: viewStore.state.nicknameTextFieldString, + nickname: viewStore.nicknameTextFieldString, thumbnailURL: viewStore.thumbnailURL, originalImageURL: viewStore.originalImageURL)) }) { @@ -115,11 +137,11 @@ public struct RegistrationView: View { } } .foregroundColor(.white) - .background(viewStore.canRegister ? .black : .gray) + .background(viewStore.state.canRegister ? .black : .white.opacity(0.3)) .cornerRadius(16) - .disabled(viewStore.canRegister ? false : true) + .padding(.bottom, 20) + .disabled(viewStore.state.canRegister ? false : true) } - .modifier(DismissKeyboardOnTap()) .padding(.horizontal, 16) .onChange(of: nickname) { newValue in guard 1 <= newValue.count, newValue.count <= 6 else { @@ -136,6 +158,12 @@ public struct RegistrationView: View { viewStore.send(.debouncedNicknameUpdate(text: newValue)) } + .fullFrame() + .ignoresSafeArea(.keyboard, edges: .bottom) + } + .contentShape(Rectangle()) + .onTapGesture { + isTextFieldFocused = false } } } @@ -143,7 +171,15 @@ public struct RegistrationView: View { // ë‹‰ë„¤ìž„ì— ëŒ€í•œ ê²€ì¦ ì—¬ë¶€ë¥¼ 보여주는 ë·° extension RegistrationView { func profileImage(imageData: Data?) -> some View { - ZStack(alignment: .bottomTrailing) { + let outercircleSize = 160.0 + let iconSize = 33.3 + + return ZStack(alignment: .center) { + Circle() + .foregroundColor(.white.opacity(0.15)) + .overlay(Circle().stroke(.white.opacity(0.30), lineWidth: 1)) + .frame(width: outercircleSize, height: outercircleSize) + Group { if let selectedImageData = imageData, @@ -152,25 +188,22 @@ extension RegistrationView { Image(uiImage: profileImage) .resizable() .scaledToFill() + .frame(width: outercircleSize - 20, height: outercircleSize - 20) } else { - Circle() - .foregroundColor(.gray) - .overlay(Circle().stroke(.white, lineWidth: 1)) + ZStack { + Circle() + .foregroundColor(DSKitAsset.Color.keymeBlack.swiftUIColor.opacity(0.8)) + .frame(width: outercircleSize - 20, height: outercircleSize - 20) + + Image(systemName: "photo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: iconSize, height: iconSize) + .foregroundColor(.white) + } } } - .frame(width: 160, height: 160) .clipShape(Circle()) - - ZStack { - Circle() - .foregroundColor(.black) - .frame(width: 50, height: 50) - Image(systemName: "photo") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 33.3, height: 33.3) - .foregroundColor(.white) - } } } @@ -192,3 +225,11 @@ extension RegistrationView { } } } + +struct RegistrationView_preview: PreviewProvider { + static var previews: some View { + RegistrationView(store: Store(initialState: RegistrationFeature.State(), reducer: { + RegistrationFeature() + })) + } +} diff --git a/Projects/Features/Sources/Root/RootFeature+.swift b/Projects/Features/Sources/Root/RootFeature+.swift deleted file mode 100644 index 45f9ca0e..00000000 --- a/Projects/Features/Sources/Root/RootFeature+.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// RootFeature+.swift -// Features -// -// Created by ì´ì˜ë¹ˆ on 2023/08/25. -// Copyright © 2023 team.humanwave. All rights reserved. -// - -import Combine -import ComposableArchitecture - -import Foundation -import FirebaseCore -import FirebaseMessaging - -import UserNotifications -import Network - -import SwiftUI - -extension RootFeature { - final class UserNotificationCenterDelegateManager: NSObject { - private var fcmToken: String? - private let tokenSemaphore = DispatchSemaphore(value: 0) - - func waitForToken() async -> String? { - startRegister() - - return await withCheckedContinuation { continuation in - // If the token has already been received before this method was called - if let token = self.fcmToken { - continuation.resume(returning: token) - return - } - - // Wait for the token to be received - DispatchQueue.global().async { - _ = self.tokenSemaphore.wait(timeout: .now() + 20) - if let token = self.fcmToken { - continuation.resume(returning: token) - } else { - continuation.resume(returning: nil) - } - } - } - } - - private func startRegister() { - Messaging.messaging().delegate = self - - UNUserNotificationCenter.current().delegate = self - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in - guard granted else { return } - - // í‘¸ì‹œí† í° ì• í”Œ ì„œë²„ì— ë“±ë¡í•˜ê¸° - UNUserNotificationCenter.current().getNotificationSettings { settings in - guard settings.authorizationStatus == .authorized else { return } - - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() - } - } - } - } - } -} - -extension RootFeature.UserNotificationCenterDelegateManager: MessagingDelegate { - func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - guard let token = fcmToken else { - return - } - - self.fcmToken = token - tokenSemaphore.signal() - } -} - -extension RootFeature.UserNotificationCenterDelegateManager: UNUserNotificationCenterDelegate { - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) } - let token = tokenParts.joined() - Messaging.messaging().setAPNSToken(deviceToken, type: .unknown) - - print("firebase Device Token: \(token)") - } - - func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - print("firebase Failed to register for remote notifications: \(error)") - } -} diff --git a/Projects/Features/Sources/Root/RootFeature.swift b/Projects/Features/Sources/Root/RootFeature.swift index ea4e001d..c5787217 100644 --- a/Projects/Features/Sources/Root/RootFeature.swift +++ b/Projects/Features/Sources/Root/RootFeature.swift @@ -2,232 +2,195 @@ // RootFeature.swift // Features // -// Created by ì´ì˜ë¹ˆ on 2023/08/10. -// Edited by ê³ ë„ on 2023/08/14. -// +// Created by ì´ì˜ë¹ˆ on 2023/09/04. // Copyright © 2023 team.humanwave. All rights reserved. // import Foundation - import Domain import Network import ComposableArchitecture +import SwiftUI public struct RootFeature: Reducer { @Dependency(\.userStorage) private var userStorage + @Dependency(\.notificationManager) private var notificationManager @Dependency(\.keymeAPIManager) private var network + var authorizationToken: String? { + @Dependency(\.keymeAPIManager.authorizationToken) var token + return token + } + public init() {} - public struct State: Equatable { - @PresentationState public var logInStatus: SignInFeature.State? - @PresentationState public var registrationState: RegistrationFeature.State? - @PresentationState public var onboardingStatus: OnboardingFeature.State? - @PresentationState public var mainPageState: MainPageFeature.State? - - public init( - isLoggedIn: Bool? = nil, - doneRegistration: Bool? = nil, - doneOnboarding: Bool? = nil - ) { - registrationState = .init() - onboardingStatus = .init() - - if let isLoggedIn { - logInStatus = isLoggedIn ? .loggedIn : .loggedOut - } else { - logInStatus = .notDetermined - } - - if let doneRegistration { - registrationState?.status = doneRegistration ? .complete : .needsRegister - } else { - registrationState?.status = .notDetermined - } - - if let doneOnboarding { - onboardingStatus?.status = doneOnboarding ? .completed : .needsOnboarding - } else { - onboardingStatus?.status = .notDetermined - } - } + public enum State: Equatable { + case notDetermined + case needSignIn(SignInFeature.State) + case needRegistration(RegistrationFeature.State) + case needOnboarding(OnboardingFeature.State) + case canUseApp(MainPageFeature.State) } public enum Action { - case login(PresentationAction) - case registration(PresentationAction) - case onboarding(PresentationAction) - case mainPage(PresentationAction) - case onboardingChecked(TaskResult) - - case checkUserStatus + public enum View { + case checkUserStatus + } + case view(View) - case checkLoginStatus - case checkRegistrationStatus - case checkOnboardingStatus + case login(SignInFeature.Action) + case registration(RegistrationFeature.Action) + case onboarding(OnboardingFeature.Action) + case mainPage(MainPageFeature.Action) - case updateMemberInformation - case startMainPage(userId: Int, nickname: String) + case updateState(State) + case updateMemberInformation(withMemberData: MemberUpdateDTO.MemberData?, authorizationToken: String) + case registerPushNotification } public var body: some ReducerOf { Reduce { state, action in switch action { - case .login(.presented(.signInWithAppleResponse(let response))): + case .view(.checkUserStatus): + let accessToken = userStorage.accessToken + if let accessToken { // ë¡œê·¸ì¸ ìƒíƒœ + network.registerAuthorizationToken(accessToken) + return .send(.updateMemberInformation( + withMemberData: nil, + authorizationToken: accessToken)) + } else { // 로그 아웃 ìƒíƒœ + return .send(.updateState(.needSignIn(SignInFeature.State()))) + } + + case .updateMemberInformation(let receviedMemberData, let accessToken): + return .run(priority: .userInitiated) { send in + let memberInformation: MemberUpdateDTO.MemberData + if let receviedMemberData { + memberInformation = receviedMemberData + } else { + memberInformation = try await network.request( + .member(.fetch), + object: MemberUpdateDTO.self + ).data + } + + userStorage.userId = memberInformation.id + userStorage.nickname = memberInformation.nickname + userStorage.friendCode = memberInformation.friendCode + + if let profileImageURL = URL(string: memberInformation.profileImage) { + userStorage.profileImageURL = profileImageURL + } + + if let profileThumbnailURL = URL(string: memberInformation.profileImage) { + userStorage.profileThumbnailURL = profileThumbnailURL + } + + if let userId = memberInformation.id, let nickname = memberInformation.nickname { + if memberInformation.isOnboardingClear != true { + await send( + .updateState( + .needOnboarding(OnboardingFeature.State(authorizationToken: accessToken)))) + } else { + await send(.updateState( + .canUseApp(MainPageFeature.State(userId: userId, nickname: nickname)))) + } + } else { + await send(.updateState(.needRegistration(RegistrationFeature.State()))) + } + + await send(.registerPushNotification) + } + + case .updateState(let receivedState): + state = receivedState + return .none + + case .registerPushNotification: + Task { + guard let token = await notificationManager.registerPushNotification() else { + print("í‘¸ì‹œí† í° ë“±ë¡ ì¤‘ ì—러 ë°œìƒ") + return + } + + _ = try await network.request(.registerPushToken(.register(token))) + } + + return .none + + // MARK: - Child actions + case .login(.signInWithAppleResponse(let response)): switch response { case .success(let body): let token = body.data.token.accessToken userStorage.accessToken = token network.registerAuthorizationToken(token) - if body.data.nickname == nil { - state.registrationState?.status = .needsRegister - } else { - state.registrationState?.status = .complete - } - return .none + return .send(.updateMemberInformation(withMemberData: nil, authorizationToken: token)) case .failure: return .none } - case .login(.presented(.signInWithKakaoResponse(let response))): + case .login(.signInWithKakaoResponse(let response)): switch response { case .success(let body): let token = body.data.token.accessToken userStorage.accessToken = token network.registerAuthorizationToken(token) - if body.data.nickname == nil { - state.registrationState?.status = .needsRegister - } else { - state.registrationState?.status = .complete - } - return .none + return .send(.updateMemberInformation(withMemberData: nil, authorizationToken: token)) case .failure: return .none } - case .checkUserStatus: - let accessToken = userStorage.accessToken - if accessToken == nil { - state.logInStatus = .loggedOut - - return .none - } else { - state.logInStatus = .loggedIn - network.registerAuthorizationToken(accessToken) - - return .run { send in - await send(.updateMemberInformation) - await send(.checkRegistrationStatus) - await send(.checkOnboardingStatus) - } + case .registration(.finishRegisterResponse(let response)): + guard let token = authorizationToken else { + // ë¡œê·¸ì¸ ìž¬ì‹œë„ + return gotoSignInState } + return .send(.updateMemberInformation(withMemberData: response.data, authorizationToken: token)) - case .checkLoginStatus: - let accessToken = userStorage.accessToken - if accessToken == nil { - state.logInStatus = .loggedOut - } else { - state.logInStatus = .loggedIn - network.registerAuthorizationToken(accessToken) + case .onboarding(.testResult(.closeButtonDidTap)): + guard let token = authorizationToken else { + // ë¡œê·¸ì¸ ìž¬ì‹œë„ + return gotoSignInState } - - return .none - - case .registration(.presented(.finishRegisterResponse)): - // Do nothing currently - return .none - - case .checkRegistrationStatus: - let nickname: String? = userStorage.nickname - - if nickname == nil { - state.registrationState?.status = .needsRegister - } else { - state.registrationState?.status = .complete + guard let userId = userStorage.userId, let nickname = userStorage.nickname else { + // 멤버 ì •ë³´ 수신 ìž¬ì‹œë„ + return .send(.updateMemberInformation(withMemberData: nil, authorizationToken: token)) } + return .send(.updateState(.canUseApp(MainPageFeature.State(userId: userId, nickname: nickname)))) - return .none - - case .checkOnboardingStatus: - return .run(priority: .userInitiated) { send in - await send(.onboardingChecked( - TaskResult { - // TODO: API 갈아ë¼ìš°ê¸° -// try await Task.sleep(until: .now + .seconds(0.1), clock: .continuous) - return false - } - )) - } + case .mainPage(.myPage(.setting(.presented(.view(.logout))))): + userStorage.accessToken = nil + // Logout + return gotoSignInState - case .onboardingChecked(.success(let result)): - switch result { - case true: - state.onboardingStatus?.status = .completed - case false: - state.onboardingStatus?.status = .needsOnboarding - } - return .none - - case .updateMemberInformation: - return .run(priority: .userInitiated) { send in - let memberInformation = try await network.request( - .member(.fetch), - object: MemberUpdateDTO.self).data - - userStorage.userId = memberInformation.id - userStorage.nickname = memberInformation.nickname - - if let friendCode = memberInformation.friendCode { - userStorage.friendCode = friendCode - } - - if let profileImageURL = URL(string: memberInformation.profileImage) { - userStorage.profileImageURL = profileImageURL - } - - if let profileThumbnailURL = URL(string: memberInformation.profileImage) { - userStorage.profileThumbnailURL = profileThumbnailURL - } - - Task.detached(priority: .low) { - let notificationDelegate = UserNotificationCenterDelegateManager() - guard let token = await notificationDelegate.waitForToken() else { - print("ERROR TOEKN PUSH") - return - } - - _ = try await network.request(.registerPushToken(.register(token))) - } - - await send(.startMainPage( - userId: memberInformation.id, - nickname: memberInformation.nickname)) - } - - case .startMainPage(let userId, let nickname): - state.mainPageState = MainPageFeature.State(userId: userId, nickname: nickname) - return .none + case .mainPage(.home(.requestLogout)): + // Logout + return gotoSignInState default: return .none } } - .ifLet(\.$logInStatus, action: /Action.login) { + .ifCaseLet(/State.needSignIn, action: /Action.login) { SignInFeature() } - .ifLet(\.$registrationState, action: /Action.registration) { + .ifCaseLet(/State.needRegistration, action: /Action.registration) { RegistrationFeature() } - .ifLet(\.$onboardingStatus, action: /Action.onboarding) { + .ifCaseLet(/State.needOnboarding, action: /Action.onboarding) { OnboardingFeature() } - .ifLet(\.$mainPageState, action: /Action.mainPage) { + .ifCaseLet(/State.canUseApp, action: /Action.mainPage) { MainPageFeature() } } + + private var gotoSignInState: Effect { + .send(.updateState(.needSignIn(SignInFeature.State()))) + } } diff --git a/Projects/Features/Sources/Root/RootView.swift b/Projects/Features/Sources/Root/RootView.swift index 56d26c43..ab64ad6e 100644 --- a/Projects/Features/Sources/Root/RootView.swift +++ b/Projects/Features/Sources/Root/RootView.swift @@ -1,80 +1,98 @@ // // RootView.swift -// Keyme +// Features // -// Created by ì´ì˜ë¹ˆ on 2023/08/09. +// Created by ì´ì˜ë¹ˆ on 2023/09/04. // Copyright © 2023 team.humanwave. All rights reserved. // import SwiftUI +import Core import ComposableArchitecture +import DSKit public struct RootView: View { + @State private var showBlurringBackground = false private let store: StoreOf public init() { - self.store = Store(initialState: RootFeature.State()) { + self.store = Store(initialState: RootFeature.State.notDetermined) { RootFeature() } - store.send(.checkUserStatus) + store.send(.view(.checkUserStatus)) } public var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - if viewStore.logInStatus == .notDetermined { - // 여기 걸리면 ì—러임. 조심하셈. - EmptyView() - } else if viewStore.logInStatus == .loggedOut { - // 회ì›ê°€ìž…ì„ í•˜ì§€ 않았거나 로그ì¸ì„ 하지 ì•Šì€ ìœ ì € - let loginStore = store.scope( - state: \.$logInStatus, - action: RootFeature.Action.login) - - IfLetStore(loginStore) { store in - SignInView(store: store) + ZStack { + // 애니메ì´ì…˜ 부웅.. 부웅.. + KeymeLottieView(asset: .background, loopMode: .autoReverse) + .ignoresSafeArea() + + SwitchStore(store) { state in + // 블러 ê¹” 것ì¸ì§€ íŒë‹¨(ë¡œê·¸ì¸ ì•„ë‹ˆë©´ ê¹ë‹¤) + if case .needSignIn = state { + EmptyView() + } else { + BackgroundBlurringView(style: .dark) + .ignoresSafeArea() + .transition(.opacity.animation(Animation.customInteractiveSpring())) } - } else if viewStore.registrationState?.status == .notDetermined { - // ê°œì¸ì •ë³´ ë“±ë¡ ìƒíƒœë¥¼ 로딩 중 - ProgressView() - } else if viewStore.registrationState?.status == .needsRegister { - // ê°œì¸ì •ë³´ ë“±ë¡ - let registrationStore = store.scope( - state: \.$registrationState, - action: RootFeature.Action.registration) + + switch state { + case .needSignIn: + CaseLet( + /RootFeature.State.needSignIn, + action: RootFeature.Action.login + ) { store in + SignInView(store: store) + } + .zIndex(ViewZIndex.siginIn.rawValue) + .transition(.opacity.animation(Animation.customInteractiveSpring())) + + case .needRegistration: + CaseLet( + /RootFeature.State.needRegistration, + action: RootFeature.Action.registration + ) { store in + RegistrationView(store: store) + } + .zIndex(ViewZIndex.registration.rawValue) + .transition(.opacity.animation(Animation.customInteractiveSpring())) - IfLetStore(registrationStore) { store in - RegistrationView(store: store) - } - } else if viewStore.onboardingStatus?.status == .notDetermined { - // 온보딩 ìƒíƒœë¥¼ 로딩 중 - ProgressView() - } else if viewStore.onboardingStatus?.status == .needsOnboarding { - // 가입했지만 ì˜¨ë³´ë”©ì„ í•˜ì§€ ì•Šê³  ì¢…ë£Œí–ˆë˜ ìœ ì € - let onboardingStore = store.scope( - state: \.$onboardingStatus, - action: RootFeature.Action.onboarding) + case .needOnboarding: + CaseLet( + /RootFeature.State.needOnboarding, + action: RootFeature.Action.onboarding + ) { store in + OnboardingView(store: store) + } + .zIndex(ViewZIndex.onboarding.rawValue) + .transition(.opacity.animation(Animation.customInteractiveSpring())) - IfLetStore(onboardingStore) { store in - OnboardingView(store: store) - } - } else { - // 가입했고 ì˜¨ë³´ë”©ì„ ì§„í–‰í•œ 유저 - let mainPageStore = store.scope(state: \.$mainPageState, action: RootFeature.Action.mainPage) - - IfLetStore(mainPageStore) { store in - KeymeMainView(store: store) - .transition(.opacity) - } else: { - Text("ì—러") + case .canUseApp: + CaseLet( + /RootFeature.State.canUseApp, + action: RootFeature.Action.mainPage + ) { store in + KeymeMainView(store: store) + } + .zIndex(ViewZIndex.main.rawValue) + .transition(.opacity.animation(Animation.customInteractiveSpring())) + + default: + Text("") } } } } } -struct RootView_Previews: PreviewProvider { - static var previews: some View { - RootView() +private extension RootView { + enum ViewZIndex: CGFloat { + case siginIn = 4 + case registration = 3 + case onboarding = 2 + case main = 1 } } diff --git a/Projects/Features/Sources/Setting/SettingFeature.swift b/Projects/Features/Sources/Setting/SettingFeature.swift new file mode 100644 index 00000000..aa3e0c10 --- /dev/null +++ b/Projects/Features/Sources/Setting/SettingFeature.swift @@ -0,0 +1,73 @@ +// +// SettingFeature.swift +// Features +// +// Created by Young Bin on 2023/09/02. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import ComposableArchitecture + +import Domain +import Network + +public struct SettingFeature: Reducer { + @Dependency(\.notificationManager) var notificationManager + + public struct State: Equatable { + var isPushNotificationEnabled: Bool + + init() { + @Dependency(\.notificationManager.isPushNotificationGranted) var isPushNotificationGranted + self.isPushNotificationEnabled = isPushNotificationGranted + print("@@ init") + } + } + + public enum Action: Equatable { + public enum View: Equatable { + case logout + case withdrawal + case togglePushNotification + } + + case view(View) + case setPushNotificationStatus(Bool) + } + + public init() { } + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.logout): + print("logout from setting") + return .none + + case .view(.withdrawal): + // TODO: Call api + return .none + + case .view(.togglePushNotification): + if state.isPushNotificationEnabled == false { + // 푸시알림 설정 + return .run { send in + guard await notificationManager.registerPushNotification() != nil else { + return + } + await send(.setPushNotificationStatus(true)) + } + } else { + // 푸시알림 í•´ì œ + notificationManager.unregisterPushNotification() + return .send(.setPushNotificationStatus(false)) + } + + case .setPushNotificationStatus(let value): + state.isPushNotificationEnabled = value + print("@@", value) + return .none + } + } + } +} diff --git a/Projects/Features/Sources/Setting/SettingView.swift b/Projects/Features/Sources/Setting/SettingView.swift new file mode 100644 index 00000000..ad54a479 --- /dev/null +++ b/Projects/Features/Sources/Setting/SettingView.swift @@ -0,0 +1,131 @@ +// +// SettingView.swift +// Features +// +// Created by Young Bin on 2023/09/02. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import ComposableArchitecture +import DSKit +import SwiftUI +import SwiftUIIntrospect + +struct SettingView: View { + @State private var showAlert = false + @State private var alertItem: AlertItem? + + private let store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + WithViewStore(store, observe: { $0 }, send: SettingFeature.Action.view) { viewStore in + ZStack { + DSKitAsset.Color.keymeBlack.swiftUIColor + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 30) { + section(title: "ê°œì¸ì •ë³´") { + logoutButton(action: { viewStore.send(.logout) }) + withdrawlButton(action: { viewStore.send(.withdrawal) }) + } + + Divider() + + section(title: "마케팅 ì •ë³´ 수신 ë™ì˜") { + pushNotificationToggleButton( + isOn: viewStore.binding( + get: \.isPushNotificationEnabled, + send: .togglePushNotification)) + } + } + .fullFrame() + .foregroundColor(.white) + .scrollContentBackground(.hidden) + .padding(.horizontal, 34) + .padding(.top, 40) + } + .alert("ì•—!", isPresented: $showAlert, presenting: alertItem) { item in + Button("취소", role: .cancel) { } + Button(item.actionButtonName) { item.action() } + } message: { item in + Text(item.message) + } + } + } + } +} + +private extension SettingView { + func logoutButton(action: @escaping () -> Void) -> some View { + Button(action: { + showAlert = true + alertItem = AlertItem( + message: "ì •ë§ ë¡œê·¸ì•„ì›ƒ 하시겠어요?", + actionButtonName: "로그아웃", + action: action + ) + }) { + item(text: "로그아웃").frame(minWidth: 0, maxWidth: .infinity) + } + } + + func withdrawlButton(action: @escaping () -> Void) -> some View { + Button(action: { + showAlert = true + alertItem = AlertItem( + message: "탈퇴 ì‹œ 모든 ì •ë³´ê°€ ì‚­ì œë©ë‹ˆë‹¤. ì •ë§ íƒˆí‡´í•˜ì‹œê² ì–´ìš”?", + actionButtonName: "회ì›íƒˆí‡´", + action: action + ) + }) { + item(text: "서비스 탈퇴").frame(minWidth: 0, maxWidth: .infinity) + } + } + + func pushNotificationToggleButton(isOn binding: Binding) -> some View { + HStack { + item(text: "푸시 알림") + + Spacer() + + Toggle("", isOn: binding) + } + } + + func section(title: String, @ViewBuilder items: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 0) { + caption(text: title) + .padding(.bottom, 24) + + VStack(alignment: .leading, spacing: 0) { + items() + } + .frame(minWidth: 0, maxWidth: .infinity) + } + } + + func caption(text: String) -> some View { + Text.keyme(text, font: .body4) + .foregroundColor(DSKitAsset.Color.keymeMediumgray.swiftUIColor) + } + + func item(text: String) -> some View { + HStack { + Text.keyme(text, font: .body2) + .foregroundColor(DSKitAsset.Color.keymeWhite.swiftUIColor) + Spacer() + } + .padding(.vertical, 12) + } +} + +private struct AlertItem { + let message: String + let actionButtonName: String + let action: () -> Void +} diff --git a/Projects/Features/Sources/ShareSheet/ShareSheetView.swift b/Projects/Features/Sources/ShareSheet/ShareSheetView.swift index 36de76de..e861b521 100644 --- a/Projects/Features/Sources/ShareSheet/ShareSheetView.swift +++ b/Projects/Features/Sources/ShareSheet/ShareSheetView.swift @@ -15,13 +15,13 @@ struct ActivityViewController: UIViewControllerRepresentable { @Binding var isPresented: Bool var activityItems: [Any] - var applicationActivities: [UIActivity]? = nil + var applicationActivities: [UIActivity]? func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIActivityViewController { let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities) // Set the completion handler - controller.completionWithItemsHandler = { (activityType, completed, returnedItems, error) in + controller.completionWithItemsHandler = { (_, _, _, _) in // Dismiss the share sheet when the share action completes (whether successful or not) self.isPresented = false } @@ -34,7 +34,6 @@ struct ActivityViewController: UIViewControllerRepresentable { } } - extension ActivityViewController { struct SharedURL: Identifiable { let id = UUID() diff --git a/Projects/Features/Sources/SignIn/SignInFeature.swift b/Projects/Features/Sources/SignIn/SignInFeature.swift index ccfa0b08..b87a8a9b 100644 --- a/Projects/Features/Sources/SignIn/SignInFeature.swift +++ b/Projects/Features/Sources/SignIn/SignInFeature.swift @@ -24,10 +24,15 @@ public enum SignInError: Error { public struct SignInFeature: Reducer { @Dependency(\.keymeAPIManager) var network - public enum State: Equatable { - case notDetermined - case loggedIn - case loggedOut + public struct State: Equatable { + var isLoading: Bool = false + var status: Status = .notDetermined + + enum Status { + case notDetermined + case loggedIn + case loggedOut + } } public enum Action: Equatable { @@ -43,20 +48,26 @@ public struct SignInFeature: Reducer { switch action { // MARK: - Kakao case .signInWithKakao: + state.isLoading = true + return .run { send in await send(.signInWithKakaoResponse(TaskResult { try await signInWithKakao() })) } case .signInWithKakaoResponse(.success): // 카카오 ë¡œê·¸ì¸ ì„±ê³µ - state = .loggedIn + state.isLoading = false + state.status = .loggedIn return .none case .signInWithKakaoResponse(.failure): // 카카오 ë¡œê·¸ì¸ ì‹¤íŒ¨ - state = .loggedOut + state.isLoading = false + state.status = .loggedOut return .none // MARK: - Apple case .signInWithApple(let authorization): + state.isLoading = true + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential, let identityTokenData = appleIDCredential.identityToken, @@ -72,11 +83,11 @@ public struct SignInFeature: Reducer { } case .signInWithAppleResponse(.success): // 애플 ë¡œê·¸ì¸ ì„±ê³µ - state = .loggedIn + state.status = .loggedIn return .none case .signInWithAppleResponse(.failure): // 애플 ë¡œê·¸ì¸ ì‹¤íŒ¨ - state = .loggedOut + state.status = .loggedOut return .none } } diff --git a/Projects/Features/Sources/SignIn/SignInView.swift b/Projects/Features/Sources/SignIn/SignInView.swift index e18a953e..7f70e138 100644 --- a/Projects/Features/Sources/SignIn/SignInView.swift +++ b/Projects/Features/Sources/SignIn/SignInView.swift @@ -9,7 +9,9 @@ // import AuthenticationServices +import Core import ComposableArchitecture +import DSKit import SwiftUI import Network @@ -21,16 +23,33 @@ public struct SignInView: View { } public var body: some View { - VStack(alignment: .center, spacing: 0) { - Spacer() - - KakaoLoginButton(store: store) - - AppleLoginButton(store: store) + WithViewStore(store, observe: { $0 }) { viewStore in + if viewStore.state.isLoading { + ProgressView() + } + } + + ZStack(alignment: .center) { + Text.keyme("KEYME", font: .checkResult) + .foregroundColor(.white) + .offset(y: -39) - GuideMessageView() + VStack(alignment: .center, spacing: 30) { + Spacer() + + VStack(spacing: 16) { + KakaoLoginButton(store: store) + .frame(height: 48) + + AppleLoginButton(store: store) + .frame(height: 48) + } + + GuideMessageView() + } + .padding(.horizontal, 32) + .padding(.bottom, 56) } - .padding() } // 카카오 ë¡œê·¸ì¸ ë²„íŠ¼ @@ -40,12 +59,12 @@ public struct SignInView: View { var body: some View { Button(action: { store.send(.signInWithKakao) + HapticManager.shared.boong() }) { Image("kakao_login") .resizable() .scaledToFill() } - .frame(width: 312, height: 48) .cornerRadius(6) } } @@ -57,6 +76,7 @@ public struct SignInView: View { var body: some View { SignInWithAppleButton( onRequest: { request in + HapticManager.shared.boong() request.requestedScopes = [.fullName, .email] }, onCompletion: { completion in @@ -68,20 +88,26 @@ public struct SignInView: View { } }) .signInWithAppleButtonStyle(.white) - .frame(width: 312, height: 48) .cornerRadius(6) - .padding(.vertical) } } struct GuideMessageView: View { + let serviceTermURLString = "https://keyme.notion.site/Keyme-b1f3902d8fe04b97be6d8835119887cd?pvs=4" + let privacyTermURLString = "https://keyme.notion.site/Keyme-46bef61be1204fc594a49e85e5913a39?pvs=4" + var body: some View { VStack(spacing: 8) { Text("가입 ì‹œ, í‚¤ë¯¸ì˜ ë‹¤ìŒ ì‚¬í•­ì— ë™ì˜í•˜ëŠ” 것으로 간주합니다.") .foregroundColor(.gray) HStack(spacing: 4) { - Button(action: {}) { + Button(action: { + guard let serviceTermURL = URL(string: serviceTermURLString) else { + return + } + UIApplication.shared.open(serviceTermURL) + }) { Text("서비스 ì´ìš©ì•½ê´€") .fontWeight(.bold) .foregroundColor(.white) @@ -90,7 +116,12 @@ public struct SignInView: View { Text("ë°") .foregroundColor(.gray) - Button(action: {}) { + Button(action: { + guard let privacyTermURL = URL(string: privacyTermURLString) else { + return + } + UIApplication.shared.open(privacyTermURL) + }) { Text("ê°œì¸ì •ë³´ ì •ì±…") .fontWeight(.bold) .foregroundColor(.white) @@ -99,7 +130,6 @@ public struct SignInView: View { } } .font(.system(size: 11)) - .frame(width: 265, height: 36) } } } diff --git a/Projects/Keyme/Sources/KeymeApp.swift b/Projects/Keyme/Sources/KeymeApp.swift index c58d92f7..5adfa321 100644 --- a/Projects/Keyme/Sources/KeymeApp.swift +++ b/Projects/Keyme/Sources/KeymeApp.swift @@ -1,6 +1,7 @@ import SwiftUI import UserNotifications +import Core import ComposableArchitecture import FirebaseCore import FirebaseMessaging @@ -8,51 +9,43 @@ import FirebaseMessaging import Features import Network -import KakaoSDKAuth import KakaoSDKCommon +import KakaoSDKAuth @main struct KeymeApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - let KAKAO_PRIVATE_KEY = "" // 🚨 SECRET 🚨 - - init() { - KakaoSDK.initSDK(appKey: KAKAO_PRIVATE_KEY) - } - var body: some Scene { WindowGroup { RootView() - .onOpenURL(perform: { url in + .onOpenURL { url in + print(url) if (AuthApi.isKakaoTalkLoginUrl(url)) { - AuthController.handleOpenUrl(url: url) + _ = AuthController.handleOpenUrl(url: url) } - }) + } } } } class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { + @Dependency(\.notificationManager) var notificationManager + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + if let kakaoAPIKey = Bundle.main.object(forInfoDictionaryKey: "KAKAO_API_KEY") as? String { + KakaoSDK.initSDK(appKey: kakaoAPIKey) + } FirebaseApp.configure() + + Task { await notificationManager.registerPushNotification() } - UNUserNotificationCenter.current().delegate = self - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in - guard granted else { return } - - // í‘¸ì‹œí† í° ì• í”Œ ì„œë²„ì— ë“±ë¡í•˜ê¸° - UNUserNotificationCenter.current().getNotificationSettings { settings in - guard settings.authorizationStatus == .authorized else { return } - - DispatchQueue.main.async { - application.registerForRemoteNotifications() - } - } - } + // 웹 ë·° 로딩ì†ë„ 개선 툴 + WKWebViewWarmUper.shared.prepare() + return true } } diff --git a/Projects/Network/Sources/DTO/AuthDTO.swift b/Projects/Network/Sources/DTO/AuthDTO.swift index d5bea82d..e56dc974 100644 --- a/Projects/Network/Sources/DTO/AuthDTO.swift +++ b/Projects/Network/Sources/DTO/AuthDTO.swift @@ -42,4 +42,3 @@ public struct UserData: Decodable { public struct Token: Decodable { public let accessToken: String } - diff --git a/Projects/Network/Sources/DTO/MemberUpdateDTO.swift b/Projects/Network/Sources/DTO/MemberUpdateDTO.swift index cac54c30..140cf8a0 100644 --- a/Projects/Network/Sources/DTO/MemberUpdateDTO.swift +++ b/Projects/Network/Sources/DTO/MemberUpdateDTO.swift @@ -8,16 +8,23 @@ import Foundation -public struct MemberUpdateDTO: Codable { +public struct MemberUpdateDTO: Codable, Equatable { let code: Int public let data: MemberData let message: String - public struct MemberData: Codable { + public struct MemberData: Codable, Equatable { public let friendCode: String? - public let id: Int - public let nickname: String + public let id: Int? + public let isOnboardingClear: Bool? + public let nickname: String? public let profileImage: String public let profileThumbnail: String } } + +extension MemberUpdateDTO { + public static func == (lhs: MemberUpdateDTO, rhs: MemberUpdateDTO) -> Bool { + lhs.data.id == rhs.data.id + } +} diff --git a/Projects/Network/Sources/Network/API/ShortUrlAPI.swift b/Projects/Network/Sources/Network/API/ShortUrlAPI.swift index b42ab6d0..2a30eb9d 100644 --- a/Projects/Network/Sources/Network/API/ShortUrlAPI.swift +++ b/Projects/Network/Sources/Network/API/ShortUrlAPI.swift @@ -44,7 +44,12 @@ extension ShortUrlAPI: TargetType { } public var headers: [String : String]? { - let accessToken = "e9a1ab0011a56327138c36652c2242cdff37ee1b" // TODO: 밖으로 - return ["Authorization": "Bearer \(accessToken)", "Content-Type": "application/json"] + var header = ["Content-Type": "application/json"] + + if let accessToken = Bundle.main.object(forInfoDictionaryKey: "BITLY_API_KEY") as? String { + header["Authorization"] = "Bearer \(accessToken)" + } + + return header } } diff --git a/Projects/Network/Sources/Network/Core/CoreNetworkService.swift b/Projects/Network/Sources/Network/Core/CoreNetworkService.swift index c0bd3cdb..0e9c0302 100644 --- a/Projects/Network/Sources/Network/Core/CoreNetworkService.swift +++ b/Projects/Network/Sources/Network/Core/CoreNetworkService.swift @@ -12,6 +12,7 @@ import Foundation import Moya public class CoreNetworkService { + private var token: String? public private(set) var provider: MoyaProvider init(provider: MoyaProvider) { @@ -20,6 +21,10 @@ public class CoreNetworkService { } extension CoreNetworkService: CoreNetworking { + var authorizationToken: String? { + self.token + } + func request(_ api: APIType) async throws -> Response { try await withCheckedThrowingContinuation { continuation in provider.request(api) { result in @@ -51,6 +56,7 @@ extension CoreNetworkService: CoreNetworking { plugins: [networkLogger]) provider = newProvider + token = authorizationToken } } diff --git a/Projects/Network/Sources/Network/Core/CoreNetworking.swift b/Projects/Network/Sources/Network/Core/CoreNetworking.swift index 92ae89b2..04f5a941 100644 --- a/Projects/Network/Sources/Network/Core/CoreNetworking.swift +++ b/Projects/Network/Sources/Network/Core/CoreNetworking.swift @@ -22,4 +22,6 @@ protocol CoreNetworking { /// ì¸ì¦í† í° í—¤ë”ì— ë„£ì–´ì£¼ëŠ” 메서드 func registerAuthorizationToken(_ authorizationToken: String?) + + var authorizationToken: String? { get } } diff --git a/Projects/Network/Sources/Network/Manager/APIRequestable.swift b/Projects/Network/Sources/Network/Manager/APIRequestable.swift index 77d8e43a..c7740527 100644 --- a/Projects/Network/Sources/Network/Manager/APIRequestable.swift +++ b/Projects/Network/Sources/Network/Manager/APIRequestable.swift @@ -28,4 +28,7 @@ public protocol APIRequestable { /// ì¸ì¦í† í° í—¤ë”ì— ë„£ì–´ì£¼ëŠ” 메서드 func registerAuthorizationToken(_ token: String?) + + /// ì¸ì¦í† í° + var authorizationToken: String? { get } } diff --git a/Projects/Network/Sources/Network/Manager/KeymeAPIManager.swift b/Projects/Network/Sources/Network/Manager/KeymeAPIManager.swift index 8ebfbe38..f9c44f23 100644 --- a/Projects/Network/Sources/Network/Manager/KeymeAPIManager.swift +++ b/Projects/Network/Sources/Network/Manager/KeymeAPIManager.swift @@ -32,6 +32,10 @@ public class KeymeAPIManager { } extension KeymeAPIManager: CoreNetworking { + public var authorizationToken: String? { + core.authorizationToken + } + public func registerAuthorizationToken(_ token: String?) { core.registerAuthorizationToken(token) }