diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 00000000..1efa616f --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,58 @@ +name: Bottles Pull Request Workflow + +on: + pull_request: + branches: + - develop + types: [ opened, reopened, synchronize ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Send Slack Notification + if: github.event.action == 'opened' || github.event.action == 'reopened' + env: + DATA: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*iOS PR* :bell: <@U07LESGBQEP> <@U07LHEEU2BW>" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Author:*\n" + }, + { + "type": "mrkdwn", + "text": "*Title:*\n${{ github.event.pull_request.title }}" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Description:*\n${{ github.event.pull_request.body }}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Pull Request URL:*\n<${{ github.event.pull_request.html_url }}|View PR>" + } + } + ] + } + run: | + curl -X POST -H 'Content-type: application/json' \ + -d "$DATA" \ + ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift index 218eada7..a6d4af0e 100644 --- a/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift +++ b/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Modules.swift @@ -27,6 +27,7 @@ public extension ModulePath { // MARK: - FeatureModule public extension ModulePath { enum Feature: String, CaseIterable { + case GoodFeeling case Guide case TabBar case Report @@ -48,6 +49,7 @@ public extension ModulePath { public extension ModulePath { enum Domain: String, CaseIterable { + case Application case Error case User case Report @@ -64,6 +66,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/Logger/Interface/Sources/Log.swift b/Projects/Core/Logger/Interface/Sources/Log.swift index 6faacd2c..a06b00db 100644 --- a/Projects/Core/Logger/Interface/Sources/Log.swift +++ b/Projects/Core/Logger/Interface/Sources/Log.swift @@ -5,7 +5,7 @@ // Created by μž„ν˜„κ·œ on 7/23/24. // -import Foundation +import UIKit import OSLog public enum Log { @@ -132,6 +132,79 @@ public extension Log { let logMessage = "\(message ?? "")" log(message: logMessage, level: level, fileName: fileName, line: line, funcName: funcName) assertionFailure(logMessage) +#if !DEBUG + Task { + guard let url = URL(string: Bundle.main.infoDictionary?["SLACK_WEBHOOK_URL"] as? String ?? "") + else { + return + } + guard let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String + else { + return + } + async let device = UIDevice.current + let systemVersion = await device.systemVersion + let deviceName = await UIDevice.current.name + + let errorLogJsonObject: [String: Any] = [ + "pretext": "iOS Error OccuredπŸ”₯", + "color": "#36a64f", + "fields": [ + [ + "title": "Message", + "value": "\(message ?? "no message")", + "short": true + ], + [ + "title": "File Name", + "value": "\(fileName)", + "short": true + ], + [ + "title": "Function Name", + "value": "\(funcName)", + "short": true + ], + [ + "title": "Line", + "value": "\(line)", + "short": true + ], + [ + "title": "Version", + "value": "\(appVersion)", + "short": true + ], + [ + "title": "Build Number", + "value": "\(buildNumber)", + "short": true + ], + [ + "title": "Device Name", + "value": "\(deviceName)", + "short": true + ], + [ + "title": "iOS Version", + "value": "\(systemVersion)", + "short": true + ] + ] + ] + do { + let errorLogJsonData = try JSONSerialization.data(withJSONObject: errorLogJsonObject) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = errorLogJsonData + _ = try await URLSession.shared.data(for: request) + } catch { + Log.debug(error) + } + } +#endif } static func fatal(message: Any?, level: Level = .fault, fileName: String = #fileID, line: Int = #line, funcName: StaticString = #function) { 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/Network/Sources/Interceptor/TokenInterceptor.swift b/Projects/Core/Network/Sources/Interceptor/TokenInterceptor.swift index ed6f5c1a..9ea6784e 100644 --- a/Projects/Core/Network/Sources/Interceptor/TokenInterceptor.swift +++ b/Projects/Core/Network/Sources/Interceptor/TokenInterceptor.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit import CoreKeyChainStore import CoreLoggerInterface @@ -30,7 +31,32 @@ public class TokenInterceptor: RequestInterceptor { urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } + var deviceName: String { + if let simulatorModelIdentifier = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] { + return simulatorModelIdentifier + } else { + var systemInfo = utsname() + uname(&systemInfo) + let modelIdentifier = withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { ptr in + String(validatingUTF8: ptr) + } + } + return modelIdentifier ?? "" + } + } + let appVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "" + let deviceID = UIDevice.current.identifierForVendor?.uuidString ?? "" + var osVersion = UIDevice.current.systemVersion + + urlRequest.setValue(appVersion, forHTTPHeaderField: "X-App-Version") + urlRequest.setValue(deviceName, forHTTPHeaderField: "X-Device-Model") + urlRequest.setValue(osVersion, forHTTPHeaderField: "X-OS-Version") + urlRequest.setValue(deviceID, forHTTPHeaderField: "X-Device-ID") + urlRequest.setValue("iOS", forHTTPHeaderField: "X-App-Platform") + print(urlRequest.headers) + completion(.success(urlRequest)) } diff --git a/Projects/Core/Network/Sources/MoyaPulgins/MoyaLoggerPlugin.swift b/Projects/Core/Network/Sources/MoyaPulgins/MoyaLoggerPlugin.swift index ddec0549..ae54590f 100644 --- a/Projects/Core/Network/Sources/MoyaPulgins/MoyaLoggerPlugin.swift +++ b/Projects/Core/Network/Sources/MoyaPulgins/MoyaLoggerPlugin.swift @@ -33,7 +33,7 @@ final class MoyaLoggerPlugin: PluginType { log += "header: \(headers)\n" } if let body = httpRequest.httpBody, let bodyString = String(bytes: body, encoding: String.Encoding.utf8) { - log += "bodyString: \(bodyString)" + log += "bodyString: \(bodyString)\n" } log += "---------------------------------------------" @@ -79,7 +79,7 @@ final class MoyaLoggerPlugin: PluginType { if let data = error.response?.data { log += "Data - \(data)\n" } else { - log += "Data - empty" + log += "Data - empty\n" } log += "---------------------------------------------" 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/Core/WebView/Interface/Sources/BottleWebViewAction.swift b/Projects/Core/WebView/Interface/Sources/BottleWebViewAction.swift index 0eec0adc..33de7944 100644 --- a/Projects/Core/WebView/Interface/Sources/BottleWebViewAction.swift +++ b/Projects/Core/WebView/Interface/Sources/BottleWebViewAction.swift @@ -24,7 +24,7 @@ public enum BottleWebViewAction: Equatable { /// νšŒμ›κ°€μž… 성곡 콜백 case signUpDidComplted(accessToken: String, refreshToken: String) /// μ™ΈλΆ€ 링크 이동 - case openLink(href: String) + case openLink(url: String) // MARK: - LogIn @@ -47,13 +47,19 @@ public enum BottleWebViewAction: Equatable { case logOutButtonDidTapped /// νšŒμ›νƒˆν‡΄ case withdrawalButtonDidTap + /// ν”„λ‘œν•„ 사진 μˆ˜μ • μ™„λ£Œ + case profileImageDidChanged + + // MARK: - Introduction Setup + /// μžκΈ°μ†Œκ°œ & ν”„λ‘œν•„ 사진 등둝 μ™„λ£Œ + case introductionDidCompleted public init?( type: String, message: String? = nil, accessToken: String? = nil, refreshToken: String? = nil, - href: String? = nil, + url: String? = nil, isCompletedOnboardingIntroduction: Bool? = nil ) { switch type { @@ -94,14 +100,14 @@ public enum BottleWebViewAction: Equatable { refreshToken: refreshToken ) case "openLink": - guard let href + guard let url else { Log.assertion( - message: "openLink: \(String(describing: href))" + message: "openLink: \(String(describing: url))" ) return nil } - self = .openLink(href: href) + self = .openLink(url: url) @@ -142,6 +148,14 @@ public enum BottleWebViewAction: Equatable { case "deleteUser": self = .withdrawalButtonDidTap + case "onProfileImageEditComplete": + self = .profileImageDidChanged + + // MARK: - Introduction Setup + + case "onIntroductionComplete": + self = .introductionDidCompleted + default: return nil } 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/Bottle/Interface/Sources/API/BottleAPI.swift b/Projects/Domain/Bottle/Interface/Sources/API/BottleAPI.swift index 868ebb7b..2c684145 100644 --- a/Projects/Domain/Bottle/Interface/Sources/API/BottleAPI.swift +++ b/Projects/Domain/Bottle/Interface/Sources/API/BottleAPI.swift @@ -35,7 +35,7 @@ extension BottleAPI: BaseTargetType { case .fetchBottles: return "api/v1/bottles" case .fetchBottleStorageList: - return "api/v1/bottles/ping-pong" + return "api/v2/bottles/ping-pong" case let .fetchBottlePingPong(bottleID): return "api/v1/bottles/ping-pong/\(bottleID)" case let .readBottle(bottleID): diff --git a/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottlePingPongResponseDTO.swift b/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottlePingPongResponseDTO.swift index bc27ab18..75c31058 100644 --- a/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottlePingPongResponseDTO.swift +++ b/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottlePingPongResponseDTO.swift @@ -98,8 +98,7 @@ public struct BottlePingPongResponseDTO: Decodable { public struct PhotoDTO: Decodable { let photoStatus: String? - let myImageUrl: String? - let otherImageUrl: String? + let otherImageUrls: [String]? public func toDomain() -> Photo { let photoStatus: PingPongPhotoStatus = switch photoStatus { @@ -122,8 +121,7 @@ public struct BottlePingPongResponseDTO: Decodable { } return .init( photoStatus: photoStatus, - myProfileImageURL: myImageUrl, - otherProfileImageURL: otherImageUrl + otherProfileImageURLs: otherImageUrls ) } } diff --git a/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottleStorageListResponseDTO.swift b/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottleStorageListResponseDTO.swift index 3a397c74..eb18dbec 100644 --- a/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottleStorageListResponseDTO.swift +++ b/Projects/Domain/Bottle/Interface/Sources/DTO/Response/BottleStorageListResponseDTO.swift @@ -10,8 +10,7 @@ import Foundation // MARK: - Bottle Storage List public struct BottleStorageListResponseDTO: Decodable { - let activeBottles: [BottleStorageItemResponseDTO]? - let doneBottles: [BottleStorageItemResponseDTO]? + let pingPongBottles: [BottleStorageItemResponseDTO]? public struct BottleStorageItemResponseDTO: Decodable { let age: Int? @@ -21,6 +20,8 @@ public struct BottleStorageListResponseDTO: Decodable { let mbti: String? let userImageUrl: String? let userName: String? + let lastActivatedAt: String? + let lastStatus: String? public func toDomain() -> BottleStorageItem { return BottleStorageItem( @@ -30,15 +31,15 @@ public struct BottleStorageListResponseDTO: Decodable { keyword: keyword ?? [], mbti: mbti ?? "", userImageUrl: userImageUrl ?? "", - userName: userName ?? "" + userName: userName ?? "", + lastActivatedAt: lastActivatedAt ?? "", + lastStatus: PingPongLastStatus(rawValue: lastStatus ?? "") ) } } public func toDomain() -> BottleStorageList { return BottleStorageList( - activeBottles: activeBottles?.map { $0.toDomain() } ?? [], - doneBottles: doneBottles?.map { $0.toDomain() } ?? [] - ) + pingPongBottles: pingPongBottles?.map { $0.toDomain() } ?? []) } } diff --git a/Projects/Domain/Bottle/Interface/Sources/Entity/BottlePingPong.swift b/Projects/Domain/Bottle/Interface/Sources/Entity/BottlePingPong.swift index 5eab45b4..512c61b6 100644 --- a/Projects/Domain/Bottle/Interface/Sources/Entity/BottlePingPong.swift +++ b/Projects/Domain/Bottle/Interface/Sources/Entity/BottlePingPong.swift @@ -110,17 +110,14 @@ public enum PingPongMatchStatus { public struct Photo: Equatable { public let photoStatus: PingPongPhotoStatus - public let myProfileImageURL: String? - public let otherProfileImageURL: String? + public let otherProfileImageURLs: [String]? public init( photoStatus: PingPongPhotoStatus, - myProfileImageURL: String? = nil, - otherProfileImageURL: String? = nil + otherProfileImageURLs: [String]? = nil ) { self.photoStatus = photoStatus - self.myProfileImageURL = myProfileImageURL - self.otherProfileImageURL = otherProfileImageURL + self.otherProfileImageURLs = otherProfileImageURLs } } diff --git a/Projects/Domain/Bottle/Interface/Sources/Entity/BottleStorageList.swift b/Projects/Domain/Bottle/Interface/Sources/Entity/BottleStorageList.swift index a8d52ae5..54d7eff4 100644 --- a/Projects/Domain/Bottle/Interface/Sources/Entity/BottleStorageList.swift +++ b/Projects/Domain/Bottle/Interface/Sources/Entity/BottleStorageList.swift @@ -6,17 +6,13 @@ // public struct BottleStorageList: Decodable { - public let activeBottles: [BottleStorageItem] - public let doneBottles: [BottleStorageItem] + public let pingPongBottles: [BottleStorageItem] public init( - activeBottles: [BottleStorageItem], - doneBottles: [BottleStorageItem] + pingPongBottles: [BottleStorageItem] ) { - self.activeBottles = activeBottles - self.doneBottles = doneBottles + self.pingPongBottles = pingPongBottles } - } public struct BottleStorageItem: Decodable, Equatable { @@ -27,6 +23,8 @@ public struct BottleStorageItem: Decodable, Equatable { public let mbti: String public let userImageUrl: String public let userName: String? + public let lastActivatedAt: String? + public let lastStatus: PingPongLastStatus? public init( age: Int?, @@ -35,7 +33,9 @@ public struct BottleStorageItem: Decodable, Equatable { keyword: [String], mbti: String, userImageUrl: String, - userName: String? + userName: String?, + lastActivatedAt: String?, + lastStatus: PingPongLastStatus? ) { self.age = age self.id = id @@ -44,5 +44,26 @@ public struct BottleStorageItem: Decodable, Equatable { self.mbti = mbti self.userImageUrl = userImageUrl self.userName = userName + self.lastActivatedAt = lastActivatedAt + self.lastStatus = lastStatus } } + +public enum PingPongLastStatus: String, Decodable { + /// λŒ€ν™”λŠ” μ‹œμž‘ν–ˆμœΌλ‚˜ 두 μ‚¬λžŒ λͺ¨λ‘ 문닡을 μž‘μ„±ν•˜μ§€ μ•Šμ•˜μ„ λ•Œ + case noAnswerFromBoth = "NO_ANSWER_FROM_BOTH" + /// μƒλŒ€λ°©μ΄ μƒˆλ‘œμš΄ 문닡을 μž‘μ„±ν–ˆμ„ λ•Œ + case answerFromOther = "ANSWER_FROM_OTHER" + /// μƒλŒ€λ°©μ΄ 사진을 κ³΅μœ ν–ˆμ„ λ•Œ + case photoSharedByOther = "PHOTO_SHARED_BY_OTHER" + /// μƒλŒ€λ°©μ΄ μ—°λ½μ²˜λ₯Ό κ³΅μœ ν–ˆμ„ λ•Œ + case contactSharedByOther = "CONTACT_SHARED_BY_OTHER" + /// λ‚΄κ°€ 문닡을 μž‘μ„±ν–ˆμ„ λ•Œ (μƒλŒ€λ°©μ€ μž‘μ„±X) + case answerFromMeOnly = "ANSWER_FROM_ME_ONLY" + /// λ‚΄κ°€ 사진을 κ³΅μœ ν–ˆμ„ λ•Œ (μƒλŒ€λ°©μ€ 곡유X) + case photoSharedByMeOnly = "PHOTO_SHARED_BY_ME_ONLY" + /// λ‚΄κ°€ μ—°λ½μ²˜λ₯Ό κ³΅μœ ν–ˆμ„ λ•Œ (μƒλŒ€λ°©μ€ 곡유X) + case contactSharedByMeOnly = "CONTACT_SHARED_BY_ME_ONLY" + /// λŒ€ν™”κ°€ 쀑단됐을 λ•Œ + case conversationStopped = "CONVERSATION_STOPPED" +} 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..30999f43 --- /dev/null +++ b/Projects/Domain/User/Interface/Sources/API/UserAPI.swift @@ -0,0 +1,60 @@ +// +// 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) + case updatePushNotificationAllowStatus(requestDTO: UpdatePushNotificationAllowStatusRequestDTO) +} + +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" + case .updatePushNotificationAllowStatus: + return "api/v1/user/native-setting" + } + } + + public var method: Moya.Method { + switch self { + case .fetchAlertState: + return .get + case .updateAlertState: + return .post + case .updateBlockContacts: + return .post + case .updatePushNotificationAllowStatus: + 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) + case let .updatePushNotificationAllowStatus(requestDTO): + return .requestJSONEncodable(requestDTO) + } + } +} 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/Request/UpdatePushNotificationAllowStatusRequestDTO.swift b/Projects/Domain/User/Interface/Sources/DTO/Request/UpdatePushNotificationAllowStatusRequestDTO.swift new file mode 100644 index 00000000..bc5071a5 --- /dev/null +++ b/Projects/Domain/User/Interface/Sources/DTO/Request/UpdatePushNotificationAllowStatusRequestDTO.swift @@ -0,0 +1,27 @@ +// +// UpdatePushNotificationAllowStatusRequestDTO.swift +// DomainUserInterface +// +// Created by JongHoon on 11/3/24. +// + +import Foundation + +public struct UpdatePushNotificationAllowStatusRequestDTO: Encodable { + public let alimyTurnedOn: Bool + public let deviceName: String + public let appVersion: String + public let deviceId: String + + public init( + turnOn: Bool, + deviceName: String, + appVersion: String, + deviceId: String + ) { + self.alimyTurnedOn = turnOn + self.deviceName = deviceName + self.appVersion = appVersion + self.deviceId = deviceId + } +} 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/NeedUpdatePushNotificationAllowStatusRemotelyType.swift b/Projects/Domain/User/Interface/Sources/NeedUpdatePushNotificationAllowStatusRemotelyType.swift new file mode 100644 index 00000000..27acfeec --- /dev/null +++ b/Projects/Domain/User/Interface/Sources/NeedUpdatePushNotificationAllowStatusRemotelyType.swift @@ -0,0 +1,13 @@ +// +// NeedUpdatePushNotificationAllowStatusRemotelyType.swift +// DomainUserInterface +// +// Created by JongHoon on 11/2/24. +// + +import Foundation + +public enum NeedUpdatePushNotificationAllowStatusRemotelyType { + case notNeed + case need(isAllow: Bool) +} diff --git a/Projects/Domain/User/Interface/Sources/UserClient.swift b/Projects/Domain/User/Interface/Sources/UserClient.swift index dadc0abd..439466b1 100644 --- a/Projects/Domain/User/Interface/Sources/UserClient.swift +++ b/Projects/Domain/User/Interface/Sources/UserClient.swift @@ -7,28 +7,71 @@ import Foundation +import Combine + public struct UserClient { private let _isLoggedIn: () -> Bool private let _isAppDeleted: () -> Bool + private let _isCoachMarkViewed: () -> Bool private let _fetchFcmToken: () -> String? + var _remotelyUploadedPushNotificationAllowStatus: () -> Bool? private let updateLoginState: (Bool) -> Void private let updateDeleteState: (Bool) -> Void + private let updateCoachMarkState: (Bool) -> Void private let updateFcmToken: (String) -> Void + private let updatePushNotificationAllowStatusLocally: (Bool) -> Void + private let updatePushNotificationAllowStatusRemotely: (Bool) async throws -> Void + private let updateRemotelyUploadedPushNotificationAllowStatus: (Bool) -> Void + private let _isNeedUpdatePushNotificationRemotely: () async -> NeedUpdatePushNotificationAllowStatusRemotelyType + private let _fetchAlertState: () async throws -> [UserAlertState] + private let _fetchPushNotificationAllowStatusLocally: () -> 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, isAppDeleted: @escaping () -> Bool, + isCoachMarkViewed: @escaping () -> Bool, fetchFcmToken: @escaping () -> String?, + remotelyUploadedPushNotificationAllowStatus: @escaping () -> Bool?, updateLoginState: @escaping (Bool) -> Void, updateDeleteState: @escaping (Bool) -> Void, - updateFcmToken: @escaping (String) -> Void + updateFcmToken: @escaping (String) -> Void, + updatePushNotificationAllowStatusLocally: @escaping (Bool) -> Void, + updatePushNotificationAllowStatusRemotely: @escaping (Bool) async throws -> Void, + updateRemotelyUploadedPushNotificationAllowStatus: @escaping (Bool) -> Void, + isNeedUpdatePushNotificationRemotely: @escaping () async -> NeedUpdatePushNotificationAllowStatusRemotelyType, + updateCoachMarkState: @escaping (Bool) -> Void, + fetchAlertState: @escaping () async throws -> [UserAlertState], + fetchPushNotificationAllowStatusLocally: @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 + self._isCoachMarkViewed = isCoachMarkViewed self._fetchFcmToken = fetchFcmToken + self._remotelyUploadedPushNotificationAllowStatus = remotelyUploadedPushNotificationAllowStatus self.updateLoginState = updateLoginState self.updateDeleteState = updateDeleteState self.updateFcmToken = updateFcmToken + self.updatePushNotificationAllowStatusLocally = updatePushNotificationAllowStatusLocally + self.updatePushNotificationAllowStatusRemotely = updatePushNotificationAllowStatusRemotely + self.updateRemotelyUploadedPushNotificationAllowStatus = updateRemotelyUploadedPushNotificationAllowStatus + self._isNeedUpdatePushNotificationRemotely = isNeedUpdatePushNotificationRemotely + self.updateCoachMarkState = updateCoachMarkState + self._fetchAlertState = fetchAlertState + self._fetchPushNotificationAllowStatusLocally = fetchPushNotificationAllowStatusLocally + self.updateAlertState = updateAlertState + self.fetchContacts = fetchContacts + self.updateBlockContacts = updateBlockContacts } public func isLoggedIn() -> Bool { @@ -39,10 +82,18 @@ public struct UserClient { _isAppDeleted() } + public func isCoachMarkViewd() -> Bool { + _isCoachMarkViewed() + } + public func fetchFcmToken() -> String? { _fetchFcmToken() } + public func remotelyUploadedPushNotificationAllowStatus() -> Bool? { + _remotelyUploadedPushNotificationAllowStatus() + } + public func updateLoginState(isLoggedIn: Bool) { updateLoginState(isLoggedIn) } @@ -50,8 +101,48 @@ public struct UserClient { public func updateDeleteState(isDelete: Bool) { updateDeleteState(isDelete) } - + + public func updateCoachMarkState(isViewed: Bool) { + updateCoachMarkState(isViewed) + } public func updateFcmToken(fcmToken: String) { updateFcmToken(fcmToken) } + + public func updatePushNotificationAllowStatusLocally(isAllow: Bool) { + pushNotificationAllowStatusSubject.send(isAllow) + updatePushNotificationAllowStatusLocally(isAllow) + } + + public func updatePushNotificationAllowStatusRemotely(isAllow: Bool) async throws { + try await updatePushNotificationAllowStatusRemotely(isAllow) + } + + public func updateRemotelyUploadedPushNotificationAllowStatus(isAllow: Bool) { + updateRemotelyUploadedPushNotificationAllowStatus(isAllow) + } + + public func isNeedUpdatePushNotificationRemotely() async -> NeedUpdatePushNotificationAllowStatusRemotelyType { + await _isNeedUpdatePushNotificationRemotely() + } + + public func fetchAlertState() async throws -> [UserAlertState] { + try await _fetchAlertState() + } + + public func fetchPushNotificationAllowStatusLocally() -> Bool { + _fetchPushNotificationAllowStatusLocally() + } + + 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..b1baab84 100644 --- a/Projects/Domain/User/Sources/UserClient.swift +++ b/Projects/Domain/User/Sources/UserClient.swift @@ -5,41 +5,208 @@ // Created by μž„ν˜„κ·œ on 8/22/24. // +import UIKit import Foundation +import UserNotifications +import Contacts import DomainUserInterface import CoreKeyChainStore +import CoreNetwork +import CoreLoggerInterface +import SharedUtilInterface import ComposableArchitecture +import Moya extension UserClient: DependencyKey { + private enum UserDefaultsKeys: String { + case loginState + case deleteState + case fcmToken + case alertAllowState + case remotelyUploadedPushNotificationAllowStatus + case coachMarkState + } + 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) + }, + + isCoachMarkViewed: { + return UserDefaults.standard.bool(forKey: UserDefaultsKeys.coachMarkState.rawValue) }, fetchFcmToken: { - return UserDefaults.standard.string(forKey: "fcmToken") + return UserDefaults.standard.string(forKey: UserDefaultsKeys.fcmToken.rawValue) + }, + + remotelyUploadedPushNotificationAllowStatus: { + let status = UserDefaults.standard.object(forKey: UserDefaultsKeys.remotelyUploadedPushNotificationAllowStatus.rawValue) + guard let status = status as? Bool + else { + return nil + } + + return status }, 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) + }, + + updatePushNotificationAllowStatusLocally: { isAllow in + UserDefaults.standard.set(isAllow, forKey: UserDefaultsKeys.alertAllowState.rawValue) + }, + + updatePushNotificationAllowStatusRemotely: { isAllow in + @Dependency(\.userClient) var userClient + + var deviceName: String? { + if let simulatorModelIdentifier = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] { + return simulatorModelIdentifier + } else { + var systemInfo = utsname() + uname(&systemInfo) + let modelIdentifier = withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { ptr in + String(validatingUTF8: ptr) + } + } + return modelIdentifier + } + } + + let requestDTO = await UpdatePushNotificationAllowStatusRequestDTO( + turnOn: isAllow, + deviceName: deviceName ?? "", + appVersion: (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "", + deviceId: UIDevice.current.identifierForVendor?.uuidString ?? "" + ) + + try await networkManager.reqeust(api: .apiType(UserAPI.updatePushNotificationAllowStatus(requestDTO: requestDTO))) + userClient.updateRemotelyUploadedPushNotificationAllowStatus(isAllow: isAllow) + }, + + updateRemotelyUploadedPushNotificationAllowStatus: { isAllow in + UserDefaults.standard.set(isAllow, forKey: UserDefaultsKeys.remotelyUploadedPushNotificationAllowStatus.rawValue) + }, + + isNeedUpdatePushNotificationRemotely: { + @Dependency(\.userClient) var userClient + + guard userClient.isLoggedIn() + else { + return .notNeed + } + + let remotelyUploadedStatus = userClient.remotelyUploadedPushNotificationAllowStatus() + let isAuthorized = await withCheckedContinuation { continuation in + UNUserNotificationCenter.current().getNotificationSettings { settings in + switch settings.authorizationStatus { + case .notDetermined: + continuation.resume(returning: false) + + case .denied: + continuation.resume(returning: false) + + case .authorized: + continuation.resume(returning: true) + + case .provisional: + continuation.resume(returning: false) + + case .ephemeral: + continuation.resume(returning: false) + + @unknown default: + continuation.resume(returning: false) + Log.assertion(message: "not handled status") + } + } + } + + let isNeedType: NeedUpdatePushNotificationAllowStatusRemotelyType = switch remotelyUploadedStatus { + case .none: + .need(isAllow: isAuthorized) + + case let .some(localAllowStatus): + (localAllowStatus == isAuthorized) ? .notNeed : .need(isAllow: isAuthorized) + } + + return isNeedType + }, + + updateCoachMarkState: { isViewed in + UserDefaults.standard.set(isViewed, forKey: UserDefaultsKeys.coachMarkState.rawValue) + }, + + fetchAlertState: { + let responseData = try await networkManager.reqeust(api: .apiType(UserAPI.fetchAlertState), dto: [AlertStateResponseDTO].self) + return responseData.map { $0.toDomain() } + }, + + fetchPushNotificationAllowStatusLocally: { + 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/Domain/WebView/Sources/WebViewClient.swift b/Projects/Domain/WebView/Sources/WebViewClient.swift index afa1bede..f9390b9d 100644 --- a/Projects/Domain/WebView/Sources/WebViewClient.swift +++ b/Projects/Domain/WebView/Sources/WebViewClient.swift @@ -35,7 +35,7 @@ extension WebViewClient: DependencyKey { message: dict["message"] as? String ?? "", accessToken: dict["accessToken"] as? String ?? "", refreshToken: dict["refreshToken"] as? String ?? "", - href: dict["href"] as? String ?? "", + url: dict["url"] as? String ?? "", isCompletedOnboardingIntroduction: dict["hasCompleteIntroduction"] as? Bool ?? false ) else { 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..366169b0 100644 --- a/Projects/Feature/BaseWebView/Interface/Sources/BaseWebViewType.swift +++ b/Projects/Feature/BaseWebView/Interface/Sources/BaseWebViewType.swift @@ -7,37 +7,76 @@ import Foundation +import DomainApplicationInterface +import DomainApplication + import CoreWebViewInterface import CoreKeyChainStoreInterface import CoreKeyChainStore +import CoreLoggerInterface + +import Dependencies -public enum BottleWebViewType: String { +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 bottleArrival + case editProfile + case goodFeeling + case introductionSetup + case openURL(url: String) + + var path: String { + switch self { + case .createProfile: + return "profile/create" + case .signUp: + return "signup" + case .login: + return "login" + case .bottleArrival: + return "bottles/recommendations" + case .editProfile: + return "profile/edit" + case .goodFeeling: + return "bottles/sents" + case .introductionSetup: + return "/intro/create" + case .openURL: + return "" + } + } 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 .bottleArrival: + return makeUrlWithToken(path) + + case .editProfile: + return makeUrlWithToken(path) - case .bottles: - return makeUrlWithToken(rawValue) + case .goodFeeling: + return makeUrlWithToken(path) + + case .introductionSetup: + return makeUrlWithToken(path) + + case let .openURL(url): + return URL(string: url)! } } @@ -52,11 +91,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/BottleArrivalFeature.swift b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrival/BottleArrivalFeature.swift similarity index 88% rename from Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalFeature.swift rename to Projects/Feature/BottleArrival/Interface/Sources/BottleArrival/BottleArrivalFeature.swift index 75f50fea..187704d9 100644 --- a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalFeature.swift +++ b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrival/BottleArrivalFeature.swift @@ -23,8 +23,8 @@ extension BottleArrivalFeature { state.isLoading = false return .none - case .bottelDidAccepted: - return .send(.delegate(.bottelDidAccepted)) + case let .arrivalBottleTapped(url): + return .send(.delegate(.arrivalBottleTapped(url: url))) case .closeWebView: return .send(.delegate(.closeWebView)) diff --git a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalFeatureInterface.swift b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrival/BottleArrivalFeatureInterface.swift similarity index 90% rename from Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalFeatureInterface.swift rename to Projects/Feature/BottleArrival/Interface/Sources/BottleArrival/BottleArrivalFeatureInterface.swift index 01a1decd..38b0aead 100644 --- a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalFeatureInterface.swift +++ b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrival/BottleArrivalFeatureInterface.swift @@ -29,7 +29,7 @@ public struct BottleArrivalFeature { // View Life Cycle case onAppear case webViewLoadingDidCompleted - case bottelDidAccepted + case arrivalBottleTapped(url: String) case closeWebView case presentToastDidRequired(message: String) // Delegate @@ -38,6 +38,7 @@ public struct BottleArrivalFeature { public enum Delegate { case bottelDidAccepted case closeWebView + case arrivalBottleTapped(url: String) } } diff --git a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalView.swift b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrival/BottleArrivalView.swift similarity index 83% rename from Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalView.swift rename to Projects/Feature/BottleArrival/Interface/Sources/BottleArrival/BottleArrivalView.swift index 18e02dc9..3b2253ff 100644 --- a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalView.swift +++ b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrival/BottleArrivalView.swift @@ -22,13 +22,12 @@ public struct BottleArrivalView: View { public var body: some View { WithPerceptionTracking { - BaseWebView( - type: .bottles) { action in + BaseWebView(type: .bottleArrival) { action in switch action { case .webViewLoadingDidCompleted: store.send(.webViewLoadingDidCompleted) - case .bottelDidAccepted: - store.send(.bottelDidAccepted) + case let .openLink(url): + store.send(.arrivalBottleTapped(url: url)) case .closeWebView: store.send(.closeWebView) case let .showTaost(message): @@ -43,7 +42,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/BottleArrival/Interface/Sources/BottleArrivalDetail/BottleArrivalDetailFeature.swift b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalDetail/BottleArrivalDetailFeature.swift new file mode 100644 index 00000000..122d3670 --- /dev/null +++ b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalDetail/BottleArrivalDetailFeature.swift @@ -0,0 +1,47 @@ +// +// BottleArrivalDetailFeature.swift +// FeatureBottleArrival +// +// Created by JongHoon on 10/9/24. +// + +import Foundation + +import ComposableArchitecture + +@Reducer +public struct BottleArrivalDetailFeature { + private let reducer: Reduce + + public init(reducer: Reduce) { + self.reducer = reducer + } + + @ObservableState + public struct State: Equatable { + let bottleArrivalURL: String + + public init(bottleArrivalURL: String) { + self.bottleArrivalURL = bottleArrivalURL + } + } + + public enum Action: BindableAction { + case backButtonDidTapped + case bottelDidAccepted + case showToast(message: String) + + case delegate(Delegate) + public enum Delegate { + case backButtonDidTapped + } + + case binding(BindingAction) + } + + public var body: some ReducerOf { + BindingReducer() + + reducer + } +} diff --git a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalDetail/BottleArrivalDetailFeatureInterface.swift b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalDetail/BottleArrivalDetailFeatureInterface.swift new file mode 100644 index 00000000..e1e98b82 --- /dev/null +++ b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalDetail/BottleArrivalDetailFeatureInterface.swift @@ -0,0 +1,35 @@ +// +// BottleArrivalDetailFeatureInterface.swift +// FeatureBottleArrival +// +// Created by JongHoon on 10/9/24. +// + +import Foundation + +import CoreToastInterface + +import ComposableArchitecture + +extension BottleArrivalDetailFeature { + public init() { + @Dependency(\.toastClient) var toastClient + + let reducer = Reduce { state, action in + switch action { + case .backButtonDidTapped: + return .send(.delegate(.backButtonDidTapped)) + + case let .showToast(message): + toastClient.presentToast(message: message) + return .none + + default: + return .none + } + } + + self.init(reducer: reducer) + } +} + diff --git a/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalDetail/BottleArrivalDetailView.swift b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalDetail/BottleArrivalDetailView.swift new file mode 100644 index 00000000..eb9f202c --- /dev/null +++ b/Projects/Feature/BottleArrival/Interface/Sources/BottleArrivalDetail/BottleArrivalDetailView.swift @@ -0,0 +1,48 @@ +// +// BottleArrivalDetailView.swift +// FeatureBottleArrival +// +// Created by JongHoon on 10/9/24. +// + +import SwiftUI + +import FeatureBaseWebViewInterface + +import CoreLoggerInterface + +import ComposableArchitecture + +public struct BottleArrivalDetailView: View { + @Perception.Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + BaseWebView( + type: .openURL(url: store.bottleArrivalURL), + actionDidInputted: { action in + switch action { + case .webViewLoadingDidCompleted: + break + + case .closeWebView: + store.send(.backButtonDidTapped) + + case let .showTaost(message): + store.send(.showToast(message: message)) + + default: + Log.assertion(message: "not handled action: \(action)") + } + } + ) + .navigationBarBackButtonHidden() + .ignoresSafeArea(.all, edges: [.top, .bottom]) + } + } +} + diff --git a/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeature.swift b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeature.swift index 5b46a6a8..570f8126 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeature.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeature.swift @@ -8,6 +8,7 @@ import CoreLoggerInterface import DomainBottle import FeatureReportInterface +import FeatureBottleArrivalInterface import ComposableArchitecture @@ -18,11 +19,12 @@ extension BottleStorageFeature { let reducer = Reduce { state, action in switch action { case .onAppear: + state.isLoading = true return popToRootAndReload(state: &state) case let .bottleStorageListFetched(bottleStorageList): - state.activeBottleList = bottleStorageList.activeBottles - state.doneBottlsList = bottleStorageList.doneBottles + state.pingPongBottleList = bottleStorageList.pingPongBottles + state.isLoading = false return .none case let .bottleStorageItemDidTapped(bottleID, isRead, userName): @@ -33,16 +35,14 @@ extension BottleStorageFeature { ))) return .none - case let .bottleActiveStateTabButtonTapped(activeState): - state.selectedActiveStateTab = activeState - return .none + case .sandBeachButtonDidTapped: + return .send(.delegate(.sandBeachButtonDidTapped)) case let .path(.element(id: _, action: .pingPongDetail(.delegate(delegate)))): switch delegate { case .backButtonDidTapped: - state.path.removeLast() - return .none + return popToRootAndReload(state: &state) case .reportButtonDidTapped(let userReportProfile): state.path.append(.report(ReportUserFeature.State(userProfile: userReportProfile))) return .none @@ -74,7 +74,6 @@ extension BottleStorageFeature { self.init(reducer: reducer) func popToRootAndReload(state: inout State) -> Effect { - state.selectedActiveStateTab = .active state.path.removeAll() return .run { send in let bottleStorageList = try await bottleClient.fetchBottleStorageList() diff --git a/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeatureInterface.swift b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeatureInterface.swift index 96f821ef..e4fe4638 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeatureInterface.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageFeatureInterface.swift @@ -22,27 +22,15 @@ public struct BottleStorageFeature { @ObservableState public struct State: Equatable { - // 보틀 μƒνƒœ 선택 νƒ­(λŒ€ν™” 쀑, μ™„λ£Œ) - let bottleActiveStateTabs: [BottleActiveState] - var selectedActiveStateTab: BottleActiveState - var currentSelectedBottles: [BottleStorageItem] { - switch selectedActiveStateTab { - case .active: - return activeBottleList ?? [] - case .done: - return doneBottlsList ?? [] - } - } // 보틀 리슀트 - var activeBottleList: [BottleStorageItem]? - var doneBottlsList: [BottleStorageItem]? + var pingPongBottleList: [BottleStorageItem] var path = StackState() + var isLoading: Bool = false public init() { - self.bottleActiveStateTabs = BottleActiveState.allCases - self.selectedActiveStateTab = .active + self.pingPongBottleList = [] } } @@ -50,9 +38,6 @@ public struct BottleStorageFeature { // View Life Cycle case onAppear - // 보틀 μƒνƒœ 선택 νƒ­(λŒ€ν™” 쀑, μ™„λ£Œ) - case bottleActiveStateTabButtonTapped(BottleActiveState) - // 보틀 리슀트 case bottleStorageListFetched(BottleStorageList) case bottleStorageItemDidTapped( @@ -60,6 +45,7 @@ public struct BottleStorageFeature { isRead: Bool, userName: String ) + case sandBeachButtonDidTapped case selectedTabDidChanged(selectedTab: TabType) case delegate(Delegate) // ETC. @@ -68,6 +54,7 @@ public struct BottleStorageFeature { public enum Delegate { case selectedTabDidChanged(selectedTab: TabType) + case sandBeachButtonDidTapped } } @@ -78,16 +65,25 @@ public struct BottleStorageFeature { } } -public enum BottleActiveState: String, CaseIterable, Equatable { - case active - case done - +extension PingPongLastStatus { var title: String { switch self { - case .active: - return "λŒ€ν™” 쀑" - case .done: - return "μ™„λ£Œ" + case .noAnswerFromBoth: + return "문닡을 μ‹œμž‘ν•΄ μ£Όμ„Έμš”" + case .answerFromOther: + return "μƒˆλ‘œμš΄ 문닡이 λ„μ°©ν–ˆμ–΄μš”" + case .photoSharedByOther: + return "사진이 λ„μ°©ν–ˆμ–΄μš”" + case .contactSharedByOther: + return "μ—°λ½μ²˜κ°€ λ„μ°©ν–ˆμ–΄μš”" + case .answerFromMeOnly: + return "문닡을 λ³΄λƒˆμ–΄μš”" + case .photoSharedByMeOnly: + return "사진을 κ³΅μœ ν–ˆμ–΄μš”" + case .contactSharedByMeOnly: + return "μ—°λ½μ²˜λ₯Ό κ³΅μœ ν–ˆμ–΄μš”" + case .conversationStopped: + return "λŒ€ν™”κ°€ μ€‘λ‹¨λμ–΄μš”" } } } diff --git a/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageView.swift b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageView.swift index 748c78d9..365b544c 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageView.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/BottleStorage/BottleStorageView.swift @@ -10,6 +10,7 @@ import SwiftUI import SharedDesignSystem import FeatureReportInterface import FeatureTabBarInterface +import FeatureBottleArrivalInterface import ComposableArchitecture @@ -17,28 +18,30 @@ public struct BottleStorageView: View { @Perception.Bindable private var store: StoreOf public init(store: StoreOf) { - self.store = store + self.store = store } public var body: some View { WithPerceptionTracking { NavigationStack(path: $store.scope(state: \.path, action: \.path)) { VStack(spacing: 0.0) { - bottleActiveStateSelectTab - bottlsList .padding(.horizontal, .md) .padding(.top, 32.0) - .padding(.bottom, 36.0) - - Spacer() } + .padding(.top, 72) + .frame(maxHeight: .infinity, alignment: .top) + .frame(maxWidth: .infinity) + .background(to: ColorToken.background(.primary)) + .padding(.bottom, BottleConstants.bottomTabBarHeight.value) .setTabBar(selectedTab: .bottleStorage) { selectedTab in store.send(.selectedTabDidChanged(selectedTab: selectedTab)) } - - .frame(maxHeight: .infinity, alignment: .top) - .background(to: ColorToken.background(.primary)) + .overlay { + if store.pingPongBottleList.isEmpty && store.isLoading { + LoadingIndicator() + } + } } destination: { store in WithPerceptionTracking { switch store.state { @@ -67,62 +70,46 @@ public struct BottleStorageView: View { // MARK: - Private Views private extension BottleStorageView { - var bottleActiveStateSelectTab: some View { - HStack(spacing: .xs) { - OutlinedStyleButton( - .small(contentType: .text), - title: BottleActiveState.active.title, - buttonType: .throttle, - isSelected: store.selectedActiveStateTab == BottleActiveState.active, - action: { - store.send(.bottleActiveStateTabButtonTapped(.active)) - } - ) - - OutlinedStyleButton( - .small(contentType: .text), - title: BottleActiveState.done.title, - buttonType: .throttle, - isSelected: store.selectedActiveStateTab == BottleActiveState.done, - action: { - store.send(.bottleActiveStateTabButtonTapped(.done)) - } - ) - - Spacer() - } - .padding(.md) - } - @ViewBuilder var bottlsList: some View { - if store.currentSelectedBottles.isEmpty && store.activeBottleList != nil { - VStack(spacing: .xxl) { - HStack(spacing: 0.0) { - WantedSansStyleText( - "아직 보관 쀑인\n보틀이 μ—†μ–΄μš”!", - style: .title1, - color: .primary - ) - - Spacer() - } + if store.pingPongBottleList.isEmpty && !store.isLoading { + VStack(alignment: .center, spacing: 0.0) { + Spacer() + BottleImageView(type: .local(bottleImageSystem: .illustraition(.basket))) + .frame(height: 180) + .frame(width: 180) + .aspectRatio(1.0, contentMode: .fit) + .padding(.bottom, .xl) - GeometryReader { geometry in - BottleImageView(type: .local(bottleImageSystem: .illustraition(.basket))) - .frame(height: geometry.size.width) - } - .aspectRatio(1.0, contentMode: .fit) + WantedSansStyleText( + "아직 λŒ€ν™”λ₯Ό μ‹œμž‘ν•˜μ§€ μ•ŠμœΌμ…¨κ΅°μš”!", + style: .subTitle1, + color: .primary + ) + .padding(.bottom, .xs) + + WantedSansStyleText( + "λ§ˆμŒμ— λ“œλŠ” μƒλŒ€λ₯Ό μ°Ύμ•„\nκ°€μΉ˜κ΄€ 문닡을 μ‹œμž‘ν•΄ λ³ΌκΉŒμš”?", + style: .body, + color: .tertiary + ) + .lineSpacing(5) + .multilineTextAlignment(.center) + .padding(.bottom, .xl) + + SolidButton(title: "λͺ¨λž˜μ‚¬μž₯ λ°”λ‘œκ°€κΈ°", sizeType: .extraSmall, buttonType: .throttle, action: { store.send(.sandBeachButtonDidTapped) }) + Spacer() } } else { ScrollView { VStack(spacing: .md) { - ForEach(store.currentSelectedBottles, id: \.id) { bottle in - BottleStorageItem( + ForEach(store.pingPongBottleList, id: \.id) { bottle in + PingPongUserView( + status: bottle.lastStatus?.title ?? "", + lastPingPongTime: bottle.lastActivatedAt ?? "", userName: bottle.userName ?? "(μ—†μŒ)", age: bottle.age ?? 0, mbti: bottle.mbti, - keywords: bottle.keyword, imageURL: bottle.userImageUrl, isRead: bottle.isRead ) @@ -135,6 +122,9 @@ private extension BottleStorageView { } } } + + Spacer() + .frame(height: 36.0) } .scrollIndicators(.hidden) } diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PhotoSharePingPongView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PhotoSharePingPongView.swift index 93ef4ff5..c551279f 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PhotoSharePingPongView.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/PhotoSharePingPongView.swift @@ -15,30 +15,28 @@ public struct PhotoSharePingPongView: View { private let isActive: Bool private let pingPongTitle: String private let photoShareState: PingPongPhotoStatus - private let myProfileImageURL: String? - private let otherProfileImageURL: String? + private let otherProfileImageURLs: [String]? @Binding var isSelctedYesButton: Bool @Binding var isSelctedNoButton: Bool private let doneButtonAction: (() -> Void)? + @State var selectedIndex: Int = 0 public init( isActive: Bool, - pingPongTitle: String, + pingPongTitle: String, photoShareState: PingPongPhotoStatus, - myProfileImageURL: String?, - otherProfileImageURL: String?, isSelctedYesButton: Binding = .constant(false), - isSelctedNoButton: Binding = .constant(false), - doneButtonAction: (() -> Void)? = nil + isSelctedNoButton: Binding = .constant(false), + doneButtonAction: (() -> Void)? = nil, + otherProfileImageURLs: [String]? ) { self.isActive = isActive self.pingPongTitle = pingPongTitle self.photoShareState = photoShareState - self.myProfileImageURL = myProfileImageURL - self.otherProfileImageURL = otherProfileImageURL self._isSelctedYesButton = isSelctedYesButton self._isSelctedNoButton = isSelctedNoButton self.doneButtonAction = doneButtonAction + self.otherProfileImageURLs = otherProfileImageURLs } public var body: some View { @@ -140,15 +138,51 @@ private extension PhotoSharePingPongView { makeRightBubbleText(text: "사진 κ³΅κ°œκ°€ μ‹€νŒ¨ν–ˆμ–΄μš”") } + @ViewBuilder var bothPublicView: some View { - HStack(spacing: .sm) { - peerProfileImage - .frame(maxWidth: .infinity) - myProfileImage - .frame(maxWidth: .infinity) + if let images = otherProfileImageURLs { + VStack(spacing: .lg) { + GeometryReader { geo in + TabView(selection: $selectedIndex) { + ForEach(images.indices, id: \.self) { index in + makePeerProfileImage(url: images[index]) + .tag(index) + } + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + .frame(height: geo.size.width - 10) + } + .aspectRatio(1, contentMode: .fit) + + photoIndicatorView + } + } else { + EmptyView() } } + var photoIndicatorView: some View { + HStack(spacing: .xxl) { + BottleImageView(type: .local(bottleImageSystem: .icon(.leftArrow))) + .foregroundStyle(to: ColorToken.icon(.primary)) + .asButton { + if selectedIndex > 0 { + selectedIndex -= 1 + } + } + + PageIndicatorView(pageInfo: .init(nowPage: selectedIndex + 1, totalCount: otherProfileImageURLs?.count ?? 0)) + + BottleImageView(type: .local(bottleImageSystem: .icon(.leftArrow))) + .foregroundStyle(to: ColorToken.icon(.primary)) + .rotationEffect(.degrees(180)) + .asButton { + if selectedIndex + 1 < otherProfileImageURLs?.count ?? 0 { + selectedIndex += 1 + } + } + } + } @ViewBuilder var questionText: some View { @@ -168,43 +202,16 @@ private extension PhotoSharePingPongView { } } - @ViewBuilder - var peerProfileImage: some View { - if let peerProfileImageURL = otherProfileImageURL { - GeometryReader { geo in - RemoteImageView( - imageURL: peerProfileImageURL, - downsamplingWidth: 150, - downsamplingHeight: 150 - ) - .frame(height: geo.size.width) - .preventScreenshot() - } - .aspectRatio(1, contentMode: .fit) - .clipped() - .cornerRadius(.md, corenrs: [.topRight, .bottomLeft, .bottomRight]) - } else { - EmptyView() - } - } - - @ViewBuilder - var myProfileImage: some View { - if let myProfileImageURL = myProfileImageURL { - GeometryReader { geo in - RemoteImageView( - imageURL: myProfileImageURL, - downsamplingWidth: 150, - downsamplingHeight: 150 - ) - .frame(height: geo.size.width) - .preventScreenshot() - } - .aspectRatio(1, contentMode: .fit) - .clipped() - .cornerRadius(.md, corenrs: [.topRight, .topLeft, .bottomLeft]) - } else { - EmptyView() - } + func makePeerProfileImage(url: String) -> some View { + RemoteImageView( + imageURL: url, + downsamplingWidth: 150, + downsamplingHeight: 150 + ) + .preventScreenshot() + .aspectRatio(1, contentMode: .fit) + .clipped() + .cornerRadius(.md, corenrs: [.topRight, .bottomLeft, .bottomRight]) } } + 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..d7b914e8 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..c75ac59a 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)) } } @@ -62,8 +63,7 @@ private extension PingPongDetailView { var tabButtons: some View { HStack(spacing: .xs) { ForEach(PingPongDetailViewTabType.allCases, id: \.title, content: { tab in - OutlinedStyleButton( - .small(contentType: .text), + SegmentControlButton( title: tab.title, buttonType: .throttle, isSelected: store.selectedTab == tab, diff --git a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/QuestionPingPongView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/QuestionPingPongView.swift index 69738fc5..2852222f 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/QuestionPingPongView.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/QuestionPingPongView.swift @@ -97,7 +97,7 @@ private extension QuestionPingPongView { var questionText: some View { HStack(spacing: 0) { WantedSansStyleText( - questionContent, + "Q. " + questionContent, style: .subTitle1, color: .focusePrimary ) 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..af6154ed 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 @@ -27,7 +27,6 @@ public struct IntroductionView: View { } else if store.isStopped == false { UserProfileView( imageURL: store.userImageURL, - isBlurred: true, userName: store.userName, userAge: store.age ) @@ -81,9 +80,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/Matching/MatchingView.swift b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingView.swift index c1fbca1a..962c10c9 100644 --- a/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingView.swift +++ b/Projects/Feature/BottleStorage/Interface/Sources/PingPongDetail/View/SubViews/Matching/MatchingView.swift @@ -21,26 +21,26 @@ public struct MatchingView: View { public var body: some View { WithPerceptionTracking { - ScrollView { - VStack(alignment: .leading, spacing: 0.0) { - title - .padding(.vertical, 32) - - matchingInfo + ZStack(alignment: .bottom) { + ScrollView { + VStack(alignment: .leading, spacing: 0.0) { + title + .padding(.vertical, 32) - Spacer() - - bottomButton - - Spacer() - .frame(height: 30) + matchingInfo + } + .padding(.horizontal, .md) + .frame(maxWidth: .infinity) } - .padding(.horizontal, .md) - .frame(maxHeight: .infinity) .background(to: ColorToken.background(.primary)) + .scrollIndicators(.hidden) + + bottomButton + .padding(.horizontal, .md) + .padding(.bottom, 30) + .shadow(color: .white, radius: 15, y: -30) } .background(to: ColorToken.background(.primary)) - .scrollIndicators(.hidden) } } } @@ -77,7 +77,7 @@ private extension MatchingView { case .waitingOtherAnswer: GeometryReader { geometryProxy in WithPerceptionTracking { - let width = geometryProxy.size.width - 60.0 + let width = geometryProxy.size.width - 120.0 HStack(spacing: 0 ) { Spacer() BottleImageView( @@ -98,7 +98,7 @@ private extension MatchingView { case .matchFailed: GeometryReader { geometryProxy in WithPerceptionTracking { - let width = geometryProxy.size.width - 50 + let width = geometryProxy.size.width - 120.0 HStack(spacing: 0 ) { Spacer() BottleImageView( @@ -125,7 +125,7 @@ private extension MatchingView { style: .body, color: .quinary ) - .padding(.vertical, 2) + .padding(.vertical, 5) .padding(.horizontal, .xs) .background { RoundedRectangle(cornerRadius: BottleRadiusType.xs.value) @@ -139,7 +139,7 @@ private extension MatchingView { ) OutlinedStyleButton( - .small(contentType: .image(type: .local(bottleImageSystem: .icom(.share)))), + .small(contentType: .image(type: .local(bottleImageSystem: .icon(.share)))), title: "λ³΅μ‚¬ν•˜κΈ°", buttonType: .throttle ) { 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..ce722b95 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 @@ -24,6 +24,22 @@ extension QuestionAndAnswerFeature { state.configurePingPong(pingPong) return .none + case let .focusedFieldDidChanged(field): + guard let previousFocusedField = state.focusedField else { + state.focusedField = field + return .none + } + + if (previousFocusedField == .firstLetter && state.firstLetterTextFieldContent.count >= 50) || + (previousFocusedField == .secondLetter && state.secondLetterTextFieldContent.count >= 50) || + (previousFocusedField == .thirdLetter && state.thirdLetterTextFieldContent.count >= 50) { + state.textFieldState = .active + } else { + state.textFieldState = .enabled + } + + return .none + case let .texFieldDidFocused(isFocused): state.textFieldState = isFocused ? .focused : .active return .none @@ -68,7 +84,6 @@ extension QuestionAndAnswerFeature { } case let .finalSelectButtonDidTapped(willMatch: willMatch): - state.isShowLoadingIndicator = true return .run { [bottleID = state.bottleID] send in try await bottleClient.finalSelect( bottleID: bottleID, @@ -76,37 +91,17 @@ extension QuestionAndAnswerFeature { ) switch willMatch { case true: - await send(.refreshPingPongDidRequired) + await send(.delegate(.refreshPingPong)) case false: await send(.delegate(.popToRootDidRequired)) } } 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 +127,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..f04ba697 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 @@ -20,6 +20,12 @@ public struct QuestionAndAnswerFeature { self.reducer = reducer } + public enum FocusField: Hashable { + case firstLetter + case secondLetter + case thirdLetter + } + @ObservableState public struct State: Equatable { let bottleID: Int @@ -82,7 +88,8 @@ public struct QuestionAndAnswerFeature { var thirdLetterTextFieldContent: String var textFieldState: TextFieldState - + var focusedField: FocusField? = nil + // 사진 선택 var photoShareIsActive: Bool { guard let photoStatus = pingPong?.photo.photoStatus @@ -114,9 +121,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 @@ -179,14 +184,9 @@ public struct QuestionAndAnswerFeature { case stopTalkButtonDidTapped case refreshDidPulled + case focusedFieldDidChanged(FocusField?) // ETC. case binding(BindingAction) - case destination(PresentationAction) - - case alert(Alert) - public enum Alert: Equatable { - case confirmStopTalk - } case delegate(Delegate) @@ -194,21 +194,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..ef3d78ab 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 @@ -14,7 +14,7 @@ import ComposableArchitecture public struct QuestionAndAnswerView: View { @Perception.Bindable private var store: StoreOf - @FocusState private var isTextFieldFocused: Bool + @FocusState private var focusedField: QuestionAndAnswerFeature.FocusField? public init(store: StoreOf) { self.store = store @@ -39,8 +39,8 @@ public struct QuestionAndAnswerView: View { )) } ) - .focused($isTextFieldFocused) - + .focused($focusedField, equals: .firstLetter) + QuestionPingPongView( pingpongTitle: "두 번째 질문", textFieldContent: $store.secondLetterTextFieldContent, @@ -55,8 +55,8 @@ public struct QuestionAndAnswerView: View { )) } ) - .focused($isTextFieldFocused) - + .focused($focusedField, equals: .secondLetter) + QuestionPingPongView( pingpongTitle: "μ„Έ 번째 질문", textFieldContent: $store.thirdLetterTextFieldContent, @@ -71,19 +71,18 @@ public struct QuestionAndAnswerView: View { )) } ) - .focused($isTextFieldFocused) - + .focused($focusedField, equals: .thirdLetter) + PhotoSharePingPongView( isActive: store.photoShareIsActive, pingPongTitle: "사진 곡개", photoShareState: store.photoShareStateType, - myProfileImageURL: store.photoInfo?.myProfileImageURL, - otherProfileImageURL: store.photoInfo?.otherProfileImageURL, isSelctedYesButton: $store.photoIsSelctedYesButton, isSelctedNoButton: $store.photoIsSelctedNoButton, doneButtonAction: { store.send(.sharePhotoSelectButtonDidTapped(willShare: store.photoIsSelctedYesButton)) - } + }, + otherProfileImageURLs: store.photoInfo?.otherProfileImageURLs ) FinalSelectPingPongView( @@ -115,13 +114,11 @@ public struct QuestionAndAnswerView: View { Spacer() .frame(height: 14) } - .padding(.md) + .padding(.horizontal, .md) + .padding(.top, 32) .frame(maxWidth: .infinity) - .onChange(of: isTextFieldFocused) { isFocused in - store.send(.texFieldDidFocused(isFocused: isFocused)) - } - .onChange(of: store.textFieldState) { textFieldState in - isTextFieldFocused = textFieldState == .active || textFieldState == .enabled ? false : true + .onChange(of: focusedField) { field in + store.send(.focusedFieldDidChanged(field)) } } .refreshable { @@ -134,7 +131,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/GoodFeeling/Example/Sources/AppView.swift b/Projects/Feature/GoodFeeling/Example/Sources/AppView.swift new file mode 100644 index 00000000..021e3614 --- /dev/null +++ b/Projects/Feature/GoodFeeling/Example/Sources/AppView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +import FeatureGoodFeeling +import FeatureGoodFeelingInterface + +import DomainAuth +import DomainAuthInterface + +import ComposableArchitecture + +@main +struct AppView: App { + var body: some Scene { + WindowGroup { + GoodFeelingRootView(store: Store( + initialState: GoodFeelingRootFeature.State(), + reducer: { GoodFeelingRootFeature() } + )) + } + } +} diff --git a/Projects/Feature/GoodFeeling/Interface/Sources/GoodFeeling/GoodFeelingFeature.swift b/Projects/Feature/GoodFeeling/Interface/Sources/GoodFeeling/GoodFeelingFeature.swift new file mode 100644 index 00000000..937ad4c6 --- /dev/null +++ b/Projects/Feature/GoodFeeling/Interface/Sources/GoodFeeling/GoodFeelingFeature.swift @@ -0,0 +1,49 @@ +// +// GoodFeelingFeatureInterface.swift +// FeatureGoodFeelingInterface +// +// Created by JongHoon on 10/6/24. +// + +import Foundation + +import ComposableArchitecture + +@Reducer +public struct GoodFeelingFeature { + private let reducer: Reduce + + public init(reducer: Reduce) { + self.reducer = reducer + } + + @ObservableState + public struct State: Equatable { + var isLoading: Bool + + public init() { + self.isLoading = true + } + } + + public enum Action: BindableAction { + case sentBottleTapped(url: String) + case webViewLoadingDidCompleted + + case configureIsLoading(_: Bool) + + + case delegate(Delegate) + public enum Delegate { + case sentBottleTapped(url: String) + } + + case binding(BindingAction) + } + + public var body: some ReducerOf { + BindingReducer() + + reducer + } +} diff --git a/Projects/Feature/GoodFeeling/Interface/Sources/GoodFeeling/GoodFeelingFeatureInterface.swift b/Projects/Feature/GoodFeeling/Interface/Sources/GoodFeeling/GoodFeelingFeatureInterface.swift new file mode 100644 index 00000000..d9c3acfe --- /dev/null +++ b/Projects/Feature/GoodFeeling/Interface/Sources/GoodFeeling/GoodFeelingFeatureInterface.swift @@ -0,0 +1,33 @@ +// +// GoodFeelingFeature.swift +// FeatureGoodFeelingInterface +// +// Created by JongHoon on 10/6/24. +// + +import Foundation + +import ComposableArchitecture + +extension GoodFeelingFeature { + public init() { + let reducer = Reduce { state, action in + switch action { + case .webViewLoadingDidCompleted: + return .send(.configureIsLoading(false)) + + case let .sentBottleTapped(url): + return .send(.delegate(.sentBottleTapped(url: url))) + + case let .configureIsLoading(isLoading): + state.isLoading = isLoading + return .none + + default: + return .none + } + } + + self.init(reducer: reducer) + } +} diff --git a/Projects/Feature/GoodFeeling/Interface/Sources/GoodFeeling/GoodFeelingView.swift b/Projects/Feature/GoodFeeling/Interface/Sources/GoodFeeling/GoodFeelingView.swift new file mode 100644 index 00000000..401f0c86 --- /dev/null +++ b/Projects/Feature/GoodFeeling/Interface/Sources/GoodFeeling/GoodFeelingView.swift @@ -0,0 +1,49 @@ +// +// GoodFeelingView.swift +// FeatureGoodFeelingInterface +// +// Created by JongHoon on 10/6/24. +// + +import SwiftUI + +import FeatureBaseWebViewInterface + +import CoreLoggerInterface + +import SharedDesignSystem + +import ComposableArchitecture + +public struct GoodFeelingView: View { + @Perception.Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + BaseWebView( + type: .goodFeeling, + actionDidInputted: { action in + switch action { + case .webViewLoadingDidCompleted: + store.send(.webViewLoadingDidCompleted) + + case let .openLink(url): + store.send(.sentBottleTapped(url: url)) + + default: + Log.assertion(message: "not handled action: \(action)") + } + } + ) + .overlay { + if store.isLoading { + LoadingIndicator() + } + } + } + } +} diff --git a/Projects/Feature/GoodFeeling/Interface/Sources/Root/FeatureGoodFeelingRootView.swift b/Projects/Feature/GoodFeeling/Interface/Sources/Root/FeatureGoodFeelingRootView.swift new file mode 100644 index 00000000..f3ec0d05 --- /dev/null +++ b/Projects/Feature/GoodFeeling/Interface/Sources/Root/FeatureGoodFeelingRootView.swift @@ -0,0 +1,50 @@ +// +// FeatureGoodFeelingRootView.swift +// FeatureGoodFeeling +// +// Created by JongHoon on 10/6/24. +// + +import SwiftUI + +import SharedDesignSystem + +import ComposableArchitecture + +public struct GoodFeelingRootView: 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)) { + VStack(spacing: 0.0) { + GoodFeelingView(store: store.scope( + state: \.goodFeeling, + action: \.goodFeeling + )) + Spacer() + .frame(height: BottleConstants.bottomTabBarHeight.value) + } + .setTabBar(selectedTab: .goodFeeling) { selectedTab in + store.send(.selectedTabDidChanged(selectedTab)) + } + } destination: { store in + WithPerceptionTracking { + switch store.state { + case .sentBottleDetail: + if let store = store.scope( + state: \.sentBottleDetail, + action: \.sentBottleDetail + ) { + SentBottleDetailView(store: store) + } + } + } + } + } + } +} diff --git a/Projects/Feature/GoodFeeling/Interface/Sources/Root/GoodFeelingRootFeature.swift b/Projects/Feature/GoodFeeling/Interface/Sources/Root/GoodFeelingRootFeature.swift new file mode 100644 index 00000000..1deaa92f --- /dev/null +++ b/Projects/Feature/GoodFeeling/Interface/Sources/Root/GoodFeelingRootFeature.swift @@ -0,0 +1,62 @@ +// +// GoodFeelingRootFeature.swift +// FeatureGoodFeelingInterface +// +// Created by JongHoon on 10/6/24. +// + +import Foundation + +import FeatureTabBarInterface + +import ComposableArchitecture + +@Reducer +public struct GoodFeelingRootFeature { + private let reducer: Reduce + + public init(reducer: Reduce) { + self.reducer = reducer + } + + @Reducer(state: .equatable) + public enum Path { + case sentBottleDetail(SentBottleDetailFeature) + } + + @ObservableState + public struct State: Equatable { + public var goodFeeling: GoodFeelingFeature.State + + var path = StackState() + + public init() { + self.goodFeeling = .init() + } + } + + public enum Action: BindableAction { + case selectedTabDidChanged(TabType) + case goodFeeling(GoodFeelingFeature.Action) + + case path(StackAction) + case delegate(Delegate) + + public enum Delegate { + case selectedTabDidChanged(TabType) + } + + case binding(BindingAction) + } + + public var body: some ReducerOf { + BindingReducer() + Scope(state: \.goodFeeling, action: \.goodFeeling) { + GoodFeelingFeature() + } + + reducer + .forEach(\.path, action: \.path) + } +} + diff --git a/Projects/Feature/GoodFeeling/Interface/Sources/Root/GoodFeelingRootFeatureInterface.swift b/Projects/Feature/GoodFeeling/Interface/Sources/Root/GoodFeelingRootFeatureInterface.swift new file mode 100644 index 00000000..956511bb --- /dev/null +++ b/Projects/Feature/GoodFeeling/Interface/Sources/Root/GoodFeelingRootFeatureInterface.swift @@ -0,0 +1,50 @@ +// +// GoodFeelingRootFeatureInterface.swift +// FeatureGoodFeelingInterface +// +// Created by JongHoon on 10/6/24. +// + +import Foundation + +import CoreToastInterface + +import ComposableArchitecture + +extension GoodFeelingRootFeature { + public init() { + @Dependency(\.toastClient) var toastClient + let reducer = Reduce { state, action in + switch action { + case let .selectedTabDidChanged(selectedTab): + return .send(.delegate(.selectedTabDidChanged(selectedTab))) + + // GoodFeeling Delegate + case let .goodFeeling(.delegate(delegate)): + switch delegate { + case let .sentBottleTapped(url): + state.path.append(.sentBottleDetail(.init(sentBottleDetailURL: url))) + return .none + } + + // GoodFeelingDetail Delegate + case let .path(.element(id: _, action: .sentBottleDetail(.delegate(delegate)))): + switch delegate { + case .backButtonDidTapped: + _ = state.path.popLast() + return .none + + case .bottelDidAccepted: + toastClient.presentToast(message: "이제 문닡을 μ‹œμž‘ν•  수 μžˆμ–΄μš”.") + state.path.popLast() + return .send(.delegate(.selectedTabDidChanged(.bottleStorage))) + } + + default: + return .none + } + } + + self.init(reducer: reducer) + } +} diff --git a/Projects/Feature/GoodFeeling/Interface/Sources/SentBottleDetail/SentBottleDetailFeature.swift b/Projects/Feature/GoodFeeling/Interface/Sources/SentBottleDetail/SentBottleDetailFeature.swift new file mode 100644 index 00000000..8893aa67 --- /dev/null +++ b/Projects/Feature/GoodFeeling/Interface/Sources/SentBottleDetail/SentBottleDetailFeature.swift @@ -0,0 +1,54 @@ +// +// SentBottleDetailFeature.swift +// FeatureGoodFeeling +// +// Created by JongHoon on 10/9/24. +// + +import Foundation + +import ComposableArchitecture + +@Reducer +public struct SentBottleDetailFeature { + private let reducer: Reduce + + public init(reducer: Reduce) { + self.reducer = reducer + } + + @ObservableState + public struct State: Equatable { + var isLoading: Bool + let sentBottleDetailURL: String + + public init(sentBottleDetailURL: String) { + self.isLoading = true + self.sentBottleDetailURL = sentBottleDetailURL + } + } + + public enum Action: BindableAction { + case webViewLoadingDidCompleted + case backButtonDidTapped + case bottelDidAccepted + + case configureIsLoading(_: Bool) + case showToast(message: String) + + case delegate(Delegate) + public enum Delegate { + case backButtonDidTapped + case bottelDidAccepted + } + + case binding(BindingAction) + } + + public var body: some ReducerOf { + BindingReducer() + + reducer + } +} + diff --git a/Projects/Feature/GoodFeeling/Interface/Sources/SentBottleDetail/SentBottleDetailFeatureInterface.swift b/Projects/Feature/GoodFeeling/Interface/Sources/SentBottleDetail/SentBottleDetailFeatureInterface.swift new file mode 100644 index 00000000..a34362d7 --- /dev/null +++ b/Projects/Feature/GoodFeeling/Interface/Sources/SentBottleDetail/SentBottleDetailFeatureInterface.swift @@ -0,0 +1,44 @@ +// +// SentBottleDetailFeatureInterface.swift +// FeatureGoodFeeling +// +// Created by JongHoon on 10/9/24. +// + +import Foundation + +import CoreToastInterface + +import ComposableArchitecture + +extension SentBottleDetailFeature { + public init() { + @Dependency(\.toastClient) var toastClient + + let reducer = Reduce { state, action in + switch action { + case .webViewLoadingDidCompleted: + return .send(.configureIsLoading(false)) + + case .backButtonDidTapped: + return .send(.delegate(.backButtonDidTapped)) + + case let .showToast(message): + toastClient.presentToast(message: message) + return .none + + case let .configureIsLoading(isLoading): + state.isLoading = isLoading + return .none + + case .bottelDidAccepted: + return .send(.delegate(.bottelDidAccepted)) + + default: + return .none + } + } + + self.init(reducer: reducer) + } +} diff --git a/Projects/Feature/GoodFeeling/Interface/Sources/SentBottleDetail/SentBottleDetailView.swift b/Projects/Feature/GoodFeeling/Interface/Sources/SentBottleDetail/SentBottleDetailView.swift new file mode 100644 index 00000000..49e059fd --- /dev/null +++ b/Projects/Feature/GoodFeeling/Interface/Sources/SentBottleDetail/SentBottleDetailView.swift @@ -0,0 +1,57 @@ +// +// SentBottleDetailView.swift +// FeatureGoodFeeling +// +// Created by JongHoon on 10/9/24. +// + +import SwiftUI + +import FeatureBaseWebViewInterface + +import CoreLoggerInterface + +import SharedDesignSystem + +import ComposableArchitecture + +public struct SentBottleDetailView: View { + @Perception.Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + BaseWebView( + type: .openURL(url: store.sentBottleDetailURL), + actionDidInputted: { action in + switch action { + case .webViewLoadingDidCompleted: + store.send(.webViewLoadingDidCompleted) + + case .closeWebView: + store.send(.backButtonDidTapped) + + case let .showTaost(message): + store.send(.showToast(message: message)) + + case .bottelDidAccepted: + store.send(.bottelDidAccepted) + + default: + Log.assertion(message: "not handled action: \(action)") + } + } + ) + .navigationBarBackButtonHidden() + .ignoresSafeArea(.all, edges: [.top, .bottom]) + .overlay { + if store.isLoading { + LoadingIndicator() + } + } + } + } +} diff --git a/Projects/Feature/GoodFeeling/Project.swift b/Projects/Feature/GoodFeeling/Project.swift new file mode 100644 index 00000000..b63af4e7 --- /dev/null +++ b/Projects/Feature/GoodFeeling/Project.swift @@ -0,0 +1,53 @@ +import ProjectDescription +import ProjectDescriptionHelpers +import DependencyPlugin + +let project = Project.makeModule( + name: ModulePath.Feature.name+ModulePath.Feature.GoodFeeling.rawValue, + targets: [ + .feature( + interface: .GoodFeeling, + factory: .init( + dependencies: [ + .domain, + .feature(interface: .TabBar), + .feature(interface: .BaseWebView) + ] + ) + ), + .feature( + implements: .GoodFeeling, + factory: .init( + dependencies: [ + .feature(interface: .GoodFeeling) + ] + ) + ), + .feature( + testing: .GoodFeeling, + factory: .init( + dependencies: [ + .feature(interface: .GoodFeeling) + ] + ) + ), + .feature( + tests: .GoodFeeling, + factory: .init( + dependencies: [ + .feature(testing: .GoodFeeling), + .feature(implements: .GoodFeeling) + ] + ) + ), + .feature( + example: .GoodFeeling, + factory: .init( + dependencies: [ + .feature(testing: .GoodFeeling), + .feature(implements: .GoodFeeling) + ] + ) + ) + ] +) diff --git a/Projects/Feature/GoodFeeling/Sources/Source.swift b/Projects/Feature/GoodFeeling/Sources/Source.swift new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Projects/Feature/GoodFeeling/Sources/Source.swift @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Projects/Feature/GoodFeeling/Testing/Sources/GoodFeelingTesting.swift b/Projects/Feature/GoodFeeling/Testing/Sources/GoodFeelingTesting.swift new file mode 100644 index 00000000..b1853ce6 --- /dev/null +++ b/Projects/Feature/GoodFeeling/Testing/Sources/GoodFeelingTesting.swift @@ -0,0 +1 @@ +// This is for Tuist diff --git a/Projects/Feature/GoodFeeling/Tests/Sources/GoodFeelingTest.swift b/Projects/Feature/GoodFeeling/Tests/Sources/GoodFeelingTest.swift new file mode 100644 index 00000000..c453bfff --- /dev/null +++ b/Projects/Feature/GoodFeeling/Tests/Sources/GoodFeelingTest.swift @@ -0,0 +1,11 @@ +import XCTest + +final class GoodFeelingTests: XCTestCase { + override func setUpWithError() throws {} + + override func tearDownWithError() throws {} + + func testExample() { + XCTAssertEqual(1, 1) + } +} diff --git a/Projects/Feature/Guide/Interface/Sources/StartGuide/StartGuideView.swift b/Projects/Feature/Guide/Interface/Sources/StartGuide/StartGuideView.swift index 5204bc1a..ae0f6235 100644 --- a/Projects/Feature/Guide/Interface/Sources/StartGuide/StartGuideView.swift +++ b/Projects/Feature/Guide/Interface/Sources/StartGuide/StartGuideView.swift @@ -24,7 +24,7 @@ public struct StartGuideView: View { title ZStack(alignment: .bottom) { GeometryReader { geometry in - let bottleImageTopPadding: CGFloat = 48 + let bottleImageTopPadding: CGFloat = 62.0 HStack(spacing: 0) { Spacer() bottleImage @@ -32,6 +32,7 @@ public struct StartGuideView: View { Spacer() } .offset(y: bottleImageTopPadding) + .padding(.horizontal, 60.0 - BottlePaddingType.md.length) } doneButton .padding(.bottom, .lg) @@ -64,7 +65,7 @@ private extension StartGuideView { var doneButton: some View { SolidButton( - title: "μ™„λ£Œ", + title: "확인", sizeType: .large, buttonType: .throttle, action: { store.send(.doneButtonDidTapped) } 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..ca869d12 --- /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.fetchPushNotificationAllowStatusLocally() + 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..85b53e11 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/EditProfile/EditProfileFeature.swift @@ -0,0 +1,40 @@ +// +// 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)) + + case .profileImageDidChanged: + return .send(.delegate(.profileImageDidChanged)) + + 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..8198a418 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/EditProfile/EditProfileFeatureInterface.swift @@ -0,0 +1,49 @@ +// +// 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) + case profileImageDidChanged + + public enum Delegate { + case closeEditProfileView + case profileImageDidChanged + } + + // 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..8bc811d0 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/EditProfile/ProfileEditView.swift @@ -0,0 +1,56 @@ +// +// 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) + + case .profileImageDidChanged: + store.send(.profileImageDidChanged) + + 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..1119dcc2 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,30 +33,34 @@ 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) } .scrollIndicators(.hidden) .background(to: ColorToken.container(.primary)) - .padding(.bottom, 106) .padding(.top, 1) - .setTabBar(selectedTab: .myPage) { selectedTab in - store.send(.selectedTabDidChanged(selectedTab)) - } .onLoad { store.send(.onLoad) } + .task { + store.send(.onAppear) + } .overlay { if store.isShowLoadingProgressView { WithPerceptionTracking { @@ -58,7 +68,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 +98,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..0be43eef --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/MyPageRootFeatureInterface.swift @@ -0,0 +1,71 @@ +// +// 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 + case .profileImageDidChanged: + return .send(.myPage(.userProfileUpdateDidRequest)) + } + + 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..6619d607 --- /dev/null +++ b/Projects/Feature/MyPage/Interface/Sources/MyPageRootView.swift @@ -0,0 +1,58 @@ +// +// MyPageRootView.swift +// FeatureMyPageInterface +// +// Created by μž„ν˜„κ·œ on 9/21/24. +// + +import SwiftUI + +import FeatureTabBarInterface + +import SharedDesignSystem + +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)) + .padding(.bottom, BottleConstants.bottomTabBarHeight.value) + .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/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupFeature.swift b/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupFeature.swift index 380f3bb9..d78eda3b 100644 --- a/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupFeature.swift +++ b/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupFeature.swift @@ -9,9 +9,12 @@ import Foundation import DomainProfileInterface import DomainProfile -import SharedDesignSystem + +import CoreToastInterface import CoreLoggerInterface +import CoreToastInterface + import ComposableArchitecture @Reducer @@ -24,52 +27,16 @@ public struct IntroductionSetupFeature { @ObservableState public struct State: Equatable { - public var introductionText: String - public var textFieldState: TextFieldState - public var keywordItem: [ClipItem] - public var isNextButtonDisable: Bool - public var maxLength: Int - public var isLoading: Bool - - public init( - introductionText: String = "", - textFieldState: TextFieldState = .enabled, - keywordItem: [ClipItem] = [], - isNextButtonDisable: Bool = true, - maxLength: Int = 50, - isLoading: Bool = false - ) { - self.introductionText = introductionText - self.textFieldState = textFieldState - self.keywordItem = keywordItem - self.isNextButtonDisable = isNextButtonDisable - self.maxLength = maxLength - self.isLoading = isLoading - } + public init() {} } - public enum Action: BindableAction { - // View Life Cycle - case onLoad - - // User Action - case texFieldDidFocused(isFocused: Bool) - case profileSelectDidFatched(ProfileSelect) - case nextButtonDidTapped - case onTapGesture - case backButtonDidTapped - - // Delegate - case delegate(Delegate) - case binding(BindingAction) - - public enum Delegate { - case nextButtonDidTapped(introductionText: String) - } + public enum Action { + // Web Bridge + case closeWebView + case presentToastDidRequired(message: String) } public var body: some ReducerOf { - BindingReducer() reducer } } @@ -77,75 +44,19 @@ public struct IntroductionSetupFeature { extension IntroductionSetupFeature { public init() { @Dependency(\.dismiss) var dismiss + @Dependency(\.toastClient) var toastClient + let reducer = Reduce { state, action in @Dependency(\.profileClient) var profileClient switch action { - case .onLoad: - state.isLoading = true - return .run { send in - let profileSelect = try await profileClient.fetchProfileSelect() - await send(.profileSelectDidFatched(profileSelect)) - } - case let .texFieldDidFocused(isFocused): - state.textFieldState = isFocused ? .focused : .active - return .none - case .profileSelectDidFatched(let profileSelect): - // TODO: μ½”λ“œ κ°œμ„  - // TODO: μ—†μœΌλ©΄ ClipItem nil둜 - state.keywordItem = [ - ClipItem( - title: "λ‚΄ ν‚€μ›Œλ“œλ₯Ό μ°Έκ³ ν•΄λ³΄μ„Έμš”", - list: [profileSelect.job, profileSelect.mbti, "\(profileSelect.region.city) \(profileSelect.region.state)", "\(profileSelect.height)", profileSelect.smoke, profileSelect.alcohol] - ), - - ClipItem( - title: "λ‚˜μ˜ 성격은", - list: profileSelect.keyword - ), - - ClipItem( - title: "λ‚΄κ°€ ν‘Ή λΉ μ§„ μ·¨λ―ΈλŠ”", - list: (profileSelect.interset.culture ?? []) - + (profileSelect.interset.entertainment ?? []) - + (profileSelect.interset.sports ?? []) - + (profileSelect.interset.etc ?? []) - ) - ] - state.isLoading = false - return .none - case .binding(\.introductionText): - if state.introductionText.count >= state.maxLength { - state.textFieldState = .focused - state.isNextButtonDisable = false - } else { - state.textFieldState = .error - state.isNextButtonDisable = true - } - return .none - - case .nextButtonDidTapped: - return .run { [introductionText = state.introductionText] send in - Log.debug("nextButtonDidTapped") - await send(.delegate(.nextButtonDidTapped(introductionText: introductionText))) - } - case .onTapGesture: - if state.introductionText.count == 0 { - state.textFieldState = .enabled - } else { - state.textFieldState = .active - } - return .none - - case .backButtonDidTapped: + case .closeWebView: return .run { _ in - await dismiss() + await dismiss() } - case .binding(_): - return .none - - case .delegate: + case let .presentToastDidRequired(message): + toastClient.presentToast(message: message) return .none } } diff --git a/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupView.swift b/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupView.swift index 272f7671..78600e75 100644 --- a/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupView.swift +++ b/Projects/Feature/ProfileSetup/Interface/Sources/IntroductionSetup/IntroductionSetupView.swift @@ -7,9 +7,12 @@ import SwiftUI -import SharedDesignSystem +import FeatureBaseWebViewInterface + import CoreLoggerInterface +import SharedDesignSystem + import ComposableArchitecture public struct IntroductionSetupView: View { @@ -22,88 +25,28 @@ public struct IntroductionSetupView: View { public var body: some View { WithPerceptionTracking { - if store.isLoading { - LoadingIndicator() - } else { - ScrollView { - introductionTitle - introductionTextField - keywordList - nextButton - }.onTapGesture { - store.send(.onTapGesture) - }.setNavigationBar { - makeNaivgationleftButton { - store.send(.backButtonDidTapped) - } + BaseWebView(type: .introductionSetup) { action in + switch action { + case .webViewLoadingDidCompleted: + break + + case .closeWebView: + store.send(.closeWebView) + + case .introductionDidCompleted: + store.send(.closeWebView) + + case let .showTaost(message): + store.send(.presentToastDidRequired(message: message)) + + default: + Log.assertion(message: "not handled action: \(action)") } } } - .onLoad { - store.send(.onLoad) - } .scrollIndicators(.hidden) - .ignoresSafeArea(.all, edges: .bottom) + .ignoresSafeArea(.all, edges: [.bottom, .top]) .toolbar(.hidden, for: .bottomBar) - } -} - -private extension IntroductionSetupView { - var introductionTitle: some View { - TitleView( - pageInfo: PageInfo(nowPage: 1, totalCount: 2), - title: "보틀에 담을\nμ†Œκ°œλ₯Ό μž‘μ„±ν•΄ μ£Όμ„Έμš”", - caption: "μˆ˜μ •μ΄ μ–΄λ €μš°λ‹ˆ μ‹ μ€‘ν•˜κ²Œ μž‘μ„±ν•΄μ£Όμ„Έμš”" - ) - .padding(.top, .xl) - .padding(.bottom, 32) - .padding(.horizontal, .md) - } - - var introductionTextField: some View { - LinesTextField( - textFieldType: .introduction, - textFieldState: $store.textFieldState, - text: $store.introductionText, - placeHolder: "ν˜ΈκΈ°μ‹¬μ΄ 많고 μƒˆλ‘œμš΄ κ²½ν—˜μ„ μ¦κΉλ‹ˆλ‹€. 주말엔 책을 μ½κ±°λ‚˜ 맛집을 μ°Ύμ•„λ‹€λ‹ˆλ©° μ—¬μœ λ₯Ό 즐기고, μΉœκ΅¬λ“€κ³Ό μ†Œμ†Œν•œ λͺ¨μž„μœΌλ‘œ μ—λ„ˆμ§€λ₯Ό μΆ©μ „ν•΄μš”.", - errorMessage: "μ΅œμ†Œ \(store.maxLength)κΈ€μž 이상 μž‘μ„±ν•΄μ£Όμ„Έμš”", - textLimit: 300 - ) - .focused($isTextFieldFocused) - .padding(.horizontal, .md) - .padding(.bottom, .sm) - .onChange(of: isTextFieldFocused) { isFocused in - store.send(.texFieldDidFocused(isFocused: isFocused)) - } - .onChange(of: store.textFieldState) { textFieldState in - Log.error(textFieldState) - isTextFieldFocused = textFieldState == .active || textFieldState == .enabled ? false : true - } - } - - var keywordList: some View { - ClipListContainerView( - clipItemList: store.keywordItem - ) - .padding(.horizontal, .md) - .padding(.bottom, 47) - } - - var nextButton: some View { - SolidButton( - title: "λ‹€μŒ", - sizeType: .full, - buttonType: .throttle, - action: { store.send(.nextButtonDidTapped) } - ) - .padding(.horizontal, .md) - .padding(.bottom, .xl) - .disabled(store.isNextButtonDisable) - } -} - -extension View { - func endTextEditing() { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + .toolbar(.hidden, for: .navigationBar) } } diff --git a/Projects/Feature/ProfileSetup/Project.swift b/Projects/Feature/ProfileSetup/Project.swift index 6a0b52d9..c358fd31 100644 --- a/Projects/Feature/ProfileSetup/Project.swift +++ b/Projects/Feature/ProfileSetup/Project.swift @@ -9,7 +9,8 @@ let project = Project.makeModule( interface: .ProfileSetup, factory: .init( dependencies: [ - .domain + .domain, + .feature(interface: .BaseWebView) ] ) ), 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..c58b520c 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) } } @@ -50,7 +50,6 @@ private extension ReportUserView { var userProfile: some View { UserProfileView( imageURL: store.userProfile.imageURL, - isBlurred: true, userName: store.userProfile.userName, userAge: store.userProfile.userAge ) diff --git a/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootFeature.swift b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootFeature.swift index 0bd62c9a..3008787f 100644 --- a/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootFeature.swift +++ b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootFeature.swift @@ -10,7 +10,9 @@ import Foundation import FeatureProfileSetupInterface import FeatureBottleArrivalInterface import FeatureTabBarInterface + import DomainProfile +import DomainUserInterface import ComposableArchitecture @@ -29,6 +31,7 @@ public struct SandBeachRootFeature { case IntroductionSetup(IntroductionSetupFeature) case ProfileImageUpload(ProfileImageUploadFeature) case BottleArrival(BottleArrivalFeature) + case BottleArrivalDetail(BottleArrivalDetailFeature) } @ObservableState @@ -37,19 +40,24 @@ public struct SandBeachRootFeature { var introduction: String var profileImageData: Data var isLoading: Bool + public var isCoachMarkViewed: Bool = true + public var sandBeach: SandBeachFeature.State + public var sandBeachCoachMark: SandBeachCoachMarkFeature.State public init( path: StackState = StackState(), introduction: String = "", profileImageData: Data = .init(), sandBeach: SandBeachFeature.State = .init(), + sandBeachCoachMark: SandBeachCoachMarkFeature.State = .init(), isLoading: Bool = false ) { self.path = path self.introduction = introduction self.profileImageData = profileImageData self.sandBeach = sandBeach + self.sandBeachCoachMark = sandBeachCoachMark self.isLoading = isLoading } } @@ -57,6 +65,7 @@ public struct SandBeachRootFeature { public enum Action { case path(StackAction) case sandBeach(SandBeachFeature.Action) + case sandBeachCoachMark(SandBeachCoachMarkFeature.Action) case profileSetupDidCompleted case delegate(Delegate) case selectedTabDidChanged(selectedTab: TabType) @@ -73,6 +82,10 @@ public struct SandBeachRootFeature { SandBeachFeature() } + Scope(state: \.sandBeachCoachMark, action: \.sandBeachCoachMark) { + SandBeachCoachMarkFeature() + } + reducer .forEach(\.path, action: \.path) } @@ -83,16 +96,10 @@ extension SandBeachRootFeature { let reducer = Reduce { state, action in @Dependency(\.profileClient) var profileClient - + @Dependency(\.userClient) var userClient + switch action { - // IntrodctionSetup Delegate - case let .path(.element(id: _, action: - .IntroductionSetup(.delegate(.nextButtonDidTapped(introductionText))))): - state.introduction = introductionText - state.path.append(.ProfileImageUpload(ProfileImageUploadFeature.State())) - return .none - // ProfileImageUpload Delegate case let .path(.element(id: _, action: .ProfileImageUpload(.delegate(.doneButtonDidTapped(selectedImageData))))): @@ -127,6 +134,10 @@ extension SandBeachRootFeature { case .closeWebView: state.path.removeLast() return .none + + case let .arrivalBottleTapped(url): + state.path.append(.BottleArrivalDetail(.init(bottleArrivalURL: url))) + return .none } // SandBeach Delegate @@ -142,6 +153,27 @@ extension SandBeachRootFeature { case .writeButtonDidTapped: state.path.append(.IntroductionSetup(IntroductionSetupFeature.State())) return .none + + case .sandBeachLoadCompleted: + state.isCoachMarkViewed = userClient.isCoachMarkViewd() + return .none + } + + // BottleArrivalDetail Delegate + case let .path(.element(id: _, action: .BottleArrivalDetail(.delegate(delegate)))): + switch delegate { + case .backButtonDidTapped: + _ = state.path.popLast() + return .none + } + + // SandBeachCoachMark Delegate + case let .sandBeachCoachMark(.delegate(delegate)): + switch delegate { + case .coachMarkDidCompleted: + userClient.updateCoachMarkState(isViewed: true) + state.isCoachMarkViewed = true + return .none } case .profileSetupDidCompleted: diff --git a/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootView.swift b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootView.swift index 2d974631..514c8a0e 100644 --- a/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootView.swift +++ b/Projects/Feature/SandBeach/Interface/Sources/Root/SandBeachRootView.swift @@ -25,10 +25,17 @@ public struct SandBeachRootView: View { public var body: some View { WithPerceptionTracking { NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + ZStack { SandBeachView(store: store.scope(state: \.sandBeach, action: \.sandBeach)) - .setTabBar(selectedTab: .sandBeach) { selectedTab in - store.send(.selectedTabDidChanged(selectedTab: selectedTab)) + .setTabBar(selectedTab: .sandBeach) { selectedTab in + store.send(.selectedTabDidChanged(selectedTab: selectedTab)) + } + + if !store.isCoachMarkViewed && store.sandBeach.userState == .noIntroduction { + SandBeachCoachMarkView( + store: store.scope(state: \.sandBeachCoachMark, action: \.sandBeachCoachMark)) } + } } destination: { store in WithPerceptionTracking { switch store.state { @@ -52,6 +59,14 @@ public struct SandBeachRootView: View { action: \.BottleArrival) { BottleArrivalView(store: store) } + + case .BottleArrivalDetail: + if let store = store.scope( + state: \.BottleArrivalDetail, + action: \.BottleArrivalDetail + ) { + BottleArrivalDetailView(store: store) + } } } } diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachFeatureInterface.swift index 95ac0b99..76894279 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 @@ -56,6 +56,7 @@ public struct SandBeachFeature { case writeButtonDidTapped case newBottleIslandDidTapped case bottleStorageIslandDidTapped + case sandBeachLoadCompleted } case alert(Alert) @@ -96,52 +97,55 @@ extension SandBeachFeature { Log.error(error) } }) - + return .run { send in - async let _ = authClient.checkUpdateVersion() - async let isExsit = try await profileClient.checkExistIntroduction() - // μžκΈ°μ†Œκ°œ μ—†λŠ” μƒνƒœ - if try await !isExsit { + async let versionCheckTask: Void = authClient.checkUpdateVersion() + async let userProfileStatusTask = profileClient.fetchUserProfileSelect() + async let userBottleInfoTask = bottleClient.fetchUserBottleInfo() + async let bottlesStorageListTask = bottleClient.fetchBottleStorageList() + + let (_, userProfileStatus, userBottleInfo, bottlesStorageList) = try await ( + versionCheckTask, + userProfileStatusTask, + userBottleInfoTask, + bottlesStorageListTask + ) + + let newBottlesCount = userBottleInfo.randomBottleCount + let activeBottlesCount = bottlesStorageList.pingPongBottles + .filter { $0.lastStatus != .conversationStopped && $0.lastStatus != .contactSharedByMeOnly }.count + let nextBottleLeftHours = userBottleInfo.nextBottlLeftHours + + if userProfileStatus == .empty || userProfileStatus == .doneIntroduction { await send(.userStateFetchCompleted( userState: .noIntroduction, isDisableButton: true)) return } - let userBottleInfo = try await bottleClient.fetchUserBottleInfo() - let newBottlesCount = userBottleInfo.randomBottleCount + userBottleInfo.sendBottleCount - // μƒˆλ‘œ λ„μ°©ν•œ 보틀이 μžˆλŠ” μƒνƒœ - - if newBottlesCount > 0 { + if userProfileStatus == .doneProfileImage && newBottlesCount > 0 { await send(.userStateFetchCompleted( userState: .hasNewBottle(bottleCount: newBottlesCount), - isDisableButton: false) - ) - } else { - let bottlesStorageList = try await bottleClient.fetchBottleStorageList() - let activeBottlesCount = bottlesStorageList.activeBottles.count - - // μžκΈ°μ†Œκ°œλ§Œ μž‘μ„±ν•œ μƒνƒœ - if activeBottlesCount <= 0 { - // TODO: time μ„€μ • - let nextBottleLeftHours = userBottleInfo.nextBottlLeftHours - await send(.userStateFetchCompleted( - userState: .noBottle(time: nextBottleLeftHours ?? 0), - isDisableButton: true) - ) - } else { // λŒ€ν™” 쀑인 보틀이 μžˆλŠ” μƒνƒœ - await send(.userStateFetchCompleted( - userState: .hasActiveBottle(bottleCount: activeBottlesCount), - isDisableButton: false) - ) - } + isDisableButton: false)) + return } + + if userProfileStatus == .doneProfileImage && activeBottlesCount > 0 { + await send(.userStateFetchCompleted( + userState: .hasActiveBottle(bottleCount: activeBottlesCount), + isDisableButton: false)) + return + } + + await send(.userStateFetchCompleted( + userState: .noBottle(time: nextBottleLeftHours ?? 0), + isDisableButton: false)) } catch: { error, send in // TODO: μ—λŸ¬ 핸듀링 Log.error(error) if let authError = error as? DomainError.AuthError { switch authError { - case .needUpdateAppVersion: + case .invalidAppVersion: await send(.needUpdateAppVersionErrorOccured) } } @@ -151,7 +155,7 @@ extension SandBeachFeature { state.userState = userState state.isDisableIslandBottle = isDisableButton state.isLoading = false - return .none + return .send(.delegate(.sandBeachLoadCompleted)) case .writeButtonDidTapped: return .send(.delegate(.writeButtonDidTapped)) @@ -184,8 +188,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..07cb9a22 100644 --- a/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeach/SandBeachView.swift @@ -20,67 +20,72 @@ public struct SandBeachView: View { public var body: some View { WithPerceptionTracking { - GeometryReader { geo in - WithPerceptionTracking { - if store.userState == .none && store.isLoading { - LoadingIndicator() - } else { - VStack(spacing: 0) { - Spacer() - BottleImageView(type: .local(bottleImageSystem: .illustraition(.logo))) - .frame(width: 78.06, height: 20) - .padding(.top, geo.safeAreaInsets.top + 14) - .padding(.bottom, 38) - - WantedSansStyleText( - store.userState.title, style: .title1, color: .secondary) - .frame(height: 62) - .multilineTextAlignment(.center) - .padding(.bottom, 24) - Spacer() - - popup - .padding(.bottom, 8) - - BottleImageView(type: .local( - bottleImageSystem: - store.userState.isEmptyBottle ? .illustraition(.islandEmptyBottle) : .illustraition(.islandHasBottle)) - ) - .frame(width: geo.size.width) - .frame(height: geo.size.width) - .asThrottleButton { - if store.userState.isHasNewBottle { - store.send(.newBottleIslandDidTapped) - } - - if store.userState.isHasActiveBottle { - store.send(.bottleStorageIslandDidTapped) - } - } - .disabled(store.isDisableIslandBottle) - - Spacer() - } - } + if store.userState == .none && store.isLoading { + LoadingIndicator() + } else { + VStack(spacing: 0) { + Spacer() + .frame(height: 1) + logoImage + userStateTitle + popup + islandImage + Spacer() } } - .alert($store.scope(state: \.destination?.alert, action: \.destination.alert)) - .onAppear { - store.send(.onAppear) - } - .background { - BottleImageView( - type: .local(bottleImageSystem: .illustraition(.sandBeachBackground)) - ) - } } - .edgesIgnoringSafeArea([.top, .bottom]) + .bottleAlert($store.scope(state: \.destination?.alert, action: \.destination.alert)) + .onAppear { + store.send(.onAppear) + } + .background { + BottleImageView( + type: .local(bottleImageSystem: .illustraition(.sandBeachBackground)) + ) + .edgesIgnoringSafeArea(.all) + } } } // MARK: - Views public extension SandBeachView { + var logoImage: some View { + BottleImageView(type: .local(bottleImageSystem: .illustraition(.logo))) + .frame(width: 78.06, height: 20) + .padding(.top, 14) + .padding(.bottom, 46) + } + + var userStateTitle: some View { + WantedSansStyleText( + store.userState.title, style: .mainTitle, color: .secondary) + .multilineTextAlignment(.center) + .padding(.bottom, store.userState == .noIntroduction ? 32 : 64) + .lineSpacing(5) + } + + var islandImage: some View { + GeometryReader { geo in + BottleImageView(type: .local( + bottleImageSystem: + store.userState.isEmptyBottle ? .illustraition(.islandEmptyBottle) : .illustraition(.islandHasBottle)) + ) + .frame(width: geo.size.width) + .frame(height: geo.size.width) + .asThrottleButton { + if store.userState.isHasNewBottle { + store.send(.newBottleIslandDidTapped) + } else if store.userState.isHasActiveBottle { + store.send(.bottleStorageIslandDidTapped) + } else if store.userState != .noIntroduction { + store.send(.newBottleIslandDidTapped) + } + } + .disabled(store.isDisableIslandBottle) + } + } + @ViewBuilder var popup: some View { let userState = store.userState diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeature.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeature.swift new file mode 100644 index 00000000..a2ed0f81 --- /dev/null +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeature.swift @@ -0,0 +1,30 @@ +// +// SandBeachCoachMarkFeature.swift +// FeatureSandBeachInterface +// +// Created by μž„ν˜„κ·œ on 10/28/24. +// + +import ComposableArchitecture + +extension SandBeachCoachMarkFeature { + public init() { + let reducer = Reduce { state, action in + switch action { + case .coachMarkDidTapped: + state.count += 1 + + if state.count == 3 { + return .send(.delegate(.coachMarkDidCompleted)) + } else { + return .none + } + + case .delegate: + return .none + } + } + + self.init(reducer: reducer) + } +} diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeatureInterface.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeatureInterface.swift new file mode 100644 index 00000000..85043236 --- /dev/null +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkFeatureInterface.swift @@ -0,0 +1,38 @@ +// +// SandBeachCoachMarkFeatureInterface.swift +// FeatureSandBeachInterface +// +// Created by μž„ν˜„κ·œ on 10/28/24. +// + +import ComposableArchitecture + +@Reducer +public struct SandBeachCoachMarkFeature { + private let reducer: Reduce + + + public init(reducer: Reduce) { + self.reducer = reducer + } + + @ObservableState + public struct State: Equatable { + public var count: Int = 0 + + public init() {} + } + + public enum Action { + case coachMarkDidTapped + case delegate(Delegate) + + public enum Delegate { + case coachMarkDidCompleted + } + } + + public var body: some ReducerOf { + reducer + } +} diff --git a/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkView.swift b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkView.swift new file mode 100644 index 00000000..00788278 --- /dev/null +++ b/Projects/Feature/SandBeach/Interface/Sources/SandBeachCoachMark/SandBeachCoachMarkView.swift @@ -0,0 +1,105 @@ +// +// SandBeachCoachMarkView.swift +// FeatureSandBeachInterface +// +// Created by μž„ν˜„κ·œ on 10/28/24. +// + +import SwiftUI + +import SharedDesignSystem + +import ComposableArchitecture + +public struct SandBeachCoachMarkView: View { + @Perception.Bindable private var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ZStack { + Color.black.opacity(0.6) + .edgesIgnoringSafeArea(.all) + if store.count == 0 { + firstCoachMark + } else if store.count == 1 { + secondCoachMark + } else { + thirdCoachMark + } + } + .compositingGroup() + .asButton { + store.send(.coachMarkDidTapped) + } + } +} + +private extension SandBeachCoachMarkView { + var firstCoachMark: some View { + VStack(spacing: 0) { + Spacer() + .frame(height: 96) + + PopupView(popupType: .coachMark(content: "λ‚˜μ˜ 첫인상이 될\nμžκΈ°μ†Œκ°œλ₯Ό μž‘μ„±ν•΄μ£Όμ„Έμš”")) + + RoundedRectangle(cornerRadius: 20) + .frame(width: 267, height: 106) + .foregroundColor(.white) + .blendMode(.destinationOut) + .padding(.top, .xl) + + Spacer() + } + } + + var secondCoachMark: some View { + VStack(spacing: 0.0) { + Spacer() + .frame(height: 239) + + PopupView(popupType: .coachMark(content: "λ°”κ΅¬λ‹ˆλ₯Ό ν΄λ¦­ν•˜λ©΄\n보틀 속 μžκΈ°μ†Œκ°œλ₯Ό 읽어볼 수 μžˆμ–΄μš”")) + + GeometryReader { geo in + HStack(spacing: 0.0) { + Spacer() + RoundedRectangle(cornerRadius: 20) + .frame(width: geo.size.width - 200, height: geo.size.width - 200) + .foregroundColor(.white) + .blendMode(.destinationOut) + .padding(.top, .xl) + Spacer() + } + } + Spacer() + } + } + + var thirdCoachMark: some View { + GeometryReader { geo in + + VStack(spacing: 0.0) { + Spacer() + + PopupView(popupType: .coachMark(content: "κ°€μΉ˜κ΄€ 문닡을 μ‹œμž‘ν•œ 경우\nλ¬Έλ‹΅μ—μ„œ 확인할 수 μžˆμ–΄μš”")) + .offset(x: geo.size.width * 0.09) + + HStack(spacing: 0.0) { + Spacer() + RoundedRectangle(cornerRadius: 20) + .frame(width: 72, height: 72) + .foregroundColor(.white) + .blendMode(.destinationOut) + .padding(.top, .xl) + .offset(x: geo.size.width * 0.09) + Spacer() + + } + } + .offset(y: -34) + } + .ignoresSafeArea(.all, edges: .bottom) + } +} diff --git a/Projects/Feature/Sources/App/AppDelegateFeature.swift b/Projects/Feature/Sources/App/AppDelegateFeature.swift index 4233bede..0cbd733c 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.updatePushNotificationAllowStatusLocally(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..c2463401 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 @@ -18,6 +18,7 @@ import ComposableArchitecture @Reducer public struct SplashFeature { @Dependency(\.authClient) private var authClient + @Dependency(\.userClient) private var userClient @ObservableState public struct State: Equatable { @@ -59,14 +60,18 @@ public struct SplashFeature { switch action { case .onAppear: return .run { send in - try await authClient.checkUpdateVersion() + async let checkUpdateVersionTask: () = try await authClient.checkUpdateVersion() + async let updatePushNotificationAllowStatusTask: () = try await updatePushNotificationAllowStatusRemotely() + + let _ = try await (checkUpdateVersionTask, updatePushNotificationAllowStatusTask) + await send(.delegate(.initialCheckCompleted)) } catch: { error, send in Log.error(error) // TODO: Error handling if let authError = error as? DomainError.AuthError { switch authError { - case .needUpdateAppVersion: + case .invalidAppVersion: await send(.needUpdateAppVersionErrorOccured) } } @@ -92,8 +97,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) } @@ -101,6 +105,17 @@ public struct SplashFeature { case .alert, .delegate, .destination, .binding: return .none } + + @Sendable func updatePushNotificationAllowStatusRemotely() async throws { + let isNeed = await userClient.isNeedUpdatePushNotificationRemotely() + switch isNeed { + case let .need(isAllow): + try await userClient.updatePushNotificationAllowStatusRemotely(isAllow: isAllow) + + case .notNeed: + return + } + } } } 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..60833013 100644 --- a/Projects/Feature/Sources/TabView/MainTabView.swift +++ b/Projects/Feature/Sources/TabView/MainTabView.swift @@ -7,9 +7,10 @@ import SwiftUI +import FeatureSandBeachInterface +import FeatureGoodFeelingInterface import FeatureBottleStorageInterface import FeatureMyPageInterface -import FeatureSandBeachInterface import FeatureTabBarInterface import SharedDesignSystem @@ -30,11 +31,15 @@ public struct MainTabView: View { .tag(TabType.sandBeach) .toolbar(.hidden, for: .tabBar) + GoodFeelingRootView(store: store.scope(state: \.goodFeelingRoot, action: \.goodFeelingRoot)) + .tag(TabType.goodFeeling) + .toolbar(.hidden, for: .tabBar) + BottleStorageView(store: store.scope(state: \.bottleStorage, action: \.bottleStorage)) .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..df30a2a9 100644 --- a/Projects/Feature/Sources/TabView/MainTabViewFeature.swift +++ b/Projects/Feature/Sources/TabView/MainTabViewFeature.swift @@ -7,12 +7,14 @@ import Foundation +import FeatureSandBeach +import FeatureSandBeachInterface +import FeatureGoodFeeling +import FeatureGoodFeelingInterface import FeatureBottleStorage import FeatureBottleStorageInterface import FeatureMyPage import FeatureMyPageInterface -import FeatureSandBeach -import FeatureSandBeachInterface import FeatureTabBarInterface import ComposableArchitecture @@ -23,14 +25,16 @@ public struct MainTabViewFeature { @ObservableState public struct State: Equatable { var sandBeachRoot: SandBeachRootFeature.State + var goodFeelingRoot: GoodFeelingRootFeature.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.goodFeelingRoot = .init() self.bottleStorage = .init() - self.myPage = .init() + self.myPageRoot = .init() self.selectedTab = .sandBeach self.isLoading = false } @@ -38,8 +42,9 @@ public struct MainTabViewFeature { public enum Action: BindableAction { case sandBeachRoot(SandBeachRootFeature.Action) + case goodFeelingRoot(GoodFeelingRootFeature.Action) case bottleStorage(BottleStorageFeature.Action) - case myPage(MyPageFeature.Action) + case myPageRoot(MyPageRootFeature.Action) case selectedTabChanged(TabType) case binding(BindingAction) @@ -57,11 +62,14 @@ public struct MainTabViewFeature { Scope(state: \.sandBeachRoot, action: \.sandBeachRoot) { SandBeachRootFeature() } + Scope(state: \.goodFeelingRoot, action: \.goodFeelingRoot) { + GoodFeelingRootFeature() + } Scope(state: \.bottleStorage, action: \.bottleStorage) { BottleStorageFeature() } - Scope(state: \.myPage, action: \.myPage) { - MyPageFeature() + Scope(state: \.myPageRoot, action: \.myPageRoot) { + MyPageRootFeature() } Reduce(feature) } @@ -83,20 +91,31 @@ public struct MainTabViewFeature { case let .selectedTabDidChanged(selectedTab): state.selectedTab = selectedTab case .profileSetUpDidCompleted: - return .send(.myPage(.userProfileUpdateDidRequest)) + return .send(.myPageRoot(.userProfileUpdateDidRequest)) } return .none + // GoodFeeling Delegate + case let .goodFeelingRoot(.delegate(delegate)): + switch delegate { + case let .selectedTabDidChanged(selectedTab): + state.selectedTab = selectedTab + return .none + } + // BottleStorage Delegate case let .bottleStorage(.delegate(delegate)): switch delegate { case let .selectedTabDidChanged(selectedTab): state.selectedTab = selectedTab + return .none + case .sandBeachButtonDidTapped: + state.selectedTab = .sandBeach + return .send(.sandBeachRoot(.sandBeach(.newBottleIslandDidTapped))) } - 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/Feature/TabBar/Interface/Sources/TabBarModifier.swift b/Projects/Feature/TabBar/Interface/Sources/TabBarModifier.swift index 0c151aa6..c3f1f38a 100644 --- a/Projects/Feature/TabBar/Interface/Sources/TabBarModifier.swift +++ b/Projects/Feature/TabBar/Interface/Sources/TabBarModifier.swift @@ -42,7 +42,7 @@ private struct TabBarModifier: ViewModifier { color: selectedTab == item ? .primary : .enableTertiary ) } - .offset(y: -9) + .offset(y: -15) .asThrottleButton { action(item) } diff --git a/Projects/Feature/TabBar/Interface/Sources/TabType.swift b/Projects/Feature/TabBar/Interface/Sources/TabType.swift index b001961c..ffef7273 100644 --- a/Projects/Feature/TabBar/Interface/Sources/TabType.swift +++ b/Projects/Feature/TabBar/Interface/Sources/TabType.swift @@ -10,6 +10,7 @@ import SharedDesignSystem public enum TabType: Hashable, CaseIterable { case sandBeach + case goodFeeling case bottleStorage case myPage @@ -18,8 +19,11 @@ public enum TabType: Hashable, CaseIterable { case .sandBeach: return "λͺ¨λž˜μ‚¬μž₯" + case .goodFeeling: + return "호감" + case .bottleStorage: - return "보틀 보관함" + return "λ¬Έλ‹΅" case .myPage: return "λ§ˆμ΄νŽ˜μ΄μ§€" @@ -29,13 +33,16 @@ public enum TabType: Hashable, CaseIterable { var image: Image.BottleImageSystem { switch self { case .sandBeach: - return .icom(.sandBeach) + return .icon(.sandBeach) + + case .goodFeeling: + return .icon(.goodFeeling) case .bottleStorage: - return .icom(.bottleStorage) + return .icon(.talk) case .myPage: - return .icom(.myPage) + return .icon(.myPage) } } } diff --git a/Projects/Shared/DesignSystem/Example/Sources/DesignSystemExampleView/DesignSystemExampleView.swift b/Projects/Shared/DesignSystem/Example/Sources/DesignSystemExampleView/DesignSystemExampleView.swift index 5b83ef92..03aabafa 100644 --- a/Projects/Shared/DesignSystem/Example/Sources/DesignSystemExampleView/DesignSystemExampleView.swift +++ b/Projects/Shared/DesignSystem/Example/Sources/DesignSystemExampleView/DesignSystemExampleView.swift @@ -255,7 +255,14 @@ struct CardSection: View { label: { Text("StopCardView") } - ) + ) + + NavigationLink( + destination: BottleStorageList(), + label: { + Text("BottleStorageList") + } + ) // NavigationLink( // destination: // QuestionPingPongTestView(), @@ -329,6 +336,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/BottleStorageList.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/BottleStorageList.swift index 21207d91..5c664feb 100644 --- a/Projects/Shared/DesignSystem/Example/Sources/SubViews/BottleStorageList.swift +++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/BottleStorageList.swift @@ -75,11 +75,12 @@ struct BottleStorageList: View { var body: some View { VStack(spacing: 20.0) { ForEach(bottles, id: \.id) { bottle in - BottleStorageItem( + PingPongUserView( + status: "문닡이 λ„μ°©ν–ˆμ–΄μš”", + lastPingPongTime: "3μ‹œκ°„ μ „", userName: bottle.userName, age: bottle.age, mbti: bottle.mbti, - keywords: bottle.keyworkds, imageURL: bottle.imageURL, isRead: bottle.isRead ) 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/Example/Sources/SubViews/UserProfileTest/UserProfileTestView.swift b/Projects/Shared/DesignSystem/Example/Sources/SubViews/UserProfileTest/UserProfileTestView.swift index 17d2d093..86ef0c45 100644 --- a/Projects/Shared/DesignSystem/Example/Sources/SubViews/UserProfileTest/UserProfileTestView.swift +++ b/Projects/Shared/DesignSystem/Example/Sources/SubViews/UserProfileTest/UserProfileTestView.swift @@ -15,7 +15,6 @@ public struct UserProfileTestView: View { VStack(spacing: .xl) { UserProfileView( imageURL: "https://static.wikia.nocookie.net/wallaceandgromit/images/3/38/Gromit-3.png/revision/latest/scale-to-width/360?cb=20191228190308", - isBlurred: true, userName: "μž„ν˜„κ·œ", userAge: 26 ) @@ -23,7 +22,6 @@ public struct UserProfileTestView: View { UserProfileView( imageURL: "", - isBlurred: false, userName: "μž„ν˜„κ·œ", userAge: 26 ) diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_heart.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_heart.imageset/Contents.json new file mode 100644 index 00000000..3280bd66 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_heart.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "icon_heart.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_heart.imageset/icon_heart.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_heart.imageset/icon_heart.svg new file mode 100644 index 00000000..5c514d3c --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_heart.imageset/icon_heart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_talk.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_talk.imageset/Contents.json new file mode 100644 index 00000000..168012e1 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_talk.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "icon_talk.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_talk.imageset/icon_talk.svg b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_talk.imageset/icon_talk.svg new file mode 100644 index 00000000..c8971ba2 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Images.xcassets/icon/icon_talk.imageset/icon_talk.svg @@ -0,0 +1,8 @@ + + + + + + + + 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/Resources/Lotties.xcassets/Bottle_loading_ellipse.dataset/Bottle_loading_ellipse.json b/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/Bottle_loading_ellipse.dataset/Bottle_loading_ellipse.json new file mode 100644 index 00000000..c2b27b49 --- /dev/null +++ b/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/Bottle_loading_ellipse.dataset/Bottle_loading_ellipse.json @@ -0,0 +1 @@ +{"assets":[],"ddd":0,"fr":60,"h":100,"ip":0,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"progress","hd":false,"sr":1,"ks":{"a":{"a":0,"k":[30,30]},"o":{"a":0,"k":100},"p":{"a":0,"k":[50,50]},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":0.88,"y":0.77},"o":{"x":0.5,"y":0}},{"t":7.2,"s":[90],"i":{"x":0.75,"y":0.75},"o":{"x":0.25,"y":0.25}},{"t":14.4,"s":[180],"i":{"x":0.75,"y":0.75},"o":{"x":0.25,"y":0.25}},{"t":21.6,"s":[270],"i":{"x":0.75,"y":0.75},"o":{"x":0.25,"y":0.25}},{"t":28.8,"s":[360],"i":{"x":0,"y":0},"o":{"x":1,"y":1}}]},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}},"ao":0,"ip":0,"op":30,"st":0,"bm":0,"shapes":[{"ty":"el","hd":false,"nm":"progress","p":{"a":0,"k":[30,30]},"s":{"a":0,"k":[60,60]},"d":1},{"ty":"gs","hd":false,"bm":0,"o":{"a":0,"k":100},"e":{"a":0,"k":[66.00000236034398,25.500000196695332]},"g":{"p":2,"k":{"a":0,"k":[0,0.306,0.396,0.945,1,0.306,0.396,0.945,0,0,1,1]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":0,"k":[-2.93843085448443e-14,25.500000196695332]},"lc":2,"lj":3,"ml":28.96,"w":{"a":0,"k":10}},{"ty":"tm","hd":false,"bm":0,"e":{"a":0,"k":20},"o":{"a":0,"k":0},"s":{"a":0,"k":0},"m":1}]},{"ddd":0,"ind":2,"ty":4,"nm":"oval bg","hd":true,"sr":1,"ks":{"a":{"a":0,"k":[40,40]},"o":{"a":0,"k":100},"p":{"a":0,"k":[180,320]},"r":{"a":0,"k":0},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}},"ao":0,"ip":0,"op":30,"st":0,"bm":0,"shapes":[{"ty":"el","hd":true,"nm":"oval bg","p":{"a":0,"k":[40,40]},"s":{"a":0,"k":[80,80]},"d":1},{"ty":"st","hd":false,"bm":0,"c":{"a":0,"k":[0.961,0.961,0.961]},"lc":2,"lj":1,"ml":28.96,"o":{"a":0,"k":100},"w":{"a":0,"k":20}}]},{"ddd":0,"ind":3,"ty":4,"nm":"Screen","hd":false,"sr":1,"ks":{"a":{"a":0,"k":[50,50]},"o":{"a":0,"k":100},"p":{"a":0,"k":[50,50]},"r":{"a":0,"k":0},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}},"ao":0,"ip":0,"op":30,"st":0,"bm":0,"shapes":[{"ty":"gr","hd":false,"nm":"Screen Group","bm":0,"it":[{"ty":"rc","hd":false,"nm":"Screen","d":1,"p":{"a":0,"k":[50,50]},"r":{"a":0,"k":0},"s":{"a":0,"k":[100,100]}},{"ty":"fl","hd":false,"bm":0,"c":{"a":0,"k":[1,1,1]},"r":1,"o":{"a":0,"k":0}},{"ty":"tr","nm":"Transform","a":{"a":0,"k":[0,0]},"o":{"a":0,"k":100},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"s":{"a":0,"k":[100,100]},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"np":0}]}],"meta":{"g":"@phase-software/lottie-exporter 0.7.0"},"nm":"","op":29,"v":"5.6.0","w":100} \ No newline at end of file diff --git a/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/Contents.json b/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/Bottle_loading_ellipse.dataset/Contents.json similarity index 70% rename from Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/Contents.json rename to Projects/Shared/DesignSystem/Resources/Lotties.xcassets/Bottle_loading_ellipse.dataset/Contents.json index 74fb6963..f2d2dcdf 100644 --- a/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/Bottle_loading_ellipse.dataset/Contents.json @@ -1,7 +1,7 @@ { "data" : [ { - "filename" : "ProgressIndicator.lottie", + "filename" : "Bottle_loading_ellipse.json", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/ProgressIndicator.lottie b/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/ProgressIndicator.lottie deleted file mode 100644 index 5632421c..00000000 --- a/Projects/Shared/DesignSystem/Resources/Lotties.xcassets/ProgressIndicator.dataset/ProgressIndicator.lottie +++ /dev/null @@ -1 +0,0 @@ -{"v":"5.7.11","fr":60,"ip":0,"op":81,"w":1920,"h":1080,"nm":"Loading Dots","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Dot4","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":25,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":39,"s":[100]},{"t":55,"s":[25]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":25,"s":[1142,540,0],"to":null,"ti":null},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":39,"s":[1142,500,0],"to":null,"ti":null},{"t":55,"s":[1142,540,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-284,92,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":25,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":39,"s":[75,75,100]},{"t":55,"s":[50,50,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[120,120],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false,"_render":true},{"ty":"fl","c":{"a":0,"k":[0.7608,0.7608,0.7608,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false,"_render":true},{"ty":"tr","p":{"a":0,"k":[-284,92],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform","_render":true}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false,"_render":true}],"ip":0,"op":360,"st":0,"bm":0,"completed":true},{"ddd":0,"ind":2,"ty":4,"nm":"Dot3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":31,"s":[100]},{"t":47,"s":[25]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":17,"s":[1022,540,0],"to":null,"ti":null},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":31,"s":[1022,500,0],"to":null,"ti":null},{"t":47,"s":[1022,540,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-284,92,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":17,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":31,"s":[75,75,100]},{"t":47,"s":[50,50,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[120,120],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false,"_render":true},{"ty":"fl","c":{"a":0,"k":[0.7608,0.7608,0.7608,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false,"_render":true},{"ty":"tr","p":{"a":0,"k":[-284,92],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform","_render":true}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false,"_render":true}],"ip":0,"op":360,"st":0,"bm":0,"completed":true},{"ddd":0,"ind":3,"ty":4,"nm":"Dot2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":23,"s":[100]},{"t":39,"s":[25]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":9,"s":[902,540,0],"to":null,"ti":null},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":23,"s":[902,500,0],"to":null,"ti":null},{"t":39,"s":[902,540,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-284,92,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":9,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":23,"s":[75,75,100]},{"t":39,"s":[50,50,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[120,120],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false,"_render":true},{"ty":"fl","c":{"a":0,"k":[0.7608,0.7608,0.7608,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false,"_render":true},{"ty":"tr","p":{"a":0,"k":[-284,92],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform","_render":true}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false,"_render":true}],"ip":0,"op":360,"st":0,"bm":0,"completed":true},{"ddd":0,"ind":4,"ty":4,"nm":"Dot1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":14,"s":[100]},{"t":30,"s":[25]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[782,540,0],"to":null,"ti":null},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":14,"s":[782,500,0],"to":null,"ti":null},{"t":30,"s":[782,540,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-284,92,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":14,"s":[75,75,100]},{"t":30,"s":[50,50,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[120,120],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false,"_render":true},{"ty":"fl","c":{"a":0,"k":[0.7608,0.7608,0.7608,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false,"_render":true},{"ty":"tr","p":{"a":0,"k":[-284,92],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform","_render":true}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false,"_render":true}],"ip":0,"op":360,"st":0,"bm":0,"completed":true}],"markers":[],"__complete":true} \ No newline at end of file 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..a25aae16 --- /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: .icon(.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/OutlinedButton/OutlinedStyleButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/OutlinedButton/OutlinedStyleButton.swift index bfa51416..f6dba8b7 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/OutlinedButton/OutlinedStyleButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/OutlinedButton/OutlinedStyleButton.swift @@ -132,7 +132,7 @@ private extension OutlinedStyleButton { default: assertionFailure("Wrong Image Configuration") - return .local(bottleImageSystem: .icom(.siren)) + return .local(bottleImageSystem: .icon(.siren)) } case let .local(bottleImageSystem): diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/SegmentControlButton/SegmentControlButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/SegmentControlButton/SegmentControlButton.swift new file mode 100644 index 00000000..02498126 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/SegmentControlButton/SegmentControlButton.swift @@ -0,0 +1,55 @@ +// +// SegmentControlButton.swift +// SharedDesignSystem +// +// Created by μž„ν˜„κ·œ on 10/1/24. +// + +import SwiftUI + +public struct SegmentControlButton: View { + private let title: String + private let buttonType: ButtonType + private let isSelected: Bool? + private let _action: (() -> Void)? + private var action: () -> Void { + return _action ?? {} + } + + public init( + title: String, + buttonType: ButtonType, + isSelected: Bool? = nil, + action: (() -> Void)? = nil + ) { + self.title = title + self.buttonType = buttonType + self.isSelected = isSelected + self._action = action + } + + public var body: some View { + segmentControlButton + .buttonStyle(SegmentControlButtonStyle(isSelected: isSelected)) + } +} + +// MARK: - Private Views +private extension SegmentControlButton { + var titleText: some View { + Text(title) + .font(to: .wantedSans(.body)) + } + + @ViewBuilder + var segmentControlButton: some View { + switch buttonType { + case .debounce: + titleText.asDebounceButton(action: action) + case .throttle: + titleText.asThrottleButton(action: action) + case .normal: + titleText.asButton(action: action) + } + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/SegmentControlButton/SegmentControlButtonStyle.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/SegmentControlButton/SegmentControlButtonStyle.swift new file mode 100644 index 00000000..114ebd02 --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/SegmentControlButton/SegmentControlButtonStyle.swift @@ -0,0 +1,57 @@ +// +// SegmentControlButtonStyle.swift +// SharedDesignSystem +// +// Created by μž„ν˜„κ·œ on 10/1/24. +// + +import SwiftUI + +struct SegmentControlButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled: Bool + private let isSelected: Bool? + + public init(isSelected: Bool? = nil) { + self.isSelected = isSelected + } + + func makeBody(configuration: Configuration) -> some View { + let buttonState = makeButtonState(configuration) + + return configuration.label + .padding(.horizontal, .sm) + .frame(height: 42) + .foregroundStyle(foregroundColor(buttonState)) + .overlay(alignment: .bottom) { + switch buttonState { + case .enabled: + EmptyView() + case .selected: + Divider() + .frame(height: 2) + .background(to: ColorToken.border(.selected)) + case .disabled: + EmptyView() + } + } + } +} + +// MARK: - Private Methods +private extension SegmentControlButtonStyle { + func makeButtonState(_ configuration: Configuration) -> ButtonStateType { + return !isEnabled ? .disabled : configuration.isPressed || isSelected == true ? .selected : .enabled + } + + func foregroundColor(_ buttonState: ButtonStateType) -> Color { + switch buttonState { + case .enabled: + return ColorToken.text(.enableSecondary).color + case .selected: + return ColorToken.text(.selectPrimary).color + case .disabled: + return ColorToken.text(.disableSecondary).color + } + } +} + diff --git a/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButton.swift b/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButton.swift index 3720a565..8df94456 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Button/SolidButton/SolidButton.swift @@ -87,14 +87,16 @@ extension SolidButton { var image: some View { switch buttonApperance { case .kakao: - BottleImageView(type: .local(bottleImageSystem: .icom(.kakaoLogo))) + BottleImageView(type: .local(bottleImageSystem: .icon(.kakaoLogo))) case .apple: - BottleImageView(type: .local(bottleImageSystem: .icom(.appleLogo))) + BottleImageView(type: .local(bottleImageSystem: .icon(.appleLogo))) case .solid: 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/Card/PingPong/PingPongContainerView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongContainerView.swift index f77e13ea..a631779d 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongContainerView.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongContainerView.swift @@ -62,7 +62,7 @@ private extension PingPongContainer { Spacer() - LocalImageView(.icom(.up)) + LocalImageView(.icon(.up)) .rotationEffect(.degrees((isHidden ? -180 : 0))) .asButton { withAnimation(.snappy) { diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongUserView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongUserView.swift new file mode 100644 index 00000000..35c7558e --- /dev/null +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/PingPong/PingPongUserView.swift @@ -0,0 +1,137 @@ +// +// PingPongUserView.swift +// SharedDesignSystem +// +// Created by μž„ν˜„κ·œ on 9/28/24. +// + +import SwiftUI + +public struct PingPongUserView: View { + private let status: String + private let lastPingPongTime: String + private let userName: String + private let age: Int + private let mbti: String + private let imageURL: String + private let isRead: Bool + + public init( + status: String, + lastPingPongTime: String, + userName: String, + age: Int, + mbti: String, + imageURL: String, + isRead: Bool + ) { + self.status = status + self.lastPingPongTime = lastPingPongTime + self.userName = userName + self.age = age + self.mbti = mbti + self.imageURL = imageURL + self.isRead = isRead + } + + public var body: some View { + HStack(spacing: 0.0) { + VStack(alignment: .leading, spacing: 0.0) { + bottleStatus + HStack(spacing: .xxs) { + userNameText + activeDot + } + .padding(.bottom, .sm) + infos + } + Spacer() + userImage + } + .padding(.md) + .overlay( + RoundedRectangle(cornerRadius: 20.0) + .strokeBorder( + ColorToken.border(.primary).color, + lineWidth: 1.0 + ) + ) + } +} + +// MARK: - Private Views + +private extension PingPongUserView { + var userNameText: some View { + WantedSansStyleText( + userName, + style: .title2, + color: .secondary + ) + } + + @ViewBuilder + var activeDot: some View { + if isRead == false { + ColorToken.icon(.update).color + .frame(width: 4.0, height: 4.0) + .clipShape(Circle()) + } else { + EmptyView() + } + } + + var userImage: some View { + BlurImageView( + imageURL: imageURL, + downsamplingWidth: 60.0, + downsamplingHeight: 60.0 + ) + .frame(width: 48.0, height: 48.0) + .clipShape(Circle()) + } + + var infos: some View { + HStack(spacing: .xs) { + WantedSansStyleText( + "\(age)μ„Έ", + style: .caption, + color: .secondary + ) + + verticalSeparator + + WantedSansStyleText( + mbti, + style: .caption, + color: .secondary + ) + } + } + + var verticalSeparator: some View { + ColorToken.border(.secondary).color + .frame(width: 1.0, height: 12.0) + .padding(1.0) + } + + var bottleStatus: some View { + HStack(spacing: .xs) { + WantedSansStyleText( + status, + style: .caption, + color: .secondary + ) + verticalSeparator + WantedSansStyleText( + lastPingPongTime, + style: .caption, + color: .tertiary + ) + } + .padding(.xs) + .background(to: ColorToken.onContainer(.secondary)) + .cornerRadius(.xs, corenrs: .allCorners) + .padding(.bottom, .sm) + } +} diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/Stop/StopCardView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/Stop/StopCardView.swift index 558957cb..8842ba97 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Card/Stop/StopCardView.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/Stop/StopCardView.swift @@ -63,7 +63,7 @@ private extension StopCardView { // TODO: - 아직 λ””μžμΈ μ•ˆλ‚˜μ˜΄ λ‚˜μ˜€λ©΄ μˆ˜μ • var image: some View { LocalImageView(.illustraition(.loudspeark)) - .frame(width: 120) - .frame(height: 120) + .frame(width: 200) + .frame(height: 200) } } diff --git a/Projects/Shared/DesignSystem/Sources/Components/Card/UserProfile/UserProfileView.swift b/Projects/Shared/DesignSystem/Sources/Components/Card/UserProfile/UserProfileView.swift index 6ae44506..fbcc45fa 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Card/UserProfile/UserProfileView.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Card/UserProfile/UserProfileView.swift @@ -9,18 +9,15 @@ import SwiftUI public struct UserProfileView: View { private let imageURL: String - private let isBlurred: Bool private let userName: String private let userAge: Int public init( imageURL: String, - isBlurred: Bool, userName: String, userAge: Int ) { self.imageURL = imageURL - self.isBlurred = isBlurred self.userName = userName self.userAge = userAge } @@ -41,25 +38,13 @@ private extension UserProfileView { @ViewBuilder var profileImage: some View { - switch isBlurred { - case true: - BlurImageView( - imageURL: imageURL, - downsamplingWidth: 80.0, - downsamplingHeight: 80.0 - ) - .clipShape(Circle()) - .frame(width: 80.0, height: 80.0) - - case false: - RemoteImageView( - imageURL: imageURL, - downsamplingWidth: 80.0, - downsamplingHeight: 80.0 - ) - .clipShape(Circle()) - .frame(width: 80, height: 80) - } + RemoteImageView( + imageURL: imageURL, + downsamplingWidth: 80.0, + downsamplingHeight: 80.0 + ) + .clipShape(Circle()) + .frame(width: 80, height: 80) } var userNameText: some View { @@ -70,7 +55,7 @@ private extension UserProfileView { } var verticalLine: some View { - LocalImageView(.icom(.verticalLine)) + LocalImageView(.icon(.verticalLine)) .foregroundStyle(to: ColorToken.border(.secondary)) } diff --git a/Projects/Shared/DesignSystem/Sources/Components/ETC/ImagePickerButton.swift b/Projects/Shared/DesignSystem/Sources/Components/ETC/ImagePickerButton.swift index 8f06031c..9882812c 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/ETC/ImagePickerButton.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/ETC/ImagePickerButton.swift @@ -55,7 +55,7 @@ private extension ImagePickerButton { deleteButton } } else { - LocalImageView(.icom(.plus)) + LocalImageView(.icon(.plus)) .frame(width: width, height: width, alignment: .center) } } @@ -68,7 +68,7 @@ private extension ImagePickerButton { .clipShape(RoundedRectangle(cornerRadius: BottleRadiusType.xs.value)) .frame(width: 36, height: 36) .overlay { - LocalImageView(.icom(.clearDelete)) + LocalImageView(.icon(.clearDelete)) .asThrottleButton { self.selectedImage.removeAll() action() 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..a1f91696 --- /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: .icon(.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/ListItem/BottleStorageItem.swift b/Projects/Shared/DesignSystem/Sources/Components/ListItem/BottleStorageItem.swift index 85fe6d91..c98f873e 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/ListItem/BottleStorageItem.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/ListItem/BottleStorageItem.swift @@ -56,7 +56,7 @@ public struct BottleStorageItem: View { Spacer() - BlurImageView( + RemoteImageView( imageURL: imageURL, downsamplingWidth: 60.0, downsamplingHeight: 60.0 diff --git a/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupType.swift b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupType.swift index 0306e841..629f5efe 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupType.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupType.swift @@ -10,5 +10,5 @@ import Foundation public enum PopupType { case text(content: String) case button(content: String, buttonTitle: String) - + case coachMark(content: String) } diff --git a/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupView.swift b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupView.swift index b7b03e54..d0c78e30 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupView.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/Popup/PopupView.swift @@ -42,6 +42,8 @@ private extension PopupView { WantedSansStyleText(content, style: .subTitle2, color: .secondary) case .button(let content, _): WantedSansStyleText(content, style: .subTitle2, color: .secondary) + case .coachMark(let content): + WantedSansStyleText(content, style: .subTitle2, color: .secondary) } } @@ -69,6 +71,9 @@ private extension PopupView { .frame(width: 227) } .padding(.lg) + case .coachMark: + popupText + .padding(.lg) } } } @@ -84,8 +89,9 @@ private extension PopupView { var height: CGFloat { switch popupType { - case .button: return 106 - case .text: return 42 + case .button: return 106 + case .text: return 42 + case .coachMark: return 42 } } } diff --git a/Projects/Shared/DesignSystem/Sources/Components/TextField/LineTextField/LineTextField.swift b/Projects/Shared/DesignSystem/Sources/Components/TextField/LineTextField/LineTextField.swift index 8065f33b..1b0ebbcf 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/TextField/LineTextField/LineTextField.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/TextField/LineTextField/LineTextField.swift @@ -49,7 +49,7 @@ private extension LineTextField { if text.isEmpty { EmptyView() } else { - BottleImageView(type: .local(bottleImageSystem: .icom(.delete))) + BottleImageView(type: .local(bottleImageSystem: .icon(.delete))) .foregroundStyle(to: ColorToken.icon(.primary)) .asThrottleButton { self.text = "" diff --git a/Projects/Shared/DesignSystem/Sources/Components/TextField/LinesTextField/LinesTextField.swift b/Projects/Shared/DesignSystem/Sources/Components/TextField/LinesTextField/LinesTextField.swift index a8dd57db..ea971cb9 100644 --- a/Projects/Shared/DesignSystem/Sources/Components/TextField/LinesTextField/LinesTextField.swift +++ b/Projects/Shared/DesignSystem/Sources/Components/TextField/LinesTextField/LinesTextField.swift @@ -109,6 +109,10 @@ private extension LinesTextField { if newValue.count >= textLimit { text = String(text.prefix(textLimit)) } + + if newValue.count == 0 { + textFieldState = .enabled + } } } } 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/Font/BottleFontSystem+WantedSans.swift b/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+WantedSans.swift index 84339409..424a2ccf 100644 --- a/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+WantedSans.swift +++ b/Projects/Shared/DesignSystem/Sources/Font/BottleFontSystem+WantedSans.swift @@ -15,6 +15,7 @@ public extension Font.BottleFontSystem { case subTitle2 case body case caption + case mainTitle } } @@ -33,6 +34,8 @@ public extension Font.BottleFontSystem.WantedSans { return SharedDesignSystemFontFamily.WantedSans.medium.swiftUIFont(size: 14) case .caption: return SharedDesignSystemFontFamily.WantedSans.medium.swiftUIFont(size: 12) + case .mainTitle: + return SharedDesignSystemFontFamily.WantedSans.bold.swiftUIFont(size: 32) } } } diff --git a/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Icon.swift b/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Icon.swift index 999d320f..ecd5d6f1 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Icon.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/BottleImageSystem+Icon.swift @@ -22,8 +22,11 @@ public extension Image.BottleImageSystem { case kakaoLogo case sandBeach case bottleStorage + case goodFeeling case myPage case appleLogo + case warning + case talk } } @@ -60,11 +63,20 @@ public extension Image.BottleImageSystem.Icon { case .bottleStorage: return SharedDesignSystemAsset.Images.iconBottleStorage.swiftUIImage + case .goodFeeling: + return SharedDesignSystemAsset.Images.iconHeart.swiftUIImage + case .myPage: return SharedDesignSystemAsset.Images.iconMyPage.swiftUIImage case .appleLogo: return SharedDesignSystemAsset.Images.iconAppleLogo.swiftUIImage + + case .warning: + return SharedDesignSystemAsset.Images.iconWarning.swiftUIImage + + case .talk: + return SharedDesignSystemAsset.Images.iconTalk.swiftUIImage } } } diff --git a/Projects/Shared/DesignSystem/Sources/Image/Image+Extensions.swift b/Projects/Shared/DesignSystem/Sources/Image/Image+Extensions.swift index 28d6c5f6..8affd312 100644 --- a/Projects/Shared/DesignSystem/Sources/Image/Image+Extensions.swift +++ b/Projects/Shared/DesignSystem/Sources/Image/Image+Extensions.swift @@ -9,12 +9,12 @@ import SwiftUI public extension Image { enum BottleImageSystem: Imageable { - case icom(Icon) + case icon(Icon) case illustraition(Illustraition) public var image: Image { switch self { - case .icom(let icon): + case .icon(let icon): return icon.image case .illustraition(let illustraition): return illustraition.image @@ -23,7 +23,7 @@ public extension Image { public var description: String { switch self { - case .icom: + case .icon: return "icon" case .illustraition: return "illustraition" diff --git a/Projects/Shared/DesignSystem/Sources/Modifiers/NavigationBarModifier.swift b/Projects/Shared/DesignSystem/Sources/Modifiers/NavigationBarModifier.swift index aacd17ba..9cf2edb6 100644 --- a/Projects/Shared/DesignSystem/Sources/Modifiers/NavigationBarModifier.swift +++ b/Projects/Shared/DesignSystem/Sources/Modifiers/NavigationBarModifier.swift @@ -103,13 +103,13 @@ public extension View { public extension View { func makeNaivgationleftButton(action: (() -> Void)? = nil) -> some View { - BottleImageView(type: .local(bottleImageSystem: .icom(.leftArrow))) + BottleImageView(type: .local(bottleImageSystem: .icon(.leftArrow))) .foregroundStyle(to: ColorToken.icon(.primary)) .asThrottleButton(action: action ?? {}) } func makeNavigationReportButton(action: (() -> Void)? = nil) -> some View { - BottleImageView(type: .local(bottleImageSystem: .icom(.siren))) + BottleImageView(type: .local(bottleImageSystem: .icon(.siren))) .foregroundStyle(to: ColorToken.icon(.primary)) .asThrottleButton(action: action ?? {}) } diff --git a/Projects/Shared/DesignSystem/Sources/ProgressIndicator/LoadingIndicator.swift b/Projects/Shared/DesignSystem/Sources/ProgressIndicator/LoadingIndicator.swift index ff001aba..2e464026 100644 --- a/Projects/Shared/DesignSystem/Sources/ProgressIndicator/LoadingIndicator.swift +++ b/Projects/Shared/DesignSystem/Sources/ProgressIndicator/LoadingIndicator.swift @@ -15,12 +15,11 @@ public struct LoadingIndicator: View { public var body: some View { ZStack { - Color(.black) - .opacity(0.5) + ColorToken.background(.primary).color - LottieView(animation: try? .from(data: SharedDesignSystemAsset.Lotties.progressIndicator.data.data)) + LottieView(animation: try? .from(data: SharedDesignSystemAsset.Lotties.bottleLoadingEllipse.data.data)) .looping() - .frame(width: 150.0, height: 84.0) + .frame(width: 100.0, height: 100.0) } .ignoresSafeArea() } 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/Projects/Shared/Util/Interface/Sources/UtilInterface.swift b/Projects/Shared/Util/Interface/Sources/UtilInterface.swift deleted file mode 100644 index b5477d2b..00000000 --- a/Projects/Shared/Util/Interface/Sources/UtilInterface.swift +++ /dev/null @@ -1,5 +0,0 @@ -// This is for Tuist - -public protocol UtilInterface { - -} diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift index d1f8d7f0..3258a57d 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.10", + "CFBundleVersion": "37", "UIUserInterfaceStyle": "Light", "CFBundleName": "보틀", "UILaunchScreen": [ @@ -22,10 +22,15 @@ public extension InfoPlist { "UISupportedInterfaceOrientations": [ "UIInterfaceOrientationPortrait" ], + "NSContactsUsageDescription": "λ§€μΉ­ 차단 κΈ°λŠ₯을 μœ„ν•΄ μ—°λ½μ²˜κ°€ ν•„μš”ν•©λ‹ˆλ‹€. ν—ˆμš©ν•˜μ‹œλ©΄ μ—°λ½μ²˜κ°€ μ„œλ²„μ— μ—…λ‘œλ“œλ©λ‹ˆλ‹€.", + "NSCameraUsageDescription": "μΉ΄λ©”λΌλŠ” μžκΈ°μ†Œκ°œ 사진을 찍기 μœ„ν•΄ μ‚¬μš©λ©λ‹ˆλ‹€.", "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)", + "SLACK_WEBHOOK_URL": "$(SLACK_WEBHOOK_URL)", "LSApplicationQueriesSchemes": ["kakaokompassauth", "kakaotalk"], "CFBundleURLTypes": [ [ @@ -40,17 +45,21 @@ public extension InfoPlist { static var example: InfoPlist { return .extendingDefault(with: [ - "CFBundleShortVersionString": "1.0.7", - "CFBundleVersion": "27", + "CFBundleShortVersionString": "1.0.10", + "CFBundleVersion": "37", "UIUserInterfaceStyle": "Light", "UILaunchScreen": [:], "UISupportedInterfaceOrientations": [ "UIInterfaceOrientationPortrait" ], + "NSContactsUsageDescription": "λ§€μΉ­ 차단 κΈ°λŠ₯을 μœ„ν•΄ μ—°λ½μ²˜κ°€ ν•„μš”ν•©λ‹ˆλ‹€. ν—ˆμš©ν•˜μ‹œλ©΄ μ—°λ½μ²˜κ°€ μ„œλ²„μ— μ—…λ‘œλ“œλ©λ‹ˆλ‹€.", + "NSCameraUsageDescription": "μΉ΄λ©”λΌλŠ” μžκΈ°μ†Œκ°œ 사진을 찍기 μœ„ν•΄ μ‚¬μš©λ©λ‹ˆλ‹€.", "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": [ [