diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift index 218eada7..8f56cd8c 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift @@ -48,6 +48,7 @@ public extension ModulePath { public extension ModulePath { enum Domain: String, CaseIterable { + case Application case Error case User case Report @@ -64,6 +65,7 @@ public extension ModulePath { public extension ModulePath { enum Core: String, CaseIterable { + case URLHandler case Toast case KeyChainStore case WebView diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index 1b193461..870f4114 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -28,14 +28,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { UIApplication.shared.registerForRemoteNotifications() UNUserNotificationCenter.current().delegate = self Messaging.messaging().delegate = self - + setNotification() application.registerForRemoteNotifications() - store.send(.appDelegate(.didFinishLunching)) return true } } +// MARK: - UNUserNotificationCenterDelegate extension AppDelegate: UNUserNotificationCenterDelegate { func messaging( _ messaging: Messaging, @@ -62,3 +62,41 @@ extension AppDelegate: UNUserNotificationCenterDelegate { return [.badge, .sound, .banner, .list] } } + +// MARK: - objc funcs +private extension AppDelegate { + @objc func checkPushNotificationStatus() { + UNUserNotificationCenter.current() + .getNotificationSettings { [weak self] permission in + guard let self = self else { return } + DispatchQueue.main.async { + switch permission.authorizationStatus { + case .notDetermined: + self.store.send(.appDelegate(.pushNotificationAllowStatusDidChanged(isAllow: true))) + case .denied: + self.store.send(.appDelegate(.pushNotificationAllowStatusDidChanged(isAllow: false))) + case .authorized: + self.store.send(.appDelegate(.pushNotificationAllowStatusDidChanged(isAllow: true))) + case .provisional: + self.store.send(.appDelegate(.pushNotificationAllowStatusDidChanged(isAllow: false))) + case .ephemeral: + self.store.send(.appDelegate(.pushNotificationAllowStatusDidChanged(isAllow: true))) + @unknown default: + Log.error("Unknow Notification Status") + } + } + } + } +} + +// MARK: - Private Methods +private extension AppDelegate { + func setNotification() { + NotificationCenter.default.addObserver( + self, + selector: #selector(checkPushNotificationStatus), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } +} diff --git a/Projects/Core/Network/Project.swift b/Projects/Core/Network/Project.swift index 91468fd7..cc98e573 100644 --- a/Projects/Core/Network/Project.swift +++ b/Projects/Core/Network/Project.swift @@ -18,7 +18,8 @@ let project = Project.makeModule( factory: .init( dependencies: [ .core(interface: .Network), - .core(interface: .Logger) + .core(interface: .Logger), + .core(implements: .KeyChainStore) ] ) ), diff --git a/Projects/Core/URLHandler/Interface/Sources/BottleURLType.swift b/Projects/Core/URLHandler/Interface/Sources/BottleURLType.swift new file mode 100644 index 00000000..d568ae38 --- /dev/null +++ b/Projects/Core/URLHandler/Interface/Sources/BottleURLType.swift @@ -0,0 +1,31 @@ +// +// BottleURLType.swift +// CoreURLHandlerInterface +// +// Created by JongHoon on 9/20/24. +// + +import Foundation + +public enum BottleURLType { + case bottleAppStore + case bottleAppLookUp + case kakaoChannelTalk + case setting + + public var url: URL { + switch self { + case .bottleAppStore: + return URL(string: Bundle.main.infoDictionary?["APP_STORE_URL"] as? String ?? "")! + + case .kakaoChannelTalk: + return URL(string: Bundle.main.infoDictionary?["KAKAO_CHANNEL_TALK_URL"] as? String ?? "")! + + case .bottleAppLookUp: + return URL(string: Bundle.main.infoDictionary?["APP_LOOK_UP_URL"] as? String ?? "")! + + case .setting: + return URL(string: "App-prefs:root=General")! + } + } +} diff --git a/Projects/Core/URLHandler/Interface/Sources/URLHandler.swift b/Projects/Core/URLHandler/Interface/Sources/URLHandler.swift new file mode 100644 index 00000000..f02cbc96 --- /dev/null +++ b/Projects/Core/URLHandler/Interface/Sources/URLHandler.swift @@ -0,0 +1,19 @@ +// +// URLHandler.swift +// CoreURLHandlerInterface +// +// Created by JongHoon on 9/20/24. +// + +import UIKit + +public final class URLHandler { + + public static let shared = URLHandler() + + private init() { } + + public func openURL(urlType: BottleURLType) { + UIApplication.shared.open(urlType.url) + } +} diff --git a/Projects/Core/URLHandler/Project.swift b/Projects/Core/URLHandler/Project.swift new file mode 100644 index 00000000..12cd19c6 --- /dev/null +++ b/Projects/Core/URLHandler/Project.swift @@ -0,0 +1,40 @@ +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: ModulePath.Core.name+ModulePath.Core.URLHandler.rawValue, + targets: [ + .core( + interface: .URLHandler, + factory: .init() + ), + .core( + implements: .URLHandler, + factory: .init( + dependencies: [ + .core(interface: .URLHandler) + ] + ) + ), + + .core( + testing: .URLHandler, + factory: .init( + dependencies: [ + .core(interface: .URLHandler) + ] + ) + ), + .core( + tests: .URLHandler, + factory: .init( + dependencies: [ + .core(testing: .URLHandler), + .core(implements: .URLHandler) + ] + ) + ), + + ] +) diff --git a/Projects/Core/URLHandler/Sources/Source.swift b/Projects/Core/URLHandler/Sources/Source.swift new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Projects/Core/URLHandler/Sources/Source.swift @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Projects/Core/URLHandler/Testing/Sources/URLHandlerTesting.swift b/Projects/Core/URLHandler/Testing/Sources/URLHandlerTesting.swift new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Projects/Core/URLHandler/Testing/Sources/URLHandlerTesting.swift @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Projects/Core/URLHandler/Tests/Sources/URLHandlerTest.swift b/Projects/Core/URLHandler/Tests/Sources/URLHandlerTest.swift new file mode 100644 index 00000000..9c7ef9e7 --- /dev/null +++ b/Projects/Core/URLHandler/Tests/Sources/URLHandlerTest.swift @@ -0,0 +1,11 @@ +import XCTest + +final class URLHandlerTests: XCTestCase { + override func setUpWithError() throws {} + + override func tearDownWithError() throws {} + + func testExample() { + XCTAssertEqual(1, 1) + } +} diff --git a/Projects/Domain/Application/Interface/Sources/ApplicationClient.swift b/Projects/Domain/Application/Interface/Sources/ApplicationClient.swift new file mode 100644 index 00000000..05641128 --- /dev/null +++ b/Projects/Domain/Application/Interface/Sources/ApplicationClient.swift @@ -0,0 +1,36 @@ +// +// ApplicationClient.swift +// DomainApplicationInterface +// +// Created by JongHoon on 9/22/24. +// + +import Foundation + +public struct ApplicationClient { + private let _fetchCurrentAppVersion: () -> String + private let fetchLatestAppVersion: () async throws -> String + private let checkNeedApplicationUpdate: () async throws -> Bool + + public init( + fetchCurrentAppVersion: @escaping () -> String, + fetchLatestAppVersion: @escaping () async throws -> String, + checkNeedApplicationUpdate: @escaping () async throws -> Bool + ) { + self._fetchCurrentAppVersion = fetchCurrentAppVersion + self.fetchLatestAppVersion = fetchLatestAppVersion + self.checkNeedApplicationUpdate = checkNeedApplicationUpdate + } + + public func fetchCurrentAppVersion() -> String { + _fetchCurrentAppVersion() + } + + public func fetchLatestAppVersion() async throws -> String { + try await fetchLatestAppVersion() + } + + public func checkNeedApplicationUpdate() async throws -> Bool { + try await checkNeedApplicationUpdate() + } +} diff --git a/Projects/Domain/Application/Project.swift b/Projects/Domain/Application/Project.swift new file mode 100644 index 00000000..0b0d5d6e --- /dev/null +++ b/Projects/Domain/Application/Project.swift @@ -0,0 +1,44 @@ +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: ModulePath.Domain.name+ModulePath.Domain.Application.rawValue, + targets: [ + .domain( + interface: .Application, + factory: .init( + dependencies: [ + .core + ] + ) + ), + .domain( + implements: .Application, + factory: .init( + dependencies: [ + .domain(interface: .Application) + ] + ) + ), + + .domain( + testing: .Application, + factory: .init( + dependencies: [ + .domain(interface: .Application) + ] + ) + ), + .domain( + tests: .Application, + factory: .init( + dependencies: [ + .domain(testing: .Application), + .domain(implements: .Application) + ] + ) + ), + + ] +) diff --git a/Projects/Domain/Application/Sources/ApplicationClient.swift b/Projects/Domain/Application/Sources/ApplicationClient.swift new file mode 100644 index 00000000..a6e0054c --- /dev/null +++ b/Projects/Domain/Application/Sources/ApplicationClient.swift @@ -0,0 +1,66 @@ +// +// ApplicationClient.swift +// DomainApplicationInterface +// +// Created by JongHoon on 9/22/24. +// + +import Foundation + +import DomainApplicationInterface +import DomainErrorInterface + +import CoreURLHandlerInterface +import CoreURLHandler + +import Dependencies + +extension ApplicationClient: DependencyKey { + static public var liveValue: ApplicationClient = .live() + + static func live() -> ApplicationClient { + @Dependency(\.applicationClient) var applicationClient + return .init( + fetchCurrentAppVersion: { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String + return version + }, + fetchLatestAppVersion: { + let appLookUpURL = BottleURLType.bottleAppLookUp.url + let (data, _) = try await URLSession.shared.data(from: appLookUpURL) + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let results = json["results"] as? [[String: Any]], + let appStoreVersion = results.first?["version"] as? String { + return appStoreVersion + } else { + throw DomainError.unknown("fetch latest app version failed") + } + }, + checkNeedApplicationUpdate: { + let currentAppVersion = applicationClient.fetchCurrentAppVersion() + let latestAppVersion = try await applicationClient.fetchLatestAppVersion() + let currentAppVersionArray = currentAppVersion.split(separator: ".").compactMap { Int($0) } + let latestAppVersionArray = latestAppVersion.split(separator: ".").compactMap { Int($0) } + + let maxLength = max(currentAppVersionArray.count, latestAppVersionArray.count) + + for i in 0.. currentVersion { + return true + } + } + return false + } + ) + } +} + +extension DependencyValues { + public var applicationClient: ApplicationClient { + get { self[ApplicationClient.self] } + set { self[ApplicationClient.self] = newValue } + } +} diff --git a/Projects/Domain/Application/Testing/Sources/ApplicationTesting.swift b/Projects/Domain/Application/Testing/Sources/ApplicationTesting.swift new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Projects/Domain/Application/Testing/Sources/ApplicationTesting.swift @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Projects/Domain/Application/Tests/Sources/ApplicationTest.swift b/Projects/Domain/Application/Tests/Sources/ApplicationTest.swift new file mode 100644 index 00000000..028a2c6a --- /dev/null +++ b/Projects/Domain/Application/Tests/Sources/ApplicationTest.swift @@ -0,0 +1,11 @@ +import XCTest + +final class ApplicationTests: XCTestCase { + override func setUpWithError() throws {} + + override func tearDownWithError() throws {} + + func testExample() { + XCTAssertEqual(1, 1) + } +} diff --git a/Projects/Domain/Auth/Interface/Sources/AuthClient.swift b/Projects/Domain/Auth/Interface/Sources/AuthClient.swift index 81d65931..3f268530 100644 --- a/Projects/Domain/Auth/Interface/Sources/AuthClient.swift +++ b/Projects/Domain/Auth/Interface/Sources/AuthClient.swift @@ -93,7 +93,7 @@ public struct AuthClient { } public func checkUpdateVersion() async throws { - return try await checkUpdateVersion() + try await checkUpdateVersion() } } diff --git a/Projects/Domain/Auth/Project.swift b/Projects/Domain/Auth/Project.swift index ff0338f1..6d3339ba 100644 --- a/Projects/Domain/Auth/Project.swift +++ b/Projects/Domain/Auth/Project.swift @@ -18,7 +18,8 @@ let project = Project.makeModule( factory: .init( dependencies: [ .domain(interface: .Auth), - .domain(interface: .Error) + .domain(interface: .Error), + .domain(implements: .User) ] ) ), diff --git a/Projects/Domain/Auth/Sources/AuthClient.swift b/Projects/Domain/Auth/Sources/AuthClient.swift index 86c0ebf3..c6c69323 100644 --- a/Projects/Domain/Auth/Sources/AuthClient.swift +++ b/Projects/Domain/Auth/Sources/AuthClient.swift @@ -106,7 +106,7 @@ extension AuthClient: DependencyKey { guard minimumBuildNumber <= buildNumber else { - throw DomainError.AuthError.needUpdateAppVersion + throw DomainError.AuthError.invalidAppVersion } } ) diff --git a/Projects/Domain/Error/Interface/Sources/DomainError.swift b/Projects/Domain/Error/Interface/Sources/DomainError.swift index c3803c98..5a2111a0 100644 --- a/Projects/Domain/Error/Interface/Sources/DomainError.swift +++ b/Projects/Domain/Error/Interface/Sources/DomainError.swift @@ -9,7 +9,7 @@ import Foundation public enum DomainError: Error { public enum AuthError: Error { - case needUpdateAppVersion + case invalidAppVersion } case unknown(_ message: String? = nil) diff --git a/Projects/Domain/Profile/Interface/Sources/API/ProfileAPI.swift b/Projects/Domain/Profile/Interface/Sources/API/ProfileAPI.swift index 2e8d5367..be5c3f4c 100644 --- a/Projects/Domain/Profile/Interface/Sources/API/ProfileAPI.swift +++ b/Projects/Domain/Profile/Interface/Sources/API/ProfileAPI.swift @@ -17,6 +17,7 @@ public enum ProfileAPI { case checkIntroduction case uploadProfileImage(data: Data) case fetchUserProfileStatus + case updateMachingActivate(requestData: MatchingActivateRequestDTO) } extension ProfileAPI: BaseTargetType { @@ -32,6 +33,8 @@ extension ProfileAPI: BaseTargetType { return "api/v1/profile/images" case .fetchUserProfileStatus: return "api/v1/profile/status" + case .updateMachingActivate: + return "api/v1/profile/activate/matching" } } @@ -47,6 +50,8 @@ extension ProfileAPI: BaseTargetType { return .post case .fetchUserProfileStatus: return .get + case .updateMachingActivate: + return .post } } @@ -69,6 +74,8 @@ extension ProfileAPI: BaseTargetType { return .uploadMultipart([imageData]) case .fetchUserProfileStatus: return .requestPlain + case let .updateMachingActivate(requestData): + return .requestJSONEncodable(requestData) } } } diff --git a/Projects/Domain/Profile/Interface/Sources/DTO/Request/MatchingActivateRequestDTO.swift b/Projects/Domain/Profile/Interface/Sources/DTO/Request/MatchingActivateRequestDTO.swift new file mode 100644 index 00000000..b8e4be19 --- /dev/null +++ b/Projects/Domain/Profile/Interface/Sources/DTO/Request/MatchingActivateRequestDTO.swift @@ -0,0 +1,16 @@ +// +// MatchingActivateRequestDTO.swift +// DomainProfileInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation + +public struct MatchingActivateRequestDTO: Encodable { + private let activate: Bool + + public init(activate: Bool) { + self.activate = activate + } +} diff --git a/Projects/Domain/Profile/Interface/Sources/DTO/Response/ProfileResponseDTO.swift b/Projects/Domain/Profile/Interface/Sources/DTO/Response/ProfileResponseDTO.swift index 240ef5e0..c5a599f9 100644 --- a/Projects/Domain/Profile/Interface/Sources/DTO/Response/ProfileResponseDTO.swift +++ b/Projects/Domain/Profile/Interface/Sources/DTO/Response/ProfileResponseDTO.swift @@ -12,6 +12,8 @@ public struct ProfileResponseDTO: Decodable { public let userName: String? public let imageUrl: String? public let age: Int? + public let isMatchActivated: Bool? + public let blockedUserCount: Int? public let introduction: [IntroductionDTO]? public let profileSelect: ProfileSelectDTO? @@ -77,7 +79,10 @@ public struct ProfileResponseDTO: Decodable { userInfo: UserInfo( userAge: age ?? -1, userImageURL: imageUrl ?? "", - userName: userName ?? ""), + userName: userName ?? "", + isActiveMatching: isMatchActivated ?? false, + blockedContactsCount: blockedUserCount ?? 0 + ), introduction: Introduction(answer: introduction?.first?.answer ?? "", question: introduction?.first?.question ?? ""), profileSelect: profileSelect?.toDomain() ?? ProfileSelect( mbti: "", @@ -105,11 +110,15 @@ public struct UserInfo: Equatable { public let userAge: Int public let userImageURL: String public let userName: String - - public init(userAge: Int, userImageURL: String, userName: String) { + public let isActiveMatching: Bool + public let blockedContactsCount: Int + + public init(userAge: Int, userImageURL: String, userName: String, isActiveMatching: Bool = false, blockedContactsCount: Int = 0) { self.userAge = userAge self.userImageURL = userImageURL self.userName = userName + self.isActiveMatching = isActiveMatching + self.blockedContactsCount = blockedContactsCount } } diff --git a/Projects/Domain/Profile/Interface/Sources/ProfileClient.swift b/Projects/Domain/Profile/Interface/Sources/ProfileClient.swift index 3dcfe4ed..44571bb8 100644 --- a/Projects/Domain/Profile/Interface/Sources/ProfileClient.swift +++ b/Projects/Domain/Profile/Interface/Sources/ProfileClient.swift @@ -15,14 +15,15 @@ public struct ProfileClient { private var uploadProfileImage: (Data) async throws -> Void private var fetchUserProfile: () async throws -> UserProfile private var fetchUserProfileSelect: () async throws -> UserProfileStatus - + private var updateMatchingActivate: (Bool) async throws -> Void public init( checkExistIntroduction: @escaping () async throws -> Bool, registerIntroduction: @escaping (String) async throws -> Void, fetchProfileSelect: @escaping () async throws -> ProfileSelect, uploadProfileImage: @escaping (Data) async throws -> Void, fetchUserProfile: @escaping () async throws -> UserProfile, - fetchUserProfileSelect: @escaping () async throws -> UserProfileStatus + fetchUserProfileSelect: @escaping () async throws -> UserProfileStatus, + updateMatchingActivate: @escaping (Bool) async throws -> Void ) { self.checkExistIntroduction = checkExistIntroduction self.registerIntroduction = registerIntroduction @@ -30,6 +31,7 @@ public struct ProfileClient { self.uploadProfileImage = uploadProfileImage self.fetchUserProfile = fetchUserProfile self.fetchUserProfileSelect = fetchUserProfileSelect + self.updateMatchingActivate = updateMatchingActivate } public func checkExistIntroduction() async throws -> Bool { @@ -55,5 +57,9 @@ public struct ProfileClient { public func fetchUserProfileSelect() async throws -> UserProfileStatus { try await fetchUserProfileSelect() } + + public func updateMatcingActivate(isActive: Bool) async throws { + try await updateMatchingActivate(isActive) + } } diff --git a/Projects/Domain/Profile/Sources/ProfileClient.swift b/Projects/Domain/Profile/Sources/ProfileClient.swift index d6265d3f..d0c36466 100644 --- a/Projects/Domain/Profile/Sources/ProfileClient.swift +++ b/Projects/Domain/Profile/Sources/ProfileClient.swift @@ -46,6 +46,10 @@ extension ProfileClient: DependencyKey { let responseData = try await networkManager.reqeust(api: .apiType(ProfileAPI.fetchUserProfileStatus), dto: ProfileStatusResponseDTO.self) let userStatus = responseData.toDomain() return userStatus + }, + updateMatchingActivate: { isActive in + let requestData = MatchingActivateRequestDTO(activate: isActive) + try await networkManager.reqeust(api: .apiType(ProfileAPI.updateMachingActivate(requestData: requestData))) } ) } diff --git a/Projects/Domain/User/Interface/Sources/API/UserAPI.swift b/Projects/Domain/User/Interface/Sources/API/UserAPI.swift new file mode 100644 index 00000000..59a2206d --- /dev/null +++ b/Projects/Domain/User/Interface/Sources/API/UserAPI.swift @@ -0,0 +1,53 @@ +// +// UserAPI.swift +// DomainUserInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation + +import CoreNetworkInterface + +import Moya + +public enum UserAPI { + case fetchAlertState + case updateAlertState(reqeustData: AlertStateRequestDTO) + case updateBlockContacts(blockContactRequestDTO: BlockContactRequestDTO) +} + +extension UserAPI: BaseTargetType { + public var path: String { + switch self { + case .fetchAlertState: + return "api/v1/user/alimy" + case .updateAlertState: + return "api/v1/user/alimy" + case .updateBlockContacts: + return "api/v1/user/block/contact-list" + } + } + + public var method: Moya.Method { + switch self { + case .fetchAlertState: + return .get + case .updateAlertState: + return .post + case .updateBlockContacts: + return .post + } + } + + public var task: Moya.Task { + switch self { + case .fetchAlertState: + return .requestPlain + case .updateAlertState(let requestData): + return .requestJSONEncodable(requestData) + case let .updateBlockContacts(blockContactRequestDTO): + return .requestJSONEncodable(blockContactRequestDTO) + } + } +} diff --git a/Projects/Domain/User/Interface/Sources/DTO/Request/AlertStateRequestDTO.swift b/Projects/Domain/User/Interface/Sources/DTO/Request/AlertStateRequestDTO.swift new file mode 100644 index 00000000..0d6bc6ee --- /dev/null +++ b/Projects/Domain/User/Interface/Sources/DTO/Request/AlertStateRequestDTO.swift @@ -0,0 +1,18 @@ +// +// AlertStateRequestDTO.swift +// DomainUserInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation + +public struct AlertStateRequestDTO: Encodable { + public let alimyType: AlertType.RawValue + public let enabled: Bool + + public init(alertType: AlertType, enabled: Bool) { + self.alimyType = alertType.rawValue + self.enabled = enabled + } +} diff --git a/Projects/Domain/User/Interface/Sources/DTO/Request/BlockContactRequestDTO.swift b/Projects/Domain/User/Interface/Sources/DTO/Request/BlockContactRequestDTO.swift new file mode 100644 index 00000000..3a3a2a1f --- /dev/null +++ b/Projects/Domain/User/Interface/Sources/DTO/Request/BlockContactRequestDTO.swift @@ -0,0 +1,14 @@ +// +// BlockContactRequestDTO.swift +// DomainUserInterface +// +// Created by JongHoon on 9/22/24. +// + +public struct BlockContactRequestDTO: Encodable { + private let blockContacts: [String] + + public init(blockContacts: [String]) { + self.blockContacts = blockContacts + } +} diff --git a/Projects/Domain/User/Interface/Sources/DTO/Response/AlertStateReponseDTO.swift b/Projects/Domain/User/Interface/Sources/DTO/Response/AlertStateReponseDTO.swift new file mode 100644 index 00000000..d3f66baa --- /dev/null +++ b/Projects/Domain/User/Interface/Sources/DTO/Response/AlertStateReponseDTO.swift @@ -0,0 +1,20 @@ +// +// AlertStateReponseDTO.swift +// DomainUserInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation + +public struct AlertStateResponseDTO: Decodable { + let alimyType: String + let enabled: Bool + + public func toDomain() -> UserAlertState { + return .init( + alertType: AlertType(rawValue: alimyType) ?? .none, + enabled: enabled + ) + } +} diff --git a/Projects/Domain/User/Interface/Sources/Entity/AlertState.swift b/Projects/Domain/User/Interface/Sources/Entity/AlertState.swift new file mode 100644 index 00000000..d05ead1c --- /dev/null +++ b/Projects/Domain/User/Interface/Sources/Entity/AlertState.swift @@ -0,0 +1,21 @@ +// +// AlertState.swift +// DomainUserInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation + +public struct UserAlertState { + public let alertType: AlertType + public let enabled: Bool + + public init( + alertType: AlertType, + enabled: Bool + ) { + self.alertType = alertType + self.enabled = enabled + } +} diff --git a/Projects/Domain/User/Interface/Sources/Entity/AlertType.swift b/Projects/Domain/User/Interface/Sources/Entity/AlertType.swift new file mode 100644 index 00000000..2c0b956d --- /dev/null +++ b/Projects/Domain/User/Interface/Sources/Entity/AlertType.swift @@ -0,0 +1,16 @@ +// +// AlertType.swift +// DomainUserInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation + +public enum AlertType: String, Codable { + case none = "NONE" + case randomBottle = "DAILY_RANDOM" + case arrivalBottle = "RECEIVE_LIKE" + case pingpong = "PINGPONG" + case marketing = "MARKETING" +} diff --git a/Projects/Domain/User/Interface/Sources/UserClient.swift b/Projects/Domain/User/Interface/Sources/UserClient.swift index dadc0abd..34fd1927 100644 --- a/Projects/Domain/User/Interface/Sources/UserClient.swift +++ b/Projects/Domain/User/Interface/Sources/UserClient.swift @@ -7,6 +7,8 @@ import Foundation +import Combine + public struct UserClient { private let _isLoggedIn: () -> Bool private let _isAppDeleted: () -> Bool @@ -14,6 +16,17 @@ public struct UserClient { private let updateLoginState: (Bool) -> Void private let updateDeleteState: (Bool) -> Void private let updateFcmToken: (String) -> Void + private let updatePushNotificationAllowStatus: (Bool) -> Void + private let _fetchAlertState: () async throws -> [UserAlertState] + private let _fetchPushNotificationAllowStatus: () -> Bool + private let updateAlertState: (UserAlertState) async throws -> Void + private let fetchContacts: () async throws -> [String] + private let updateBlockContacts: ([String]) async throws -> Void + private let pushNotificationAllowStatusSubject = CurrentValueSubject(true) + + public var pushNotificationAllowStatusPublisher: AnyPublisher { + return pushNotificationAllowStatusSubject.eraseToAnyPublisher() + } public init( isLoggedIn: @escaping () -> Bool, @@ -21,7 +34,13 @@ public struct UserClient { fetchFcmToken: @escaping () -> String?, updateLoginState: @escaping (Bool) -> Void, updateDeleteState: @escaping (Bool) -> Void, - updateFcmToken: @escaping (String) -> Void + updateFcmToken: @escaping (String) -> Void, + updatePushNotificationAllowStatus: @escaping (Bool) -> Void, + fetchAlertState: @escaping () async throws -> [UserAlertState], + fetchPushNotificationAllowStatus: @escaping () -> Bool, + updateAlertState: @escaping (UserAlertState) async throws -> Void, + fetchContacts: @escaping () async throws -> [String], + updateBlockContacts: @escaping ([String]) async throws -> Void ) { self._isLoggedIn = isLoggedIn self._isAppDeleted = isAppDeleted @@ -29,6 +48,12 @@ public struct UserClient { self.updateLoginState = updateLoginState self.updateDeleteState = updateDeleteState self.updateFcmToken = updateFcmToken + self.updatePushNotificationAllowStatus = updatePushNotificationAllowStatus + self._fetchAlertState = fetchAlertState + self._fetchPushNotificationAllowStatus = fetchPushNotificationAllowStatus + self.updateAlertState = updateAlertState + self.fetchContacts = fetchContacts + self.updateBlockContacts = updateBlockContacts } public func isLoggedIn() -> Bool { @@ -54,4 +79,29 @@ public struct UserClient { public func updateFcmToken(fcmToken: String) { updateFcmToken(fcmToken) } + + public func updatePushNotificationAllowStatus(isAllow: Bool) { + pushNotificationAllowStatusSubject.send(isAllow) + updatePushNotificationAllowStatus(isAllow) + } + + public func fetchAlertState() async throws -> [UserAlertState] { + try await _fetchAlertState() + } + + public func fetchPushNotificationAllowStatus() -> Bool { + _fetchPushNotificationAllowStatus() + } + + public func updateAlertState(alertState: UserAlertState) async throws { + try await updateAlertState(alertState) + } + + public func fetchContacts() async throws -> [String] { + try await fetchContacts() + } + + public func updateBlockContacts(contacts: [String]) async throws { + try await updateBlockContacts(contacts) + } } diff --git a/Projects/Domain/User/Interface/Sources/UserError.swift b/Projects/Domain/User/Interface/Sources/UserError.swift new file mode 100644 index 00000000..789f2d7f --- /dev/null +++ b/Projects/Domain/User/Interface/Sources/UserError.swift @@ -0,0 +1,13 @@ +// +// UserError.swift +// DomainUserInterface +// +// Created by JongHoon on 9/22/24. +// + +import Foundation + +public enum UserError: Error { + case requestContactsAccessAuthorityFailed + case contactsAccessDenied +} diff --git a/Projects/Domain/User/Sources/UserClient.swift b/Projects/Domain/User/Sources/UserClient.swift index 6df5fad8..c46472f1 100644 --- a/Projects/Domain/User/Sources/UserClient.swift +++ b/Projects/Domain/User/Sources/UserClient.swift @@ -6,40 +6,105 @@ // import Foundation +import Contacts import DomainUserInterface import CoreKeyChainStore +import CoreNetwork import ComposableArchitecture +import Moya extension UserClient: DependencyKey { + private enum UserDefaultsKeys: String { + case loginState + case deleteState + case fcmToken + case alertAllowState + } + static public var liveValue: UserClient = .live() static func live() -> UserClient { + @Dependency(\.network) var networkManager + return .init( isLoggedIn: { - return UserDefaults.standard.bool(forKey: "loginState") + return UserDefaults.standard.bool(forKey: UserDefaultsKeys.loginState.rawValue) }, isAppDeleted: { - return !UserDefaults.standard.bool(forKey: "deleteState") + return !UserDefaults.standard.bool(forKey: UserDefaultsKeys.deleteState.rawValue) }, fetchFcmToken: { - return UserDefaults.standard.string(forKey: "fcmToken") + return UserDefaults.standard.string(forKey: UserDefaultsKeys.fcmToken.rawValue) }, updateLoginState: { isLoggedIn in - UserDefaults.standard.set(isLoggedIn, forKey: "loginState") + UserDefaults.standard.set(isLoggedIn, forKey: UserDefaultsKeys.loginState.rawValue) }, updateDeleteState: { isDelete in - UserDefaults.standard.set(!isDelete, forKey: "deleteState") + UserDefaults.standard.set(!isDelete, forKey: UserDefaultsKeys.deleteState.rawValue) }, updateFcmToken: { fcmToken in - UserDefaults.standard.set(fcmToken, forKey: "fcmToken") + UserDefaults.standard.set(fcmToken, forKey: UserDefaultsKeys.fcmToken.rawValue) + }, + + updatePushNotificationAllowStatus: { isAllow in + UserDefaults.standard.set(isAllow, forKey: UserDefaultsKeys.alertAllowState.rawValue) + }, + + fetchAlertState: { + let responseData = try await networkManager.reqeust(api: .apiType(UserAPI.fetchAlertState), dto: [AlertStateResponseDTO].self) + return responseData.map { $0.toDomain() } + }, + + fetchPushNotificationAllowStatus: { + return UserDefaults.standard.bool(forKey: UserDefaultsKeys.alertAllowState.rawValue) + }, + + updateAlertState: { alertState in + let requestData = AlertStateRequestDTO(alertType: alertState.alertType, enabled: alertState.enabled) + try await networkManager.reqeust(api: .apiType(UserAPI.updateAlertState(reqeustData: requestData))) + }, + fetchContacts: { + let store = CNContactStore() + var contacts: [String] = [] + let keys = [CNContactPhoneNumbersKey] as [CNKeyDescriptor] + + let request = CNContactFetchRequest(keysToFetch: keys) + request.sortOrder = CNContactSortOrder.userDefault + + let authorizationStatus = CNContactStore.authorizationStatus(for: .contacts) + guard authorizationStatus == .authorized || + authorizationStatus == .notDetermined + else { + throw UserError.contactsAccessDenied + } + + let granted = try await store.requestAccess(for: .contacts) + guard granted + else { + throw UserError.requestContactsAccessAuthorityFailed + } + + try store.enumerateContacts(with: request) { contact, _ in + contacts += contact.phoneNumbers + .map { $0.value.stringValue } + .map { $0.replacingOccurrences(of: "+82", with: "0") } + .map { $0.trimmingCharacters(in: .whitespaces) } + .map { $0.filter { $0.isNumber } } + } + + return contacts + }, + updateBlockContacts: { contacts in + let blockContactRequestDTO = BlockContactRequestDTO(blockContacts: contacts) + try await networkManager.reqeust(api: .apiType(UserAPI.updateBlockContacts(blockContactRequestDTO: blockContactRequestDTO))) } ) } diff --git a/Projects/Feature/BaseWebView/Interface/Sources/BaseWebView.swift b/Projects/Feature/BaseWebView/Interface/Sources/BaseWebView.swift index 16543ec7..6cbfbcb6 100644 --- a/Projects/Feature/BaseWebView/Interface/Sources/BaseWebView.swift +++ b/Projects/Feature/BaseWebView/Interface/Sources/BaseWebView.swift @@ -10,6 +10,7 @@ import WebKit import CoreLoggerInterface import CoreWebViewInterface + import DomainWebView import ComposableArchitecture diff --git a/Projects/Feature/BaseWebView/Interface/Sources/BaseWebViewType.swift b/Projects/Feature/BaseWebView/Interface/Sources/BaseWebViewType.swift index 376ba1d4..11d53307 100644 --- a/Projects/Feature/BaseWebView/Interface/Sources/BaseWebViewType.swift +++ b/Projects/Feature/BaseWebView/Interface/Sources/BaseWebViewType.swift @@ -7,37 +7,57 @@ import Foundation +import DomainApplicationInterface +import DomainApplication + import CoreWebViewInterface import CoreKeyChainStoreInterface import CoreKeyChainStore -public enum BottleWebViewType: String { +import Dependencies + +public enum BottleWebViewType { private var baseURL: String { (Bundle.main.infoDictionary?["WEB_VIEW_BASE_URL"] as? String) ?? "" } - case createProfile = "create-profile" - case myPage = "my" - case signUp = "signup" + case createProfile + case signUp case login case bottles + case editProfile + + var path: String { + switch self { + case .createProfile: + return "create-profile" + case .signUp: + return "signup" + case .login: + return "login" + case .bottles: + return "bottles" + case .editProfile: + return "profile/edit" + } + } public var url: URL { switch self { case .createProfile: - return makeUrlWithToken(rawValue) - - case .myPage: - return makeUrlWithToken(rawValue) + return makeUrlWithToken(path) case .signUp: - return URL(string: baseURL + "/" + rawValue)! + return URL(string: baseURL + "/" + path)! case .login: - return URL(string: baseURL + "/" + rawValue)! + return URL(string: baseURL + "/" + path)! case .bottles: - return makeUrlWithToken(rawValue) + return makeUrlWithToken(path) + + case .editProfile: + return makeUrlWithToken(path) } } @@ -52,11 +72,15 @@ public enum BottleWebViewType: String { // MARK: - private methods private extension BottleWebViewType { func makeUrlWithToken(_ path: String) -> URL { + @Dependency(\.applicationClient) var applicationClient + var components = URLComponents(string: baseURL) components?.path = "/\(path)" components?.queryItems = [ URLQueryItem(name: "accessToken", value: KeyChainTokenStore.shared.load(property: .accessToken)), - URLQueryItem(name: "refreshToken", value: KeyChainTokenStore.shared.load(property: .refreshToken)) + URLQueryItem(name: "refreshToken", value: KeyChainTokenStore.shared.load(property: .refreshToken)), + URLQueryItem(name: "device", value: "ios"), + URLQueryItem(name: "version", value: applicationClient.fetchCurrentAppVersion()) ] return (components?.url)! diff --git a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalView.swift b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalView.swift index 18e02dc9..1b002cf9 100644 --- a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalView.swift +++ b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalView.swift @@ -43,7 +43,7 @@ public struct BottleArrivalView: View { LoadingIndicator() } } - .ignoresSafeArea(.all, edges: .bottom) + .ignoresSafeArea(.all, edges: [.top, .bottom]) .toolbar(.hidden, for: .navigationBar) .toolbar(.hidden, for: .bottomBar) } diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeature.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeature.swift index b24ca8dd..9ee14df5 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeature.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeature.swift @@ -7,10 +7,11 @@ import Foundation -import CoreLoggerInterface import FeatureReportInterface import DomainBottle +import CoreLoggerInterface + import ComposableArchitecture extension PingPongDetailFeature { @@ -42,12 +43,46 @@ extension PingPongDetailFeature { imageURL: imageURL ?? "", userID: userId ?? -1, userName: userName, userAge: userAge ?? -1) return .send(.delegate(.reportButtonDidTapped(userReportProfile))) + case .stopTalkAlertDidRequired: + state.destination = .alert(.init( + title: { TextState("중단하기") }, + actions: { + ButtonState( + role: .cancel, + action: .dismiss, + label: { TextState("계속하기")}) + + ButtonState( + role: .destructive, + action: .confirmStopTalk, + label: { TextState("중단하기") }) + }, + message: { TextState("중단 시 모든 핑퐁 내용이 사라져요. 정말 중단하시겠어요?") } + )) + return .none + + // Destination + case let .destination(.presented(.alert(alert))): + switch alert { + case .confirmStopTalk: + return .run { [bottleID = state.bottleID] send in + try await bottleClient.stopTalk(bottleID: bottleID) + await send(.delegate(.popToRootDidRequired)) + } + + case .dismiss: + state.destination = nil + return .none + } + + // Introduction Delegate case let .introduction(.delegate(delegate)): switch delegate { - case .popToRootDidRequired: - return .send(.delegate(.popToRootDidRequired)) + case .stopTaskButtonTapped: + return .send(.stopTalkAlertDidRequired) } - + + // QuestionAndAnswer Delegate case let .questionAndAnswer(.delegate(delegate)): switch delegate { case .reloadPingPongRequired: @@ -56,8 +91,11 @@ extension PingPongDetailFeature { return .send(.delegate(.popToRootDidRequired)) case .refreshPingPong: return fetchPingPong(state: &state) + case .stopTaskButtonDidTapped: + return .send(.stopTalkAlertDidRequired) } - + + // Matching Delegate case let .matching(.delegate(delegate)): switch delegate { case .otherBottleButtonDidTapped: diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeatureInterface.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeatureInterface.swift index 45dfb790..5938b3e1 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeatureInterface.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailFeatureInterface.swift @@ -43,6 +43,8 @@ public struct PingPongDetailFeature { var matching: MatchingFeature.State var selectedTab: PingPongDetailViewTabType + @Presents var destination: Destination.State? + public init( bottleID: Int, isRead: Bool, @@ -67,7 +69,7 @@ public struct PingPongDetailFeature { case pingPongDidFetched(_: BottlePingPong) case backButtonDidTapped case reportButtonDidTapped - + case stopTalkAlertDidRequired // Delegate case delegate(Delegate) @@ -83,6 +85,13 @@ public struct PingPongDetailFeature { case questionAndAnswer(QuestionAndAnswerFeature.Action) case matching(MatchingFeature.Action) case binding(BindingAction) + case destination(PresentationAction) + // Alert + case alert(Alert) + public enum Alert: Equatable { + case confirmStopTalk + case dismiss + } } public var body: some ReducerOf { @@ -97,6 +106,15 @@ public struct PingPongDetailFeature { MatchingFeature() } reducer + .ifLet(\.$destination, action: \.destination) } } +// MARK: - Destination + +extension PingPongDetailFeature { + @Reducer(state: .equatable) + public enum Destination { + case alert(AlertState) + } +} diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailView.swift index 55cce1f8..9da0026a 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailView.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PingPongDetail/PingPongDetailView.swift @@ -55,6 +55,7 @@ public struct PingPongDetailView: View { } ) .ignoresSafeArea(.all, edges: .bottom) + .bottleAlert($store.scope(state: \.destination?.alert, action: \.destination.alert)) } } diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeature.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeature.swift index 3a88ad0d..1ba3a024 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeature.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeature.swift @@ -33,26 +33,7 @@ extension IntroductionFeature { return .none case .stopTaskButtonTapped: - state.destination = .alert(.init( - title: { TextState("중단하기") }, - actions: { - ButtonState( - role: .destructive, - action: .confirmStopTalk, - label: { TextState("중단하기") }) - }, - message: { TextState("중단 시 모든 핑퐁 내용이 사라져요. 정말 중단하시겠어요?") } - )) - return .none - - case let .destination(.presented(.alert(alert))): - switch alert { - case .confirmStopTalk: - return .run { [bottleID = state.bottleID] send in - try await bottleClient.stopTalk(bottleID: bottleID) - await send(.delegate(.popToRootDidRequired)) - } - } + return .send(.delegate(.stopTaskButtonTapped)) case .refreshPingPongDidRequired: return .run { [bottleID = state.bottleID] send in @@ -62,7 +43,7 @@ extension IntroductionFeature { await send(.introductionFetched(pingPong.introduction ?? [])) } - case .binding, .alert: + case .binding: return .none default: diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeatureInterface.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeatureInterface.swift index 52be5fb3..17e4d8df 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeatureInterface.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionFeatureInterface.swift @@ -68,9 +68,7 @@ public struct IntroductionFeature { + (interest?.etc ?? []) + (interest?.sports ?? []) } - - @Presents var destination: Destination.State? - + public init (bottleID: Int) { self.bottleID = bottleID } @@ -88,32 +86,14 @@ public struct IntroductionFeature { // ETC. case binding(BindingAction) - case destination(PresentationAction) - - case alert(Alert) - public enum Alert: Equatable { - case confirmStopTalk - } - case delegate(Delegate) public enum Delegate { - case popToRootDidRequired + case stopTaskButtonTapped } } public var body: some ReducerOf { reducer - .ifLet(\.$destination, action: \.destination) } } - -// MARK: - Destination - -extension IntroductionFeature { - @Reducer(state: .equatable) - public enum Destination { - case alert(AlertState) - } -} - diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionView.swift index 16d1f144..9744ee5a 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionView.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Introduction/IntroductionView.swift @@ -81,9 +81,7 @@ public struct IntroductionView: View { .padding(.top, 32.0) } .scrollIndicators(.hidden) - .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) .background(to: ColorToken.background(.primary)) - } } } diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeature.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeature.swift index 5a4dc213..a03ed829 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeature.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeature.swift @@ -83,30 +83,10 @@ extension QuestionAndAnswerFeature { } case .stopTalkButtonDidTapped: - state.destination = .alert(.init( - title: { TextState("중단하기") }, - actions: { - ButtonState( - role: .destructive, - action: .confirmStopTalk, - label: { TextState("중단하기") }) - }, - message: { TextState("중단 시 모든 핑퐁 내용이 사라져요. 정말 중단하시겠어요?") } - )) - return .none + return .send(.delegate(.stopTaskButtonDidTapped)) case .refreshDidPulled: return .send(.delegate(.refreshPingPong)) - - case let .destination(.presented(.alert(alert))): - switch alert { - case .confirmStopTalk: - state.isShowLoadingIndicator = true - return .run { [bottleID = state.bottleID] send in - try await bottleClient.stopTalk(bottleID: bottleID) - await send(.delegate(.popToRootDidRequired)) - } - } case .binding(\.firstLetterTextFieldContent): if state.firstLetterTextFieldContent.count >= 50 { @@ -132,7 +112,7 @@ extension QuestionAndAnswerFeature { } return .none - case .binding, .destination, .alert: + case .binding: return .none default: diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeatureInterface.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeatureInterface.swift index f4144743..770ba42a 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeatureInterface.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerFeatureInterface.swift @@ -114,9 +114,7 @@ public struct QuestionAndAnswerFeature { } var finalSelectIsSelctedYesButton: Bool var finalSelectIsSelctedNoButton: Bool - - @Presents var destination: Destination.State? - + public init(bottleID: Int) { self.bottleID = bottleID self.isShowLoadingIndicator = false @@ -181,12 +179,6 @@ public struct QuestionAndAnswerFeature { // ETC. case binding(BindingAction) - case destination(PresentationAction) - - case alert(Alert) - public enum Alert: Equatable { - case confirmStopTalk - } case delegate(Delegate) @@ -194,21 +186,12 @@ public struct QuestionAndAnswerFeature { case reloadPingPongRequired case popToRootDidRequired case refreshPingPong + case stopTaskButtonDidTapped } } public var body: some ReducerOf { BindingReducer() reducer - .ifLet(\.$destination, action: \.destination) - } -} - -// MARK: - Destination - -extension QuestionAndAnswerFeature { - @Reducer(state: .equatable) - public enum Destination { - case alert(AlertState) } } diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerView.swift index 22c45bfe..24270272 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerView.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/QuestionAndAnswer/QuestionAndAnswerView.swift @@ -134,7 +134,6 @@ public struct QuestionAndAnswerView: View { LoadingIndicator() } } - .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) .background(to: ColorToken.background(.primary)) .toolbar(.hidden, for: .bottomBar) diff --git a/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpView.swift b/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpView.swift index 6cb6f38c..a396f737 100644 --- a/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpView.swift +++ b/Projects/Feature/GeneralSignUp/Interface/Sources/GeneralSignUpView.swift @@ -54,7 +54,7 @@ public struct GeneralSignUpView: View { } } .toolbar(.hidden, for: .navigationBar) - .ignoresSafeArea(.all, edges: .bottom) + .ignoresSafeArea(.all, edges: [.top, .bottom]) .sheet(isPresented: $store.isPresentTerms) { TermsWebView(url: store.termsURL ?? "") } diff --git a/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLogInView.swift b/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLogInView.swift index 7b50c6ac..04af2723 100644 --- a/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLogInView.swift +++ b/Projects/Feature/Login/Interface/Sources/GeneralLogIn/GeneralLogInView.swift @@ -51,7 +51,7 @@ public struct GeneralLogInView: View { LoadingIndicator() } } - .ignoresSafeArea(.all, edges: .bottom) + .ignoresSafeArea(.all, edges: [.top, .bottom]) .toolbar(.hidden, for: .navigationBar) } } diff --git a/Projects/Feature/Login/Interface/Sources/Login/AppleLoginView.swift b/Projects/Feature/Login/Interface/Sources/Login/AppleLoginView.swift index e1ace40d..d79754b4 100644 --- a/Projects/Feature/Login/Interface/Sources/Login/AppleLoginView.swift +++ b/Projects/Feature/Login/Interface/Sources/Login/AppleLoginView.swift @@ -19,31 +19,33 @@ public struct AppleLoginView: View { } public var body: some View { - VStack(spacing: 0) { - Spacer() - .frame(height: 52) - whiteLogo - .padding(.top, 52) - .padding(.bottom, .xl) - mainText + ZStack(alignment: .bottom) { + VStack(spacing: 0) { + Spacer() + .frame(height: 52) + whiteLogo + .padding(.top, 52) + .padding(.bottom, .xl) + mainText - Spacer() + Spacer() + } + .frame(maxWidth: .infinity) + .background { + BottleImageView( + type: .local(bottleImageSystem: .illustraition(.loginBackground)) + ) + } + .edgesIgnoringSafeArea([.top, .bottom]) signInWithAppleButton - .padding(.bottom, 30.0) - - } - .background { - BottleImageView( - type: .local(bottleImageSystem: .illustraition(.loginBackground)) - ) + .padding(.bottom, 16.0) } .setNavigationBar { makeNaivgationleftButton() { store.send(.backButtonDidTapped) } } - .edgesIgnoringSafeArea([.top, .bottom]) } } diff --git a/Projects/Feature/Login/Interface/Sources/Login/LoginView.swift b/Projects/Feature/Login/Interface/Sources/Login/LoginView.swift index 3b585f70..56d322f6 100644 --- a/Projects/Feature/Login/Interface/Sources/Login/LoginView.swift +++ b/Projects/Feature/Login/Interface/Sources/Login/LoginView.swift @@ -28,29 +28,33 @@ public struct LoginView: View { public var body: some View { WithPerceptionTracking { NavigationStack(path: $store.scope(state: \.path, action: \.path)) { - VStack(spacing: 0) { - Spacer() - .frame(height: 52) - whiteLogo - .padding(.top, 52) - .padding(.bottom, .xl) - - mainText - - Spacer() + ZStack(alignment: .bottom) { + VStack(spacing: 0) { + Spacer() + .frame(height: 52) + whiteLogo + .padding(.top, 52) + .padding(.bottom, .xl) + + mainText + + Spacer() + } + .frame(maxWidth: .infinity) + .background { + BottleImageView( + type: .local(bottleImageSystem: .illustraition(.loginBackground)) + ) + .scaledToFill() + } + .edgesIgnoringSafeArea([.top, .bottom]) VStack(spacing: 30.0) { signInWithKakaoButton snsLoginButton } - .padding(.bottom, 30.0) - } - .background { - BottleImageView( - type: .local(bottleImageSystem: .illustraition(.loginBackground)) - ) + .padding(.bottom, 16.0) } - .edgesIgnoringSafeArea([.top, .bottom]) .sheet( isPresented: $store.isPresentTermView, content: { diff --git a/Projects/Feature/MyPage/Example/Sources/AppView.swift b/Projects/Feature/MyPage/Example/Sources/AppView.swift index cc918e49..698e8164 100644 --- a/Projects/Feature/MyPage/Example/Sources/AppView.swift +++ b/Projects/Feature/MyPage/Example/Sources/AppView.swift @@ -9,9 +9,9 @@ import ComposableArchitecture struct AppView: App { var body: some Scene { WindowGroup { - MyPageView(store: Store( - initialState: MyPageFeature.State(), - reducer: { MyPageFeature() } + MyPageRootView(store: Store( + initialState: MyPageRootFeature.State(), + reducer: { MyPageRootFeature() } )) } } diff --git a/Projects/Feature/MyPage/Interface/Sources/AccountSetting/AccountSettingFeature.swift b/Projects/Feature/MyPage/Interface/Sources/AccountSetting/AccountSettingFeature.swift new file mode 100644 index 00000000..bc4aed2e --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/AccountSetting/AccountSettingFeature.swift @@ -0,0 +1,113 @@ +// +// AccountSettingFeature.swift +// FeatureMyPageInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation + +import DomainAuth +import DomainProfile +import DomainUserInterface + +import CoreKeyChainStore + +import ComposableArchitecture + +extension AccountSettingFeature { + public init() { + @Dependency(\.profileClient) var profileClient + @Dependency(\.authClient) var authClient + @Dependency(\.dismiss) var dismiss + + let reducer = Reduce { state, action in + switch action { + case .onLoad: + return .run { send in + let profile = try await profileClient.fetchUserProfile() + await send(.matchingToggleDidFetched(isOn: profile.userInfo.isActiveMatching)) + } + case let .matchingToggleDidFetched(isOn): + state.isOnMatchingToggle = isOn + return .none + + case .backButtonDidTapped: + return .run { _ in + await dismiss() + } + + case .logoutButtonDidTapped: + state.destination = .alert(.init( + title: { TextState("로그아웃") }, + actions: { + ButtonState(role: .cancel, action: .confirmLogOut, label: { TextState("로그아웃하기") }) + ButtonState(role: .destructive, action: .dismiss, label: { TextState("취소하기") }) + }, + message: { TextState("정말 로그아웃 하시겠어요?") } + )) + return .none + + case .withdrawalButtonDidTapped: + state.destination = .alert(.init( + title: { TextState("탈퇴하기") }, + actions: { + ButtonState(role: .cancel, action: .confirmWithdrawal, label: { TextState("탈퇴하기") }) + ButtonState(role: .destructive, action: .dismiss, label: { TextState("계속 이용하기") }) + }, + message: { TextState("탈퇴 시 48시간 동안 재가입이 불가능하며 계정 복구가 어려워요.\n정말 탈퇴하시겠어요?") } + )) + return .none + + case .binding(\.isOnMatchingToggle): + return .run { [isOn = state.isOnMatchingToggle] send in + await send(.matchingToggleDidChanged(isOn: isOn)) + } + .debounce( + id: ID.matcingToggle, + for: 0.5, + scheduler: DispatchQueue.main) + + case let .matchingToggleDidChanged(isOn): + return .run { send in + try await profileClient.updateMatcingActivate(isActive: isOn) + } + + case let .destination(.presented(.alert(alert))): + switch alert { + case .confirmLogOut: + return .run { send in + try await authClient.logout() + await send(.logoutDidCompleted) + } + + case .confirmWithdrawal: + return .run { send in + await send(.delegate(.withdrawalButtonDidTapped)) + try await authClient.withdraw() + if !KeyChainTokenStore.shared.load(property: .AppleUserID).isEmpty { + // clientSceret 받아오기 + let clientSceret = try await authClient.fetchAppleClientSecret() + KeyChainTokenStore.shared.save(property: .AppleClientSecret, value: clientSceret) + try await authClient.revokeAppleLogin() + } + await send(.withdrawalDidCompleted) + } + + case .dismiss: + return .none + } + + case .logoutDidCompleted: + return .send(.delegate(.logoutDidCompleted)) + + case .withdrawalDidCompleted: + return .send(.delegate(.withdrawalDidCompleted)) + + default: + return .none + } + } + self.init(reducer: reducer) + } +} diff --git a/Projects/Feature/MyPage/Interface/Sources/AccountSetting/AccountSettingFeatureInterface.swift b/Projects/Feature/MyPage/Interface/Sources/AccountSetting/AccountSettingFeatureInterface.swift new file mode 100644 index 00000000..adb9473b --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/AccountSetting/AccountSettingFeatureInterface.swift @@ -0,0 +1,84 @@ +// +// AccountSettingFeatureInterface.swift +// FeatureMyPageInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation + +import DomainUserInterface + +import ComposableArchitecture + +@Reducer +public struct AccountSettingFeature { + private let reducer: Reduce + + public init(reducer: Reduce) { + self.reducer = reducer + } + + @ObservableState + public struct State: Equatable { + public var isOnMatchingToggle: Bool + @Presents var destination: Destination.State? + + public init( + isOnMatchingToggle: Bool = false + ) { + self.isOnMatchingToggle = isOnMatchingToggle + } + } + + public enum Action: BindableAction { + case onLoad + + case matchingToggleDidFetched(isOn: Bool) + + // UserAction + case matchingToggleDidChanged(isOn: Bool) + case backButtonDidTapped + case logoutButtonDidTapped + case withdrawalButtonDidTapped + case logoutDidCompleted + case withdrawalDidCompleted + + // binding + case binding(BindingAction) + + // alert + case alert(Alert) + public enum Alert: Equatable { + case confirmLogOut + case confirmWithdrawal + case dismiss + } + + // delegate + case delegate(Delegate) + + public enum Delegate { + case logoutDidCompleted + case withdrawalDidCompleted + case withdrawalButtonDidTapped + } + + case destination(PresentationAction) + } + + @Reducer(state: .equatable) + public enum Destination { + case alert(AlertState) + } + + enum ID: Hashable { + case matcingToggle + } + + public var body: some ReducerOf { + BindingReducer() + reducer + .ifLet(\.$destination, action: \.destination) + } +} diff --git a/Projects/Feature/MyPage/Interface/Sources/AccountSetting/AccountSettingView.swift b/Projects/Feature/MyPage/Interface/Sources/AccountSetting/AccountSettingView.swift new file mode 100644 index 00000000..0dde8a29 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/AccountSetting/AccountSettingView.swift @@ -0,0 +1,72 @@ +// +// AccountSettingView.swift +// FeatureMyPageInterface +// +// Created by 임현규 on 9/21/24. +// + +import SwiftUI + +import SharedDesignSystem + +import ComposableArchitecture + +public struct AccountSettingView: View { + @Perception.Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + + VStack(spacing: 0) { + VStack(spacing: .lg) { + matchingToggle + logoutList + withdrawList + } + .padding(.horizontal, .md) + .padding(.vertical, .xl) + .overlay(roundedRectangle) + .padding(.top, 32) + Spacer() + } + .padding(.horizontal, .lg) + .setNavigationBar { + makeNaivgationleftButton { store.send(.backButtonDidTapped) } + } + .onLoad { store.send(.onLoad) } + .bottleAlert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + } + } +} + +private extension AccountSettingView { + var roundedRectangle: some View { + RoundedRectangle(cornerRadius: BottleRadiusType.xl.value) + .strokeBorder( + ColorToken.border(.primary).color, + lineWidth: 1 + ) + } + + var matchingToggle: some View { + ToggleListView( + title: "매칭 활성화", + subTitle: "비활성화 시 다른 사람을 추천 받을 수 없고\n회원님도 다른 사람에게 추천되지 않아요", + isOn: $store.isOnMatchingToggle + ) + } + + var logoutList: some View { + ArrowListView(title: "로그아웃") + .asThrottleButton(action: { store.send(.logoutButtonDidTapped) }) + } + + var withdrawList: some View { + ArrowListView(title: "탈퇴하기") + .asThrottleButton(action: { store.send(.withdrawalButtonDidTapped) }) + } +} diff --git a/Projects/Feature/MyPage/Interface/Sources/AlertSetting/AlertSettingFeature.swift b/Projects/Feature/MyPage/Interface/Sources/AlertSetting/AlertSettingFeature.swift new file mode 100644 index 00000000..4a719871 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/AlertSetting/AlertSettingFeature.swift @@ -0,0 +1,163 @@ +// +// AlertSettingFeature.swift +// FeatureMyPageInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation +import Combine + +import DomainUser +import DomainUserInterface + +import CoreURLHandlerInterface +import CoreLoggerInterface + +import ComposableArchitecture + +extension AlertSettingFeature { + public init() { + @Dependency(\.userClient) var userClient + @Dependency(\.dismiss) var dismiss + + let reducer = Reduce { state, action in + switch action { + case .onLoad: + return Effect.publisher { + userClient.pushNotificationAllowStatusPublisher + .receive(on: DispatchQueue.main) + .map { isAllow in + .pushNotificationAllowed(isAllow: isAllow) + } + } + .cancellable(id: "PushNotificationPublisher", cancelInFlight: true) + + case .alertStateFetchDidRequest: + updatePushNotificationAllowStatus(state: &state) + + return .run { [state = state] send in + let isAllow = state.isAllowPushNotification + let alertStateList = try await userClient.fetchAlertState() + + for alertState in alertStateList { + let isOn = isAllow ? alertState.enabled : false + switch alertState.alertType { + case .randomBottle: + await send(.randomBottleToggleDidFetched(isOn: isOn)) + case .arrivalBottle: + await send(.arrivalBottleToggleDidFetched(isOn: isOn)) + case .pingpong: + await send(.pingpongToggleDidFetched(isOn: isOn)) + case .marketing: + await send(.marketingToggleDidFetched(isOn: isOn)) + default: + break + } + } + } + + case let .pushNotificationAllowed(isAllow): + state.isAllowPushNotification = isAllow + if isAllow { + return .send(.alertStateFetchDidRequest) + } else { + return .merge( + .send(.randomBottleToggleDidFetched(isOn: false)), + .send(.pingpongToggleDidFetched(isOn: false)), + .send(.arrivalBottleToggleDidFetched(isOn: false)), + .send(.marketingToggleDidFetched(isOn: false)) + ) + } + + case let .randomBottleToggleDidFetched(isOn): + state.isOnRandomBottleToggle = isOn + return .none + + case let .arrivalBottleToggleDidFetched(isOn): + state.isOnArrivalBottleToggle = isOn + return .none + + case let .pingpongToggleDidFetched(isOn): + state.isOnPingPongToggle = isOn + return .none + + case let .marketingToggleDidFetched(isOn): + state.isOnMarketingToggle = isOn + return .none + + case .backButtonDidTapped: + return .run { _ in + await dismiss() + } + + case .binding(\.isOnRandomBottleToggle): + let isOn = state.isOnRandomBottleToggle + return .send(.toggleDidChanged( + alertState: .init(alertType: .randomBottle, enabled: isOn), + id: .randomBottle)) + + case .binding(\.isOnArrivalBottleToggle): + let isOn = state.isOnArrivalBottleToggle + return .send(.toggleDidChanged( + alertState: .init(alertType: .arrivalBottle, enabled: isOn), + id: .arrivalBottle)) + + case .binding(\.isOnPingPongToggle): + let isOn = state.isOnPingPongToggle + return .send(.toggleDidChanged( + alertState: .init(alertType: .pingpong, enabled: isOn), + id: .pingping)) + + case .binding(\.isOnMarketingToggle): + let isOn = state.isOnMarketingToggle + return .send(.toggleDidChanged( + alertState: .init(alertType: .marketing, enabled: isOn), + id: .marketing)) + + case let .toggleDidChanged(alertState, id): + updatePushNotificationAllowStatus(state: &state) + + if state.isAllowPushNotification { + return .run { send in + try await userClient.updateAlertState(alertState: alertState) + } + .debounce( + id: id, + for: 0.5, + scheduler: DispatchQueue.main) + } else { + return .send(.pushNotificationAlertDidRequired) + } + + case .pushNotificationAlertDidRequired: + state.destination = .alert(.init( + title: { TextState("알림 권한 안내")}, + actions: { ButtonState( + role: .destructive, + action: .confirmPushNotification, + label: { TextState("설정하러 가기") }) }, + message: { TextState("설정 > '보틀' > 알림에서 알림을 허용해주세요.")})) + + return .none + + case let .destination(.presented(.alert(alert))): + switch alert { + case .confirmPushNotification: + URLHandler.shared.openURL(urlType: .setting) + return .none + } + + default: + return .none + } + + func updatePushNotificationAllowStatus(state: inout State) { + let isAllow = userClient.fetchPushNotificationAllowStatus() + state.isAllowPushNotification = isAllow + } + } + + self.init(reducer: reducer) + } +} diff --git a/Projects/Feature/MyPage/Interface/Sources/AlertSetting/AlertSettingFeatureInterface.swift b/Projects/Feature/MyPage/Interface/Sources/AlertSetting/AlertSettingFeatureInterface.swift new file mode 100644 index 00000000..0f554493 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/AlertSetting/AlertSettingFeatureInterface.swift @@ -0,0 +1,91 @@ +// +// AlertSettingFeatureInterface.swift +// FeatureMyPageInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation +import Combine + +import DomainUserInterface + +import ComposableArchitecture + +@Reducer +public struct AlertSettingFeature { + private let reducer: Reduce + + public init(reducer: Reduce) { + self.reducer = reducer + } + // Desination + @Reducer(state: .equatable) + public enum Destination { + case alert(AlertState) + } + + @ObservableState + public struct State: Equatable { + var isAllowPushNotification: Bool + public var isOnRandomBottleToggle: Bool + public var isOnArrivalBottleToggle: Bool + public var isOnPingPongToggle: Bool + public var isOnMarketingToggle: Bool + + @Presents var destination: Destination.State? + + public init( + isAllowPushNotification: Bool = false, + isOnRandomBottleToggle: Bool = false, + isOnArrivalBottleToggle: Bool = false, + isOnPingPongToggle: Bool = false, + isOnMarketingToggle: Bool = false + ) { + self.isAllowPushNotification = isAllowPushNotification + self.isOnRandomBottleToggle = isOnRandomBottleToggle + self.isOnArrivalBottleToggle = isOnArrivalBottleToggle + self.isOnPingPongToggle = isOnPingPongToggle + self.isOnMarketingToggle = isOnMarketingToggle + } + } + + public enum Action: BindableAction { + case onLoad + + case randomBottleToggleDidFetched(isOn: Bool) + case arrivalBottleToggleDidFetched(isOn: Bool) + case pingpongToggleDidFetched(isOn: Bool) + case marketingToggleDidFetched(isOn: Bool) + case pushNotificationAlertDidRequired + case pushNotificationAllowed(isAllow: Bool) + case alertStateFetchDidRequest + + // UserAction + case toggleDidChanged(alertState: UserAlertState, id: ID) + case backButtonDidTapped + + // ETC + case binding(BindingAction) + case destination(PresentationAction) + + // Alert + case alert(Alert) + public enum Alert: Equatable { + case confirmPushNotification + } + } + + public enum ID: Hashable { + case randomBottle + case arrivalBottle + case pingping + case marketing + } + + public var body: some ReducerOf { + BindingReducer() + reducer + .ifLet(\.$destination, action: \.destination) + } +} diff --git a/Projects/Feature/MyPage/Interface/Sources/AlertSetting/AlertSettingView.swift b/Projects/Feature/MyPage/Interface/Sources/AlertSetting/AlertSettingView.swift new file mode 100644 index 00000000..d0428bea --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/AlertSetting/AlertSettingView.swift @@ -0,0 +1,87 @@ +// +// AlertSettingView.swift +// FeatureMyPageInterface +// +// Created by 임현규 on 9/21/24. +// + +import SwiftUI + +import SharedDesignSystem + +import ComposableArchitecture + +public struct AlertSettingView: View { + @Perception.Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + + VStack(spacing: 0) { + VStack(spacing: .lg) { + randomBottleToggle + arrivalBottleToggle + pingpongToggle + Divider() + marketingToggle + } + .padding(.horizontal, .md) + .padding(.vertical, .xl) + .overlay(roundedRectangle) + .padding(.top, 32) + Spacer() + } + .padding(.horizontal, .lg) + .setNavigationBar { + makeNaivgationleftButton { store.send(.backButtonDidTapped) } + } + .bottleAlert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .onLoad { store.send(.onLoad) } + } + } +} + +private extension AlertSettingView { + var roundedRectangle: some View { + RoundedRectangle(cornerRadius: BottleRadiusType.xl.value) + .strokeBorder( + ColorToken.border(.primary).color, + lineWidth: 1 + ) + } + + var randomBottleToggle: some View { + ToggleListView( + title: "떠나니는 보틀 알림", + subTitle: "매일 랜덤으로 추천되는 보틀 안내", + isOn: $store.isOnRandomBottleToggle + ) + } + + var arrivalBottleToggle: some View { + ToggleListView( + title: "호감 도착 안내", + subTitle: "내가 받은 호감 안내", + isOn: $store.isOnArrivalBottleToggle + ) + } + + var pingpongToggle: some View { + ToggleListView( + title: "대화 알림", + subTitle: "가치관 문답 시작 · 진행 · 중단 , 매칭 안내", + isOn: $store.isOnPingPongToggle + ) + } + + var marketingToggle: some View { + ToggleListView( + title: "마케팅 수신 동의", + isOn: $store.isOnMarketingToggle + ) + } +} diff --git a/Projects/Feature/MyPage/Interface/Sources/EditProfile/EditProfileFeature.swift b/Projects/Feature/MyPage/Interface/Sources/EditProfile/EditProfileFeature.swift new file mode 100644 index 00000000..e711cc73 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/EditProfile/EditProfileFeature.swift @@ -0,0 +1,37 @@ +// +// EditProfileFeature.swift +// FeatureMyPage +// +// Created by JongHoon on 9/22/24. +// + +import Foundation + +import CoreToastInterface + +import ComposableArchitecture + +extension EditProfileFeature { + public init() { + @Dependency(\.toastClient) var toastClient + + let reducer = Reduce { state, action in + switch action { + case .initialLoadingCompleted: + state.isLoading = false + return .none + + case let .presentToast(message): + toastClient.presentToast(message: message) + return .none + + case .backButtonDidTapped: + return .send(.delegate(.closeEditProfileView)) + + default: + return .none + } + } + self.init(reducer: reducer) + } +} diff --git a/Projects/Feature/MyPage/Interface/Sources/EditProfile/EditProfileFeatureInterface.swift b/Projects/Feature/MyPage/Interface/Sources/EditProfile/EditProfileFeatureInterface.swift new file mode 100644 index 00000000..43b47e82 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/EditProfile/EditProfileFeatureInterface.swift @@ -0,0 +1,47 @@ +// +// EditProfileFeatureInterface.swift +// FeatureMyPage +// +// Created by JongHoon on 9/22/24. +// + +import Foundation + +import ComposableArchitecture + +@Reducer +public struct EditProfileFeature { + private let reducer: Reduce + + public init(reducer: Reduce) { + self.reducer = reducer + } + + @ObservableState + public struct State: Equatable { + var isLoading: Bool + + init() { + self.isLoading = true + } + } + + public enum Action: BindableAction { + case initialLoadingCompleted + case presentToast(message: String) + case backButtonDidTapped + case delegate(Delegate) + + public enum Delegate { + case closeEditProfileView + } + + // binding + case binding(BindingAction) + } + + public var body: some ReducerOf { + BindingReducer() + reducer + } +} diff --git a/Projects/Feature/MyPage/Interface/Sources/EditProfile/ProfileEditView.swift b/Projects/Feature/MyPage/Interface/Sources/EditProfile/ProfileEditView.swift new file mode 100644 index 00000000..7addfb6b --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/EditProfile/ProfileEditView.swift @@ -0,0 +1,53 @@ +// +// ProfileEditView.swift +// FeatureMyPage +// +// Created by JongHoon on 9/22/24. +// + +import SwiftUI + +import FeatureBaseWebViewInterface + +import CoreLoggerInterface + +import SharedDesignSystem + +import ComposableArchitecture + +public struct ProfileEditView: View { + @Perception.Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + BaseWebView(type: .editProfile) { action in + switch action { + case .webViewLoadingDidCompleted: + store.send(.initialLoadingCompleted) + + case let .showTaost(message): + store.send(.presentToast(message: message)) + + case .closeWebView: + store.send(.backButtonDidTapped) + + default: + Log.assertion(message: "\(action) - not handled action") + } + } + } + .navigationBarBackButtonHidden() + .overlay { + WithPerceptionTracking { + if store.isLoading { + LoadingIndicator() + } + } + } + .ignoresSafeArea(.all, edges: [.bottom, .top]) + } +} diff --git a/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeature.swift b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeature.swift index 430bfb83..fbfca7fe 100644 --- a/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeature.swift +++ b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeature.swift @@ -9,10 +9,16 @@ import Foundation import DomainAuth import DomainProfile +import DomainUserInterface +import DomainApplication + import CoreKeyChainStore import CoreToastInterface import CoreLoggerInterface +import CoreURLHandlerInterface + import SharedDesignSystem + import ComposableArchitecture extension MyPageFeature { @@ -20,6 +26,9 @@ extension MyPageFeature { @Dependency(\.authClient) var authClient @Dependency(\.toastClient) var toastClient @Dependency(\.profileClient) var profileClient + @Dependency(\.userClient) var userClient + @Dependency(\.applicationClient) var applicationClient + let reducer = Reduce { state, action in switch action { case .onLoad: @@ -29,6 +38,20 @@ extension MyPageFeature { await send(.userProfileDidFetched(userProfile)) } + case .onAppear: + return .run { send in + let currentAppVersion = applicationClient.fetchCurrentAppVersion() + let isNeedUpdateApplication = try await applicationClient.checkNeedApplicationUpdate() + await send(.applicationVersionInfoFetched(currentAppVersion: currentAppVersion, isNeedUpdate: isNeedUpdateApplication)) + } catch: { error, send in + Log.error(error) + } + + case let .applicationVersionInfoFetched(currentAppVersion, isNeedUpdate): + state.currentAppVersion = currentAppVersion + state.isShowApplicationUpdateButton = isNeedUpdate + return .none + case .logOutButtonDidTapped: state.destination = .alert(.init( title: { TextState("로그아웃") }, @@ -71,6 +94,22 @@ extension MyPageFeature { } await send(.withdrawalDidCompleted) } + + case .dismissAlert: + state.destination = nil + return .send(.configureLoadingProgressView(isShow: false)) + + case .dismissContactsAlert: + state.destination = nil + URLHandler.shared.openURL(urlType: .setting) + return .none + + case let .confirmBlockContacts(contacts): + return .run { send in + try await userClient.updateBlockContacts(contacts: contacts) + await send(.updatePhoneNumberForBlockCompleted(count: contacts.count)) + await send(.configureLoadingProgressView(isShow: false)) + } } case .userProfileDidFetched(let userProfile): @@ -79,6 +118,7 @@ extension MyPageFeature { let introduction = userProfile.introduction Log.debug(userProfile) + state.blockedContactsCount = userInfo.blockedContactsCount state.keywordItem = [ ClipItem( @@ -113,6 +153,93 @@ extension MyPageFeature { let userProfile = try await profileClient.fetchUserProfile() await send(.userProfileDidFetched(userProfile)) } + + case .updatePhoneNumberForBlockButtonDidTapped: + return .run { send in + await send(.configureLoadingProgressView(isShow: true)) + let contacts = try await userClient.fetchContacts() + await send(.contactsDidReceived(contacts: contacts)) + } catch: { error, send in + await send(.configureLoadingProgressView(isShow: false)) + if let userError = error as? UserError { + switch userError { + case .requestContactsAccessAuthorityFailed: + Log.debug("연락처 접근 요정 거부") + case .contactsAccessDenied: + await send(.contactsAccessDeniedErrorOccurred) + } + } + } + + case let .contactsDidReceived(contacts): + let count = contacts.count + state.destination = .alert(.init( + title: { TextState("연락처 차단") }, + actions: { + ButtonState(role: .cancel, action: .dismissAlert, label: { TextState("취소하기")}) + ButtonState(role: .destructive, action: .confirmBlockContacts(contacts: contacts), label: { TextState("차단하기")}) + }, + message: { TextState("주소록에 있는 \(count)개의\n전화번호를 차단할까요?") })) + return .none + + case .updateApplicationButtonTapped: + URLHandler.shared.openURL(urlType: .bottleAppStore) + return .none + + case let .updatePhoneNumberForBlockCompleted(count): + toastClient.presentToast(message: "차단이 완료됐어요") + state.blockedContactsCount = count + return .none + + case .contactsAccessDeniedErrorOccurred: + state.destination = .alert(.init( + title: { + TextState("안내") + }, + actions: { + ButtonState( + action: .dismissContactsAlert, + label: { TextState("확인") } + ) + }, + message: { + TextState("설정 > 개인정보 보호 및 보안 > 연락처에서 '보틀'의 연락처 접근을 허락해 주세요.") + } + )) + return .none + + case .profileEditListDidTapped: + return .send(.delegate(.profileEditListDidTapped)) + + case .alertSettingListDidTapped: + return .send(.delegate(.alertSettingListDidTapped)) + + case .accountSettingListDidTapped: + return .send(.delegate(.accountSettingListDidTapped)) + + case .termsOfServiceListDidTapped: + state.isPresentTerms = true + state.temrsURL = "https://spiral-ogre-a4d.notion.site/240724-e3676639ea864147bb293cfcda40d99f" + return .none + + case .privacyPolicyListDidTapped: + state.isPresentTerms = true + state.temrsURL = "https://spiral-ogre-a4d.notion.site/abb2fd284516408e8c2fc267d07c6421" + return .none + + case .termsWebViewDidDismiss: + state.isPresentTerms = false + state.temrsURL = "" + return .none + + case .contactListDidTapped: + URLHandler.shared.openURL(urlType: .kakaoChannelTalk) + return .none + + case let .configureLoadingProgressView(isShow): + state.isShowLoadingProgressView = isShow + return .none + default: return .none } diff --git a/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeatureInterface.swift b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeatureInterface.swift index cfd4f513..c1e1b8a1 100644 --- a/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeatureInterface.swift +++ b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageFeatureInterface.swift @@ -7,10 +7,12 @@ import Foundation -import SharedDesignSystem import DomainProfileInterface + import FeatureTabBarInterface +import SharedDesignSystem + import ComposableArchitecture @Reducer @@ -24,9 +26,15 @@ public struct MyPageFeature { @ObservableState public struct State: Equatable { var isShowLoadingProgressView: Bool + public var keywordItem: [ClipItem] public var userInfo: UserInfo public var introduction: Introduction + public var blockedContactsCount: Int + public var currentAppVersion: String? + public var isShowApplicationUpdateButton: Bool + public var isPresentTerms: Bool + public var temrsURL: String? @Presents var destination: Destination.State? @@ -37,19 +45,39 @@ public struct MyPageFeature { self.keywordItem = keywordItem self.userInfo = .init(userAge: -1, userImageURL: "", userName: "") self.introduction = .init(answer: "", question: "") + self.blockedContactsCount = 0 + self.isShowApplicationUpdateButton = false + self.isPresentTerms = false } } public enum Action: BindableAction { // View Life Cycle case onLoad + case onAppear + case userProfileDidFetched(UserProfile) case userProfileUpdateDidRequest + case updatePhoneNumberForBlockButtonDidTapped case logOutButtonDidTapped case logOutDidCompleted case withdrawalButtonDidTapped case withdrawalDidCompleted case selectedTabDidChanged(TabType) + case profileEditListDidTapped + case alertSettingListDidTapped + case accountSettingListDidTapped + case updateApplicationButtonTapped + + case updatePhoneNumberForBlockCompleted(count: Int) + case contactsAccessDeniedErrorOccurred + case applicationVersionInfoFetched(currentAppVersion: String, isNeedUpdate: Bool) + case termsOfServiceListDidTapped + case privacyPolicyListDidTapped + case termsWebViewDidDismiss + case contactListDidTapped + case contactsDidReceived(contacts: [String]) + case configureLoadingProgressView(isShow: Bool) case delegate(Delegate) @@ -58,12 +86,18 @@ public struct MyPageFeature { case withdrawalDidCompleted case logoutDidCompleted case selectedTabDidChanged(TabType) + case profileEditListDidTapped + case alertSettingListDidTapped + case accountSettingListDidTapped } case alert(Alert) public enum Alert: Equatable { case confirmLogOut case confirmWithdrawal + case confirmBlockContacts(contacts: [String]) + case dismissAlert + case dismissContactsAlert } // ETC diff --git a/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageView.swift b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageView.swift index 74fb50f4..933780b5 100644 --- a/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageView.swift +++ b/Projects/Feature/MyPage/Interface/Sources/MyPage/MyPageView.swift @@ -6,13 +6,19 @@ // import SwiftUI +import Contacts import FeatureTabBarInterface import FeatureBaseWebViewInterface +import FeatureGeneralSignUpInterface + +import CoreLoggerInterface + import SharedDesignSystem import ComposableArchitecture + public struct MyPageView: View { @Perception.Bindable private var store: StoreOf @@ -27,17 +33,22 @@ public struct MyPageView: View { Spacer() .frame(height: 52.0) userProfile - myIntroduction - myKeywords + profileEditList - HStack(spacing: 0) { - Spacer() - logoutButton - Spacer() - withdrawalButton - Spacer() + VStack(spacing: .lg) { + blockPhoneNumberList + pushSettingList + accountSettingList + Divider() + appVersionList + contactList + Divider() + termsOfServiceList + privacyPolicyList } - .padding(.bottom, .xl) + .padding(.horizontal, .md) + .padding(.vertical, .xl) + .overlay(roundedRectangle) } .padding(.horizontal, .md) } @@ -51,6 +62,9 @@ public struct MyPageView: View { .onLoad { store.send(.onLoad) } + .task { + store.send(.onAppear) + } .overlay { if store.isShowLoadingProgressView { WithPerceptionTracking { @@ -58,7 +72,12 @@ public struct MyPageView: View { } } } - .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .bottleAlert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .sheet(isPresented: $store.isPresentTerms) { + store.send(.termsWebViewDidDismiss) + } content: { + TermsWebView(url: store.temrsURL ?? "") + } } } } @@ -83,40 +102,70 @@ private extension MyPageView { .padding(.bottom, .xl) } - @ViewBuilder - var myIntroduction: some View { - if store.introduction.answer == "" { - EmptyView() - } else { - LettetCardView(title: "내가 쓴 편지" , letterContent: store.introduction.answer) - .padding(.bottom, .sm) - } + var roundedRectangle: some View { + RoundedRectangle(cornerRadius: BottleRadiusType.xl.value) + .strokeBorder( + ColorToken.border(.primary).color, + lineWidth: 1 + ) } - var myKeywords: some View { - ClipListContainerView(clipItemList: store.keywordItem) + var profileEditList: some View { + ArrowListView(title: "프로필 수정") + .padding(.horizontal, .md) + .padding(.vertical, .xl) + .overlay(roundedRectangle) .padding(.bottom, .md) + .asThrottleButton { + store.send(.profileEditListDidTapped) + } } - var logoutButton: some View { - WantedSansStyleText( - "로그아웃", - style: .subTitle2, - color: .enableSecondary + var blockPhoneNumberList: some View { + ButtonListView( + title: "연락처 차단", + subTitle: "연락처 속 \(store.blockedContactsCount)명을 차단했어요", + buttonTitle: "업데이트", + action: { + store.send(.updatePhoneNumberForBlockButtonDidTapped) + } ) - .asThrottleButton { - store.send(.logOutButtonDidTapped) - } } - var withdrawalButton: some View { - WantedSansStyleText( - "탈퇴하기", - style: .subTitle2, - color: .enableSecondary + var pushSettingList: some View { + ArrowListView(title: "알림 설정") + .asThrottleButton(action: { store.send(.alertSettingListDidTapped)}) + } + + var accountSettingList: some View { + ArrowListView(title: "계정 관리") + .asThrottleButton(action: { store.send(.accountSettingListDidTapped) }) + } + + var appVersionList: some View { + ButtonListView( + title: "앱 버전", + subTitle: "\(store.currentAppVersion ?? "0.0.0")", + buttonTitle: "업데이트", + isShowButton: store.isShowApplicationUpdateButton, + action: { + store.send(.updateApplicationButtonTapped) + } ) - .asThrottleButton { - store.send(.withdrawalButtonDidTapped) - } + } + + var contactList: some View { + ArrowListView(title: "1:1 문의") + .asThrottleButton(action: { store.send(.contactListDidTapped) }) + } + + var termsOfServiceList: some View { + ArrowListView(title: "보틀 이용 약관") + .asThrottleButton(action: { store.send(.termsOfServiceListDidTapped) }) + } + + var privacyPolicyList: some View { + ArrowListView(title: "개인정보처리방침") + .asThrottleButton(action: { store.send(.privacyPolicyListDidTapped) }) } } diff --git a/Projects/Feature/MyPage/Interface/Sources/MyPageRootFeature.swift b/Projects/Feature/MyPage/Interface/Sources/MyPageRootFeature.swift new file mode 100644 index 00000000..ff28ef53 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/MyPageRootFeature.swift @@ -0,0 +1,66 @@ +// +// MyPageRootFeature.swift +// FeatureMyPageInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation + +import FeatureTabBarInterface + +import ComposableArchitecture + +@Reducer +public struct MyPageRootFeature { + private let reducer: Reduce + + public init(reducer: Reduce) { + self.reducer = reducer + } + + @Reducer(state: .equatable) + public enum Path { + case alertSetting(AlertSettingFeature) + case accountSetting(AccountSettingFeature) + case editProfile(EditProfileFeature) + } + + @ObservableState + public struct State: Equatable { + var path = StackState() + public var myPage: MyPageFeature.State + + public init( + path: StackState = StackState(), + myPage: MyPageFeature.State = .init() + ) { + self.path = path + self.myPage = myPage + } + } + + public enum Action { + case path(StackAction) + case myPage(MyPageFeature.Action) + case delegate(Delegate) + case selectedTabDidChanged(selectedTab: TabType) + case userProfileUpdateDidRequest + + public enum Delegate { + case withdrawalButtonDidTapped + case withdrawalDidCompleted + case logoutDidCompleted + case selectedTabDidChanged(TabType) + } + } + + public var body: some ReducerOf { + Scope(state: \.myPage, action: \.myPage) { + MyPageFeature() + } + + reducer + .forEach(\.path, action: \.path) + } +} diff --git a/Projects/Feature/MyPage/Interface/Sources/MyPageRootFeatureInterface.swift b/Projects/Feature/MyPage/Interface/Sources/MyPageRootFeatureInterface.swift new file mode 100644 index 00000000..a7d175b3 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/MyPageRootFeatureInterface.swift @@ -0,0 +1,69 @@ +// +// MyPageRootFeatureInterface.swift +// FeatureMyPageInterface +// +// Created by 임현규 on 9/21/24. +// + +import Foundation + +import ComposableArchitecture + +extension MyPageRootFeature { + public init() { + let reducer = Reduce { state, action in + switch action { + + case .userProfileUpdateDidRequest: + return .send(.myPage(.userProfileUpdateDidRequest)) + + case let .selectedTabDidChanged(selectedTab): + return .send(.delegate(.selectedTabDidChanged(selectedTab))) + + // MyPage Delegate + case let .myPage(delegate): + switch delegate { + case .alertSettingListDidTapped: + state.path.append(.alertSetting(.init())) + return .none + + case .accountSettingListDidTapped: + state.path.append(.accountSetting(.init())) + return .none + + case .profileEditListDidTapped: + state.path.append(.editProfile(.init())) + return .none + + default: + return .none + } + + // AccountSetting Delegate + case let .path(.element(id: _, action: .accountSetting(.delegate(delegate)))): + switch delegate { + case .logoutDidCompleted: + return .send(.delegate(.logoutDidCompleted)) + + case .withdrawalButtonDidTapped: + return .send(.delegate(.withdrawalButtonDidTapped)) + + case .withdrawalDidCompleted: + return .send(.delegate(.withdrawalDidCompleted)) + } + + case let .path(.element(id: _, action: .editProfile(.delegate(delegate)))): + switch delegate { + case .closeEditProfileView: + _ = state.path.popLast() + return .none + } + + default: + return .none + } + } + + self.init(reducer: reducer) + } +} diff --git a/Projects/Feature/MyPage/Interface/Sources/MyPageRootView.swift b/Projects/Feature/MyPage/Interface/Sources/MyPageRootView.swift new file mode 100644 index 00000000..c9fd1793 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/MyPageRootView.swift @@ -0,0 +1,55 @@ +// +// MyPageRootView.swift +// FeatureMyPageInterface +// +// Created by 임현규 on 9/21/24. +// + +import SwiftUI + +import FeatureTabBarInterface + +import ComposableArchitecture + +public struct MyPageRootView: View { + @Perception.Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + MyPageView(store: store.scope(state: \.myPage, action: \.myPage)) + .setTabBar(selectedTab: .myPage) { selectedTab in + store.send(.selectedTabDidChanged(selectedTab: selectedTab)) + } + } destination: { store in + WithPerceptionTracking { + switch store.state { + case .alertSetting: + if let store = store.scope( + state: \.alertSetting, + action: \.alertSetting) { + AlertSettingView(store: store) + } + case .accountSetting: + if let store = store.scope( + state: \.accountSetting, + action: \.accountSetting) { + AccountSettingView(store: store) + } + case .editProfile: + if let store = store.scope( + state: \.editProfile, + action: \.editProfile + ) { + ProfileEditView(store: store) + } + } + } + } + } + } +} diff --git a/Projects/Feature/MyPage/Project.swift b/Projects/Feature/MyPage/Project.swift index 8398d1f4..f49c46e1 100644 --- a/Projects/Feature/MyPage/Project.swift +++ b/Projects/Feature/MyPage/Project.swift @@ -11,7 +11,8 @@ let project = Project.makeModule( dependencies: [ .domain, .feature(interface: .BaseWebView), - .feature(interface: .TabBar) + .feature(interface: .TabBar), + .feature(interface: .GeneralSignUp) ] ) ), diff --git a/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingView.swift b/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingView.swift index fb869298..6191dcde 100644 --- a/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingView.swift +++ b/Projects/Feature/Onboarding/Interface/Sources/Onboarding/OnboardingView.swift @@ -43,7 +43,7 @@ public struct OnboardingView: View { } } ) - .ignoresSafeArea(.all, edges: .bottom) + .ignoresSafeArea(.all, edges: [.top, .bottom]) .toolbar(.hidden, for: .navigationBar) .overlay { if store.isShowLoadingProgressView { diff --git a/Projects/Feature/Report/Interface/Sources/ReportUserFeature.swift b/Projects/Feature/Report/Interface/Sources/ReportUserFeature.swift index 9a008b48..b854751c 100644 --- a/Projects/Feature/Report/Interface/Sources/ReportUserFeature.swift +++ b/Projects/Feature/Report/Interface/Sources/ReportUserFeature.swift @@ -28,10 +28,19 @@ extension ReportUserFeature { print("tapped") state.destination = .alert(.init( title: { TextState("신고하기")}, - actions: { ButtonState( - role: .destructive, - action: .confirmReport, - label: { TextState("신고하기") }) }, + actions: { + ButtonState( + role: .cancel, + action: .confirmReport, + label: { TextState("계속하기") } + ) + + ButtonState( + role: .destructive, + action: .dismiss, + label: { TextState("중단하기") } + ) + }, message: { TextState("접수 후 취소할 수 없으며 해당 사용자는 차단되요.\n정말 신고하시겠어요?")})) return .none @@ -43,10 +52,17 @@ extension ReportUserFeature { } return .none - case .destination(.presented(.alert(.confirmReport))): - return .run { [userProfile = state.userProfile, reportText = state.reportText] send in - try await reportClient.reportUser(userReportInfo: .init(reason: reportText, userId: userProfile.userID)) - await send(.delegate(.reportDidCompleted)) + case let .destination(.presented(.alert(alert))): + switch alert { + case .confirmReport: + return .run { [userProfile = state.userProfile, reportText = state.reportText] send in + try await reportClient.reportUser(userReportInfo: .init(reason: reportText, userId: userProfile.userID)) + await send(.delegate(.reportDidCompleted)) + } + + case .dismiss: + state.destination = nil + return .none } case .binding(\.reportText): diff --git a/Projects/Feature/Report/Interface/Sources/ReportUserFeatureInterface.swift b/Projects/Feature/Report/Interface/Sources/ReportUserFeatureInterface.swift index 9c8b487f..6b1fe780 100644 --- a/Projects/Feature/Report/Interface/Sources/ReportUserFeatureInterface.swift +++ b/Projects/Feature/Report/Interface/Sources/ReportUserFeatureInterface.swift @@ -70,6 +70,7 @@ public struct ReportUserFeature { case alert(Alert) public enum Alert: Equatable { case confirmReport + case dismiss } // ETC diff --git a/Projects/Feature/Report/Interface/Sources/ReportUserView.swift b/Projects/Feature/Report/Interface/Sources/ReportUserView.swift index 3f626967..bef7669e 100644 --- a/Projects/Feature/Report/Interface/Sources/ReportUserView.swift +++ b/Projects/Feature/Report/Interface/Sources/ReportUserView.swift @@ -35,7 +35,7 @@ public struct ReportUserView: View { } } .padding(.horizontal, .md) - .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .bottleAlert($store.scope(state: \.destination?.alert, action: \.destination.alert)) .toolbar(.hidden, for: .bottomBar) } } diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift index 95ac0b99..715d3da5 100644 --- a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift @@ -6,7 +6,6 @@ // import Foundation -import UIKit import DomainProfile import DomainBottle @@ -14,6 +13,7 @@ import DomainAuth import DomainErrorInterface import CoreLoggerInterface +import CoreURLHandlerInterface import SharedDesignSystem import SharedUtilInterface @@ -141,7 +141,7 @@ extension SandBeachFeature { Log.error(error) if let authError = error as? DomainError.AuthError { switch authError { - case .needUpdateAppVersion: + case .invalidAppVersion: await send(.needUpdateAppVersionErrorOccured) } } @@ -184,8 +184,7 @@ extension SandBeachFeature { } case .updateAppVersion: - let appStoreURL = URL(string: Bundle.main.infoDictionary?["APP_STORE_URL"] as? String ?? "")! - UIApplication.shared.open(appStoreURL) + URLHandler.shared.openURL(urlType: .bottleAppStore) return .run { send in await send(.needUpdateAppVersionErrorOccured) } diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift index bcfea92b..cb523178 100644 --- a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift @@ -64,7 +64,7 @@ public struct SandBeachView: View { } } } - .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .bottleAlert($store.scope(state: \.destination?.alert, action: \.destination.alert)) .onAppear { store.send(.onAppear) } diff --git a/Projects/Feature/Sources/App/AppDelegateFeature.swift b/Projects/Feature/Sources/App/AppDelegateFeature.swift index 4233bede..fb7b9659 100644 --- a/Projects/Feature/Sources/App/AppDelegateFeature.swift +++ b/Projects/Feature/Sources/App/AppDelegateFeature.swift @@ -7,6 +7,8 @@ import Foundation +import DomainUserInterface + import ComposableArchitecture import KakaoSDKCommon @@ -20,6 +22,7 @@ public struct AppDelegateFeature { public enum Action { case didFinishLunching case didReceivedFcmToken(fcmToken: String) + case pushNotificationAllowStatusDidChanged(isAllow: Bool) // Delegate case delegate(Delegate) @@ -37,6 +40,8 @@ public struct AppDelegateFeature { state: inout State, action: Action ) -> EffectOf { + @Dependency(\.userClient) var userClient + switch action { case .didFinishLunching: guard let kakaoAppKey = Bundle.main.infoDictionary?["KAKAO_APP_KEY"] as? String else { @@ -51,6 +56,10 @@ public struct AppDelegateFeature { await send(.delegate(.fcmTokenDidRecevied(fcmToken: fcmToken))) } + case let .pushNotificationAllowStatusDidChanged(isAllow): + userClient.updatePushNotificationAllowStatus(isAllow: isAllow) + return .none + default: return .none } diff --git a/Projects/Feature/Sources/SplashView/SplashFeature.swift b/Projects/Feature/Sources/SplashView/SplashFeature.swift index 60ed9240..6f009b86 100644 --- a/Projects/Feature/Sources/SplashView/SplashFeature.swift +++ b/Projects/Feature/Sources/SplashView/SplashFeature.swift @@ -6,9 +6,9 @@ // import Foundation -import UIKit import CoreLoggerInterface +import CoreURLHandlerInterface import DomainAuthInterface import DomainErrorInterface @@ -66,7 +66,7 @@ public struct SplashFeature { // TODO: Error handling if let authError = error as? DomainError.AuthError { switch authError { - case .needUpdateAppVersion: + case .invalidAppVersion: await send(.needUpdateAppVersionErrorOccured) } } @@ -92,8 +92,7 @@ public struct SplashFeature { } case .updateAppVersion: - let appStoreURL = URL(string: Bundle.main.infoDictionary?["APP_STORE_URL"] as? String ?? "")! - UIApplication.shared.open(appStoreURL) + URLHandler.shared.openURL(urlType: .bottleAppStore) return .run { send in await send(.needUpdateAppVersionErrorOccured) } diff --git a/Projects/Feature/Sources/SplashView/SplashView.swift b/Projects/Feature/Sources/SplashView/SplashView.swift index 52d19d52..7329ec31 100644 --- a/Projects/Feature/Sources/SplashView/SplashView.swift +++ b/Projects/Feature/Sources/SplashView/SplashView.swift @@ -26,7 +26,7 @@ public struct SplashView: View { Image.BottleImageSystem.illustraition(.splash).image } - .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .bottleAlert($store.scope(state: \.destination?.alert, action: \.destination.alert)) .ignoresSafeArea() .task { store.send(.onAppear) diff --git a/Projects/Feature/Sources/TabView/MainTabView.swift b/Projects/Feature/Sources/TabView/MainTabView.swift index dcb03162..32787aa8 100644 --- a/Projects/Feature/Sources/TabView/MainTabView.swift +++ b/Projects/Feature/Sources/TabView/MainTabView.swift @@ -34,7 +34,7 @@ public struct MainTabView: View { .tag(TabType.bottleStorage) .toolbar(.hidden, for: .tabBar) - MyPageView(store: store.scope(state: \.myPage, action: \.myPage)) + MyPageRootView(store: store.scope(state: \.myPageRoot, action: \.myPageRoot)) .tag(TabType.myPage) .toolbar(.hidden, for: .tabBar) } diff --git a/Projects/Feature/Sources/TabView/MainTabViewFeature.swift b/Projects/Feature/Sources/TabView/MainTabViewFeature.swift index 92124157..ecbe49c7 100644 --- a/Projects/Feature/Sources/TabView/MainTabViewFeature.swift +++ b/Projects/Feature/Sources/TabView/MainTabViewFeature.swift @@ -24,13 +24,13 @@ public struct MainTabViewFeature { public struct State: Equatable { var sandBeachRoot: SandBeachRootFeature.State var bottleStorage: BottleStorageFeature.State - var myPage: MyPageFeature.State + var myPageRoot: MyPageRootFeature.State var selectedTab: TabType var isLoading: Bool public init() { self.sandBeachRoot = .init() self.bottleStorage = .init() - self.myPage = .init() + self.myPageRoot = .init() self.selectedTab = .sandBeach self.isLoading = false } @@ -39,7 +39,7 @@ public struct MainTabViewFeature { public enum Action: BindableAction { case sandBeachRoot(SandBeachRootFeature.Action) case bottleStorage(BottleStorageFeature.Action) - case myPage(MyPageFeature.Action) + case myPageRoot(MyPageRootFeature.Action) case selectedTabChanged(TabType) case binding(BindingAction) @@ -60,8 +60,8 @@ public struct MainTabViewFeature { Scope(state: \.bottleStorage, action: \.bottleStorage) { BottleStorageFeature() } - Scope(state: \.myPage, action: \.myPage) { - MyPageFeature() + Scope(state: \.myPageRoot, action: \.myPageRoot) { + MyPageRootFeature() } Reduce(feature) } @@ -83,7 +83,7 @@ public struct MainTabViewFeature { case let .selectedTabDidChanged(selectedTab): state.selectedTab = selectedTab case .profileSetUpDidCompleted: - return .send(.myPage(.userProfileUpdateDidRequest)) + return .send(.myPageRoot(.userProfileUpdateDidRequest)) } return .none @@ -96,7 +96,7 @@ public struct MainTabViewFeature { return .none // MyPage Delegate - case let .myPage(.delegate(delegate)): + case let .myPageRoot(.delegate(delegate)): switch delegate { case .logoutDidCompleted: return .send(.delegate(.logoutDidCompleted)) diff --git a/Projects/Shared/DesignSystem/Example/Sources/DesignSystemExampleView/DesignSystemExampleView.swift b/Projects/Shared/DesignSystem/Example/Sources/DesignSystemExampleView/DesignSystemExampleView.swift index 5b83ef92..9b851cc9 100644 --- a/Projects/Shared/DesignSystem/Example/Sources/DesignSystemExampleView/DesignSystemExampleView.swift +++ b/Projects/Shared/DesignSystem/Example/Sources/DesignSystemExampleView/DesignSystemExampleView.swift @@ -329,6 +329,10 @@ struct ListSection: View { destination: BottleStorageList(), label: { Text("Bottle Storage List") } ) + + NavigationLink( + destination: ListTestView(), + label: { Text("Lists View")}) }, header: { Text("List") diff --git a/Projects/Shared/DesignSystem/Example/Sources/SubViews/ListTest/ListTestView.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/ListTest/ListTestView.swift new file mode 100644 index 00000000..ac06f464 --- /dev/null +++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/ListTest/ListTestView.swift @@ -0,0 +1,42 @@ +// +// ListTestView.swift +// DesignSystemExample +// +// Created by 임현규 on 9/2/24. +// + +import SwiftUI + +import SharedDesignSystem + +struct ListTestView: View { + @State private var isOn: Bool = false + private let title = "title" + private let subTitle = "subTitle" + private let buttonTitle = "업데이트" + + var body: some View { + VStack(spacing: .md) { + ArrowListView(title: title) + + ArrowListView(title: title, subTitle: subTitle) + + ToggleListView(title: title, isOn: $isOn) + + ToggleListView(title: title, subTitle: subTitle, isOn: $isOn) + + ButtonListView(title: title, buttonTitle: buttonTitle) { + print("first ButtonListView Button DidTapped") + } + + ButtonListView(title: title, subTitle: subTitle, buttonTitle: buttonTitle) { + print("second ButtonListView Button DidTapped") + } + + TextListView(title: title) + + TextListView(title: title, subTitle: subTitle) + } + .padding(.horizontal, .md) + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_warning.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_warning.imageset/Contents.json new file mode 100644 index 00000000..832f1037 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_warning.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "icon_warning.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_warning.imageset/icon_warning.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_warning.imageset/icon_warning.svg new file mode 100644 index 00000000..1dbb08bd --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_warning.imageset/icon_warning.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Sources/Components/Alert/BottleAlert.swift b/Projects/Shared/DesignSystem/Sources/Components/Alert/BottleAlert.swift new file mode 100644 index 00000000..e93ccd67 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Alert/BottleAlert.swift @@ -0,0 +1,65 @@ +// +// BottleAlert.swift +// SharedDesignSystem +// +// Created by 임현규 on 9/14/24. +// + +import SwiftUI +import ComposableArchitecture + +extension View { + public func bottleAlert(_ item: Binding, Action>?>) -> some View { + + let store = item.wrappedValue + let alertState = store?.withState { $0 } + let isPresented = Binding( + get: { item.wrappedValue != nil }, + set: { newValue in + if !newValue { + item.wrappedValue = nil + } + } + ) + + return ZStack { + self + BottleAlertView( + (alertState?.title).map { Text($0).font(to: .wantedSans(.subTitle1)) } + ?? Text(verbatim: ""), + isPresented: isPresented, + presenting: alertState, + actions: { alertState in + HStack(spacing: .sm) { + ForEach(alertState.buttons) { button in + Text(button.label) + .font(to: .wantedSans(.body)) + .asButton { + switch button.action.type { + case let .send(action): + if let action { + store?.send(action) + } + case let .animatedSend(action, animation): + if let action { + store?.send(action, animation: animation) + } + } + } + .buttonStyle( + SolidButtonStyle( + sizeType: .small, + buttonApperance: button.role == .cancel ? .cancel : .solid + ) + ) + } + } + }, + message: { + $0.message.map(Text.init) + .font(to: .wantedSans(.body)) + } + ) + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Alert/BottleAlertView.swift b/Projects/Shared/DesignSystem/Sources/Components/Alert/BottleAlertView.swift new file mode 100644 index 00000000..80228c04 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Alert/BottleAlertView.swift @@ -0,0 +1,84 @@ +// +// BottleAlertView.swift +// SharedDesignSystem +// +// Created by 임현규 on 9/14/24. +// + +import SwiftUI + +struct BottleAlertView: View where A: View, M: View { + private let title: Text + private var isPresented: Binding + private let presenting: T? + private let actions: (T) -> A + private let message: (T) -> M + + public init( + _ title: Text, + isPresented: Binding, + presenting: T?, + @ViewBuilder actions: @escaping (T) -> A, + @ViewBuilder message: @escaping (T) -> M + ) { + self.title = title + self.isPresented = isPresented + self.presenting = presenting + self.actions = actions + self.message = message + } + + var body: some View { + if isPresented.wrappedValue { + ZStack { + Color.black + .opacity(0.5) + .edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + alertImage + title + .padding(.bottom, 7) + messageView + actionsView + } + .padding(.horizontal, .md) + .frame(maxWidth: 300) + .background(to: ColorToken.container(.primary)) + .cornerRadius(12) + } + } + } +} + +private extension BottleAlertView { + var alertImage: some View { + BottleImageView(type: .local(bottleImageSystem: .icom(.warning))) + .foregroundStyle(to: ColorToken.icon(.primary)) + .padding(.top, .lg) + .padding(.bottom, .xs) + } + + @ViewBuilder + var messageView: some View { + if let data = presenting { + message(data) + .multilineTextAlignment(.center) + .padding(.bottom, .sm) + .lineSpacing(5) + } else { + EmptyView() + } + } + + @ViewBuilder + var actionsView: some View { + if let data = presenting { + actions(data) + .padding(.top, .md) + .padding(.bottom, .md) + } else { + EmptyView() + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButton.swift index 3720a565..b3fae586 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButton.swift @@ -95,6 +95,8 @@ extension SolidButton { EmptyView() case .generalSignIn: EmptyView() + case .cancel: + EmptyView() } } diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButtonStyle.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButtonStyle.swift index 3dd064e5..1b7268ce 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButtonStyle.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButtonStyle.swift @@ -12,6 +12,7 @@ public enum ButtonAppearanceType { case kakao case apple case generalSignIn + case cancel } struct SolidButtonStyle: ButtonStyle { @@ -91,9 +92,10 @@ private extension SolidButtonStyle { return ColorToken.container(.kakao).color case .apple: return ColorToken.container(.primary).color - case .generalSignIn: return Color.white + case .cancel: + return ColorToken.container(.disableSecondary).color } } @@ -115,6 +117,9 @@ private extension SolidButtonStyle { case .generalSignIn: return ColorToken.text(.primary).color + + case .cancel: + return ColorToken.text(.enablePrimary).color } } } diff --git a/Projects/Shared/DesignSystem/Sources/Components/List/ArrowListView.swift b/Projects/Shared/DesignSystem/Sources/Components/List/ArrowListView.swift new file mode 100644 index 00000000..71f8bd43 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/List/ArrowListView.swift @@ -0,0 +1,43 @@ +// +// ArrowListView.swift +// DesignSystemExample +// +// Created by 임현규 on 9/2/24. +// + +import SwiftUI + +public struct ArrowListView: View { + public let title: String + public let subTitle: String? + + public init( + title: String, + subTitle: String? = nil + ) { + self.title = title + self.subTitle = subTitle + } + + public var body: some View { + ListContainerView( + title: title, + subTitle: subTitle, + content: rightArrowImage + ) + } +} + +// MARK: - Views +private extension ArrowListView { + var rightArrowImage: some View { + BottleImageView( + type: .local( + bottleImageSystem: .icom(.right) + ) + ) + .foregroundStyle(to: ColorToken.icon(.primary)) + .frame(width: 24) + .frame(height: 24) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/List/ButtonListView.swift b/Projects/Shared/DesignSystem/Sources/Components/List/ButtonListView.swift new file mode 100644 index 00000000..848a99e4 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/List/ButtonListView.swift @@ -0,0 +1,55 @@ +// +// ButtonListView.swift +// SharedDesignSystem +// +// Created by 임현규 on 9/2/24. +// + +import SwiftUI + +public struct ButtonListView: View { + private let title: String + private let subTitle: String? + private let buttonTitle: String + private let isShowButton: Bool + private let action: () -> Void + + public init( + title: String, + subTitle: String? = nil, + buttonTitle: String, + isShowButton: Bool = true, + action: @escaping () -> Void + ) { + self.title = title + self.subTitle = subTitle + self.buttonTitle = buttonTitle + self.isShowButton = isShowButton + self.action = action + } + + public var body: some View { + ListContainerView( + title: title, + subTitle: subTitle, + content: button + ) + } +} + +// MARK: - Views +public extension ButtonListView { + @ViewBuilder + var button: some View { + if isShowButton { + OutlinedStyleButton( + .small(contentType: .text), + title: buttonTitle, + buttonType: .throttle, + action: action + ) + } else { + EmptyView() + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/List/ListContainerView.swift b/Projects/Shared/DesignSystem/Sources/Components/List/ListContainerView.swift new file mode 100644 index 00000000..38190de2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/List/ListContainerView.swift @@ -0,0 +1,59 @@ +// +// ListContainerView.swift +// SharedDesignSystem +// +// Created by 임현규 on 9/2/24. +// + +import SwiftUI + +struct ListContainerView: View { + private let title: String + private let subTitle: String? + private let content: Content + + init( + title: String, + subTitle: String? = nil, + content: Content + ) { + self.title = title + self.subTitle = subTitle + self.content = content + } + + var body: some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: .xs) { + titleView + subTitleView + } + Spacer() + content + } + } +} + +// MARK: - Views +private extension ListContainerView { + var titleView: some View { + WantedSansStyleText( + title, + style: .subTitle2, + color: .secondary + ) + } + + @ViewBuilder + var subTitleView: some View { + if let subTitle = subTitle { + WantedSansStyleText( + subTitle, + style: .caption, + color: .tertiary + ) + } else { + EmptyView() + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/List/TextListView.swift b/Projects/Shared/DesignSystem/Sources/Components/List/TextListView.swift new file mode 100644 index 00000000..d8acb4c8 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/List/TextListView.swift @@ -0,0 +1,29 @@ +// +// TextListView.swift +// SharedDesignSystem +// +// Created by 임현규 on 9/20/24. +// + +import SwiftUI + +public struct TextListView: View { + public let title: String + public let subTitle: String? + + public init( + title: String, + subTitle: String? = nil + ) { + self.title = title + self.subTitle = subTitle + } + + public var body: some View { + ListContainerView( + title: title, + subTitle: subTitle, + content: EmptyView() + ) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/List/ToggleListView.swift b/Projects/Shared/DesignSystem/Sources/Components/List/ToggleListView.swift new file mode 100644 index 00000000..ba285fe0 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/List/ToggleListView.swift @@ -0,0 +1,38 @@ +// +// ToggleListView.swift +// SharedDesignSystem +// +// Created by 임현규 on 9/2/24. +// + +import SwiftUI + +public struct ToggleListView: View { + private let title: String + private let subTitle: String? + @Binding private var isOn: Bool + + public init( + title: String, + subTitle: String? = nil, + isOn: Binding + ) { + self.title = title + self.subTitle = subTitle + self._isOn = isOn + } + + public var body: some View { + ListContainerView( + title: title, + subTitle: subTitle, + content: toggle) + } +} + +// MARK: - Views +private extension ToggleListView { + var toggle: some View { + BottleToggle(isOn: $isOn) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Toggle/BottleToggle.swift b/Projects/Shared/DesignSystem/Sources/Components/Toggle/BottleToggle.swift new file mode 100644 index 00000000..c5dd1bf8 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Toggle/BottleToggle.swift @@ -0,0 +1,45 @@ +// +// BottleToggle.swift +// SharedDesignSystem +// +// Created by 임현규 on 9/2/24. +// + +import SwiftUI + +public struct BottleToggle: View { + @Binding private var isOn: Bool + + public init(isOn: Binding) { + self._isOn = isOn + } + + public var body: some View { + Toggle("", isOn: $isOn) + .toggleStyle(BottleToggleStyle()) + } +} + +// MARK: - ToggleStyle +private struct BottleToggleStyle: ToggleStyle { + private let width: CGFloat = 44 + private let height: CGFloat = 26 + + func makeBody(configuration: Configuration) -> some View { + ZStack(alignment: configuration.isOn ? .trailing : .leading) { + RoundedRectangle(cornerRadius: 100) + .frame(width: width, height: height) + .foregroundStyle(to: configuration.isOn ? ColorToken.container(.pressed) : ColorToken.icon(.disabled)) + + RoundedRectangle(cornerRadius: width / 2) + .frame(width: (width / 2), height: (width / 2)) + .padding(2) + .foregroundStyle(to: ColorToken.container(.primary)) + .onTapGesture { + withAnimation { + configuration.$isOn.wrappedValue.toggle() + } + } + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Icon.swift b/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Icon.swift index 999d320f..6abc53c8 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Icon.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Icon.swift @@ -24,6 +24,7 @@ public extension Image.BottleImageSystem { case bottleStorage case myPage case appleLogo + case warning } } @@ -65,6 +66,9 @@ public extension Image.BottleImageSystem.Icon { case .appleLogo: return SharedDesignSystemAsset.Images.iconAppleLogo.swiftUIImage + + case .warning: + return SharedDesignSystemAsset.Images.iconWarning.swiftUIImage } } } diff --git a/Projects/Shared/DesignSystemThirdPartyLib/Project.swift b/Projects/Shared/DesignSystemThirdPartyLib/Project.swift index f896a148..21d0701b 100644 --- a/Projects/Shared/DesignSystemThirdPartyLib/Project.swift +++ b/Projects/Shared/DesignSystemThirdPartyLib/Project.swift @@ -10,7 +10,8 @@ let project = Project.makeModule( factory: .init( dependencies: [ .SPM.Kingfisher, - .SPM.Lottie + .SPM.Lottie, + .SPM.ComposableArchitecture ] ) ), diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift index d1f8d7f0..3e24b704 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift @@ -10,8 +10,8 @@ import ProjectDescription public extension InfoPlist { static var app: InfoPlist { return .extendingDefault(with: [ - "CFBundleShortVersionString": "1.0.7", - "CFBundleVersion": "27", + "CFBundleShortVersionString": "1.0.8", + "CFBundleVersion": "30", "UIUserInterfaceStyle": "Light", "CFBundleName": "보틀", "UILaunchScreen": [ @@ -22,10 +22,13 @@ public extension InfoPlist { "UISupportedInterfaceOrientations": [ "UIInterfaceOrientationPortrait" ], + "NSContactsUsageDescription": "매칭 차단 기능을 위해 연락처가 필요합니다.", "BASE_URL": "$(BASE_URL)", "WEB_VIEW_BASE_URL": "$(WEB_VIEW_BASE_URL)", "WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME": "$(WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME)", "APP_STORE_URL": "$(APP_STORE_URL)", + "APP_LOOK_UP_URL": "$(APP_LOOK_UP_URL)", + "KAKAO_CHANNEL_TALK_URL": "$(KAKAO_CHANNEL_TALK_URL)", "LSApplicationQueriesSchemes": ["kakaokompassauth", "kakaotalk"], "CFBundleURLTypes": [ [ @@ -40,17 +43,20 @@ public extension InfoPlist { static var example: InfoPlist { return .extendingDefault(with: [ - "CFBundleShortVersionString": "1.0.7", - "CFBundleVersion": "27", + "CFBundleShortVersionString": "1.0.8", + "CFBundleVersion": "30", "UIUserInterfaceStyle": "Light", "UILaunchScreen": [:], "UISupportedInterfaceOrientations": [ "UIInterfaceOrientationPortrait" ], + "NSContactsUsageDescription": "매칭 차단 기능을 위해 연락처가 필요합니다.", "BASE_URL": "$(BASE_URL)", "WEB_VIEW_BASE_URL": "$(WEB_VIEW_BASE_URL)", "WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME": "$(WEB_VIEW_MESSAGE_HANDLER_DEFAULT_NAME)", "APP_STORE_URL": "$(APP_STORE_URL)", + "KAKAO_CHANNEL_TALK_URL": "$(KAKAO_CHANNEL_TALK_URL)", + "APP_LOOK_UP_URL": "$(APP_LOOK_UP_URL)", "LSApplicationQueriesSchemes": ["kakaokompassauth", "kakaotalk"], "CFBundleURLTypes": [ [