diff --git a/Projects/App/Sources/Application/AppDelegate.swift b/Projects/App/Sources/Application/AppDelegate.swift index d1f69e4..7828735 100644 --- a/Projects/App/Sources/Application/AppDelegate.swift +++ b/Projects/App/Sources/Application/AppDelegate.swift @@ -4,12 +4,14 @@ import LogMacro import Mixpanel import MixpanelSessionReplay import UIKit +import UserNotifications import WeaveDI import DomainInterface class AppDelegate: UIResponder, UIApplicationDelegate { let mixPanelKey = Bundle.main.object(forInfoDictionaryKey: "MIXPANEL_TOKEN") as? String + func application( _: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? @@ -29,14 +31,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { initializeMixpanelSessionReplay() MobileAds.shared.start() - // ๐Ÿง  ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ ์ดˆ๊ธฐํ™” (์šฐ์„ ) - ๋ชจ๋“ˆ ๋ถ„๋ฆฌ ์™„๋ฃŒ์‹œ ํ™œ์„ฑํ™” - // Task { @MainActor in - // _ = MemoryPressureManager.shared - // #if DEBUG - // _ = MemoryLeakDetector.shared - // print("๐Ÿš€ [AppDelegate] Memory management systems initialized") - // #endif - // } + // ๐Ÿ”” ํ‘ธ์‹œ ์•Œ๋ฆผ(APNs) ์ดˆ๊ธฐํ™” + configurePushNotifications() // DI ๊ด€๋ฆฌ์ž ์ดˆ๊ธฐํ™” WeaveDI.Container.bootstrapInTask { @DIContainerActor _ in @@ -68,6 +64,48 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didDiscardSceneSessions _: Set ) {} + // MARK: - Push Notifications (APNs) + + private func configurePushNotifications() { + let center = UNUserNotificationCenter.current() + center.delegate = self + + center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if let error { + #logError("[Push] ๊ถŒํ•œ ์š”์ฒญ ์‹คํŒจ: \(error.localizedDescription)") + return + } + guard granted else { + #logDebug("[Push] ์•Œ๋ฆผ ๊ถŒํ•œ ๊ฑฐ๋ถ€๋จ") + return + } + Task { @MainActor in + UIApplication.shared.registerForRemoteNotifications() + } + } + } + + // APNs ๋””๋ฐ”์ด์Šค ํ† ํฐ ์ˆ˜์‹  โ†’ ์ €์žฅ ํ›„ ๋กœ๊ทธ์ธ ์ƒํƒœ๋ฉด ์„œ๋ฒ„ ๋“ฑ๋ก. + func application( + _: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined() + PushTokenStore.current = tokenString + #logDebug("[Push] APNs ํ† ํฐ ์ˆ˜์‹ : \(tokenString.prefix(12))โ€ฆ") + + guard let keychainManager = UnifiedDI.resolve(KeychainManaging.self), + keychainManager.accessToken()?.isEmpty == false else { return } + Task { await PushTokenStore.register() } + } + + func application( + _: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + #logError("[Push] APNs ๋“ฑ๋ก ์‹คํŒจ: \(error.localizedDescription)") + } + // MARK: - Image Caching Configuration private func initializeMixpanelSessionReplay() { @@ -83,3 +121,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) } } + +// MARK: - UNUserNotificationCenterDelegate + +extension AppDelegate: @preconcurrency UNUserNotificationCenterDelegate { + // ํฌ๊ทธ๋ผ์šด๋“œ ์ˆ˜์‹  โ†’ ๋ฐฐ๋„ˆ/์‚ฌ์šด๋“œ ํ‘œ์‹œ. + func userNotificationCenter( + _: UNUserNotificationCenter, + willPresent _: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .badge, .sound]) + } + + // ์•Œ๋ฆผ ํƒญ โ†’ ํŽ˜์ด๋กœ๋“œ๋ฅผ ๋”ฅ๋งํฌ๋กœ ๋ณ€ํ™˜ํ•ด ๋ผ์šฐํŒ… ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ. + func userNotificationCenter( + _: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + PushDeeplinkBridge.handlePushPayload(userInfo) + completionHandler() + } +} diff --git a/Projects/App/Sources/Application/Deeplink/PushDeeplinkBridge.swift b/Projects/App/Sources/Application/Deeplink/PushDeeplinkBridge.swift new file mode 100644 index 0000000..d2bbe65 --- /dev/null +++ b/Projects/App/Sources/Application/Deeplink/PushDeeplinkBridge.swift @@ -0,0 +1,43 @@ +// +// PushDeeplinkBridge.swift +// Picke +// +// AppDelegate(ํ‘ธ์‹œ ํƒญ) โ†’ TCA(AppReducer) ์‚ฌ์ด ๋”ฅ๋งํฌ ์ „๋‹ฌ ๋ธŒ๋ฆฌ์ง€. (TimeSpot-iOS ํŒจํ„ด) +// ํŒŒ์‹ฑํ•œ ๋”ฅ๋งํฌ๋ฅผ NotificationCenter ๋กœ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธํ•˜๊ณ , ์ฝœ๋“œ ์Šคํƒ€ํŠธ ๋Œ€๋น„ UserDefaults ์—๋„ ๋ณด๊ด€. +// + +import Foundation + +import Entity +import LogMacro + +enum PushDeeplinkBridge { + static let pendingKey = "PickePendingDeeplink" + + /// ํ‘ธ์‹œ payload(userInfo) ๋ฅผ ๋”ฅ๋งํฌ๋กœ ๋ณ€ํ™˜ โ†’ ๋Œ€๊ธฐ์—ด ์ €์žฅ + ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ. + static func handlePushPayload(_ userInfo: [AnyHashable: Any]) { + guard let deeplink = PickeDeeplinkParser.parse(pushPayload: userInfo) else { + #logDebug("[Deeplink] ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅํ•œ ํ‘ธ์‹œ ํŽ˜์ด๋กœ๋“œ ์—†์Œ") + return + } + broadcast(deeplink) + } + + /// ๋”ฅ๋งํฌ๋ฅผ ๋Œ€๊ธฐ์—ด์— ์ €์žฅํ•˜๊ณ  ์ฆ‰์‹œ ์•Œ๋ฆผ. + static func broadcast(_ deeplink: PickeDeeplink) { + UserDefaults.standard.set(deeplink.encoded, forKey: pendingKey) + NotificationCenter.default.post( + name: .pickeDeeplink, + object: nil, + userInfo: ["deeplink": deeplink.encoded] + ) + #logDebug("[Deeplink] ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ: \(deeplink.encoded)") + } + + /// AppReducer ๊ฐ€ ๋ผ์šฐํŒ…์„ ๋๋‚ธ ๋’ค ๋Œ€๊ธฐ์—ด ๋น„์›€. + static func consumePending() -> PickeDeeplink? { + guard let encoded = UserDefaults.standard.string(forKey: pendingKey) else { return nil } + UserDefaults.standard.removeObject(forKey: pendingKey) + return PickeDeeplinkParser.parse(urlString: encoded) + } +} diff --git a/Projects/App/Sources/Application/PushTokenStore.swift b/Projects/App/Sources/Application/PushTokenStore.swift new file mode 100644 index 0000000..dfc3d1b --- /dev/null +++ b/Projects/App/Sources/Application/PushTokenStore.swift @@ -0,0 +1,44 @@ +// +// PushTokenStore.swift +// Picke +// +// APNs ๋””๋ฐ”์ด์Šค ํ† ํฐ ๋ณด๊ด€ + ์„œ๋ฒ„ ๋“ฑ๋ก/ํ•ด์ œ ํ—ฌํผ. (FCM ๋ฏธ์‚ฌ์šฉ) +// - current: ๋งˆ์ง€๋ง‰์œผ๋กœ ๋ฐœ๊ธ‰๋ฐ›์€ APNs ํ† ํฐ (UserDefaults ์บ์‹œ). +// - register/unregister: POST/DELETE /api/v1/devices (DeviceUseCase ๊ฒฝ์œ ). +// ๋กœ๊ทธ์•„์›ƒ ํ•ด์ œ๋Š” Keychain ์ดˆ๊ธฐํ™” ์ „์— ํ˜ธ์ถœํ•ด์•ผ ์ธ์ฆ ํ—ค๋”๊ฐ€ ์‚ด์•„์žˆ๋‹ค. +// + +import Foundation + +import Entity +import LogMacro +import UseCase + +enum PushTokenStore { + static var current: String? { + get { DeviceTokenStorage.token } + set { DeviceTokenStorage.token = newValue } + } + + /// ๋กœ๊ทธ์ธ ์งํ›„ / ํ† ํฐ ๊ฐฑ์‹  ์‹œ ๋””๋ฐ”์ด์Šค ๋“ฑ๋ก. + static func register() async { + guard let token = current, !token.isEmpty else { return } + do { + try await DeviceUseCaseImpl().registerDevice(fcmToken: token, platform: .ios) + #logDebug("[Push] ๋””๋ฐ”์ด์Šค ๋“ฑ๋ก ์™„๋ฃŒ") + } catch { + #logError("[Push] ๋””๋ฐ”์ด์Šค ๋“ฑ๋ก ์‹คํŒจ: \(error.localizedDescription)") + } + } + + /// ๋กœ๊ทธ์•„์›ƒ / ํƒˆํ‡ด ์‹œ ๋””๋ฐ”์ด์Šค ํ•ด์ œ (Keychain ์ดˆ๊ธฐํ™” ์ „์— ํ˜ธ์ถœ). + static func unregister() async { + guard let token = current, !token.isEmpty else { return } + do { + try await DeviceUseCaseImpl().unregisterDevice(fcmToken: token) + #logDebug("[Push] ๋””๋ฐ”์ด์Šค ํ•ด์ œ ์™„๋ฃŒ") + } catch { + #logError("[Push] ๋””๋ฐ”์ด์Šค ํ•ด์ œ ์‹คํŒจ: \(error.localizedDescription)") + } + } +} diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index 4800efa..246de39 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -43,6 +43,7 @@ public final class AppDIManager: Sendable { .register { AudioPlayerRepositoryImpl() as AudioPlayerInterface } .register { ProfileRepositoryImpl() as ProfileInterface } .register { NotificationRepositoryImpl() as NotificationInterface } + .register { DeviceRepositoryImpl() as DeviceInterface } // .register { AppUpdateRepositoryImpl() as AppUpdateInterface } // ๐Ÿ” OAuth Provider ๊ณ„์ธต (PFW ์กฐํ•ฉ ํŒจํ„ด) diff --git a/Projects/App/Sources/Reducer/AppReducer.swift b/Projects/App/Sources/Reducer/AppReducer.swift index d43f714..1def29d 100644 --- a/Projects/App/Sources/Reducer/AppReducer.swift +++ b/Projects/App/Sources/Reducer/AppReducer.swift @@ -63,6 +63,8 @@ public struct AppReducer: Sendable { public enum AsyncAction: Equatable { case startNotificationListener case refreshTokenExpired + case observeDeeplink + case deeplinkReceived(PickeDeeplink) } // MARK: - ๋„ค๋น„๊ฒŒ์ด์…˜ ์—ฐ๊ฒฐ ์•ก์…˜ @@ -85,6 +87,7 @@ public struct AppReducer: Sendable { case coordinator(CoordinatorType) case transition case refreshTokenListener + case deeplinkListener enum CoordinatorType: Hashable { case auth @@ -154,9 +157,12 @@ public struct AppReducer: Sendable { ) -> Effect { switch action { case .presentView: - return .run { send in - await send(.scope(.splash(.view(.onAppear)))) - } + return .merge( + .run { send in + await send(.scope(.splash(.view(.onAppear)))) + }, + observeDeeplink() + ) case .presentRoot: return startTransition(.completeMainTabTransition) @@ -167,7 +173,7 @@ public struct AppReducer: Sendable { } private func handleAsyncAction( - state _: inout State, + state: inout State, action: AsyncAction ) -> Effect { switch action { @@ -177,6 +183,24 @@ public struct AppReducer: Sendable { case .refreshTokenExpired: return startTransition(.completeAuthTransition) + + case .observeDeeplink: + return observeDeeplink() + + case let .deeplinkReceived(deeplink): + // ๋ฉ”์ธ ์ง„์ž… ์ƒํƒœ์—์„œ๋งŒ ์ฆ‰์‹œ ๋ผ์šฐํŒ…. ๊ทธ ์™ธ์—๋Š” pending(UserDefaults)์œผ๋กœ ๋ณด๋ฅ˜. + guard case .mainTab = state else { return .none } + switch deeplink { + case let .battle(battleId): + // ํ™ˆ ํƒญ ์ „ํ™˜ ํ›„ HomeCoordinator(Chat ๋ณด์œ )๊ฐ€ ๋ฐฐํ‹€ ์ƒ์„ธ push. + return .merge( + .send(.scope(.mainTab(.selectTab(MainTabCoordinator.Tab.home.rawValue)))), + .send(.scope(.mainTab(.home(.view(.openBattle(battleId: battleId)))))) + ) + case .perspective: + // ๊ด€์ (๋Œ“๊ธ€) ๋‹จ๋… ์ง„์ž…๋กœ๋Š” Chat ๋ชจ๋“ˆ์— perspectiveId ๊ธฐ๋ฐ˜ ์ง„์ž… ์ถ”๊ฐ€ ํ›„ ์—ฐ๊ฒฐ. + return .none + } } } @@ -191,6 +215,10 @@ public struct AppReducer: Sendable { case .completeMainTabTransition: state = .mainTab(.init()) + // ์ฝœ๋“œ ์Šคํƒ€ํŠธ/๋กœ๊ทธ์ธ ์งํ›„ ๋Œ€๊ธฐ ์ค‘์ด๋˜ ๋”ฅ๋งํฌ ์†Œ๋น„. + if let pending = PushDeeplinkBridge.consumePending() { + return .send(.async(.deeplinkReceived(pending))) + } return .none } } @@ -247,7 +275,11 @@ public struct AppReducer: Sendable { } case .auth(.navigation(.presentMainTab)): - return .send(.view(.presentRoot)) + // ๋กœ๊ทธ์ธ ์„ฑ๊ณต โ†’ ๋ฉ”์ธ ์ง„์ž… + APNs ๋””๋ฐ”์ด์Šค ํ† ํฐ ์„œ๋ฒ„ ๋“ฑ๋ก. + return .merge( + .send(.view(.presentRoot)), + .run { _ in await PushTokenStore.register() } + ) // ๋กœ๊ทธ์•„์›ƒ/ํƒˆํ‡ด โ†’ ๋กœ๊ทธ์ธ ํ™”๋ฉด์œผ๋กœ ๋ณต๊ท€ case .mainTab(.delegate(.sessionEnded)): @@ -263,6 +295,18 @@ public struct AppReducer: Sendable { return true } + /// ํ‘ธ์‹œ/์ธ์•ฑ ์•Œ๋ฆผ ํƒญ์œผ๋กœ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ๋œ ๋”ฅ๋งํฌ๋ฅผ ์ˆ˜์‹ ํ•ด ๋ผ์šฐํŒ… ์•ก์…˜์œผ๋กœ ๋ณ€ํ™˜. + private func observeDeeplink() -> Effect { + .run { send in + for await notification in NotificationCenter.default.notifications(named: .pickeDeeplink) { + guard let encoded = notification.userInfo?["deeplink"] as? String, + let deeplink = PickeDeeplinkParser.parse(urlString: encoded) else { continue } + await send(.async(.deeplinkReceived(deeplink))) + } + } + .cancellable(id: CancelID.deeplinkListener, cancelInFlight: true) + } + private func setupRefreshTokenExpiredListener() -> Effect { return .publisher { NotificationCenter.default diff --git a/Projects/Data/API/Sources/Base/PieckeDomain.swift b/Projects/Data/API/Sources/Base/PieckeDomain.swift index 27654d8..1c38990 100644 --- a/Projects/Data/API/Sources/Base/PieckeDomain.swift +++ b/Projects/Data/API/Sources/Base/PieckeDomain.swift @@ -19,6 +19,7 @@ public enum PieckeDomain { case perspective case search case notification + case device } extension PieckeDomain: DomainType { @@ -46,6 +47,8 @@ extension PieckeDomain: DomainType { return "api/v1/search/" case .notification: return "api/v1/notifications" + case .device: + return "api/v1/devices" } } } diff --git a/Projects/Data/Model/Sources/Notification/DTO/NotificationDataDTO.swift b/Projects/Data/Model/Sources/Notification/DTO/NotificationDataDTO.swift index 68eac91..7e9d533 100644 --- a/Projects/Data/Model/Sources/Notification/DTO/NotificationDataDTO.swift +++ b/Projects/Data/Model/Sources/Notification/DTO/NotificationDataDTO.swift @@ -19,6 +19,8 @@ public struct NotificationItemDTO: Decodable { public let title: String? public let body: String? public let referenceId: Int? + /// COMMENT_LIKE / NEW_COMMENT ์—์„œ ๊ด€์ (๋Œ“๊ธ€) ํ™”๋ฉด ์ด๋™์šฉ. ๊ทธ ์™ธ null. + public let perspectiveId: Int? public let isRead: Bool? public let createdAt: String? } @@ -31,6 +33,8 @@ public struct NotificationDetailDTO: Decodable { public let title: String? public let body: String? public let referenceId: Int? + /// COMMENT_LIKE / NEW_COMMENT ์—์„œ ๊ด€์ (๋Œ“๊ธ€) ํ™”๋ฉด ์ด๋™์šฉ. ๊ทธ ์™ธ null. + public let perspectiveId: Int? public let isRead: Bool? public let createdAt: String? public let readAt: String? diff --git a/Projects/Data/Model/Sources/Notification/Mapper/NotificationDataDTO+.swift b/Projects/Data/Model/Sources/Notification/Mapper/NotificationDataDTO+.swift index 87a0372..6074cc7 100644 --- a/Projects/Data/Model/Sources/Notification/Mapper/NotificationDataDTO+.swift +++ b/Projects/Data/Model/Sources/Notification/Mapper/NotificationDataDTO+.swift @@ -24,6 +24,7 @@ public extension NotificationItemDTO { title: title ?? "", body: body ?? "", referenceId: referenceId, + perspectiveId: perspectiveId, isRead: isRead ?? false, createdAt: createdAt.flatMap(NotificationDateParser.parseISO8601) ) @@ -39,6 +40,7 @@ public extension NotificationDetailDTO { title: title ?? "", body: body ?? "", referenceId: referenceId, + perspectiveId: perspectiveId, isRead: isRead ?? false, createdAt: createdAt.flatMap(NotificationDateParser.parseISO8601), readAt: readAt.flatMap(NotificationDateParser.parseISO8601) diff --git a/Projects/Data/Repository/Sources/Device/DeviceRepositoryImpl.swift b/Projects/Data/Repository/Sources/Device/DeviceRepositoryImpl.swift new file mode 100644 index 0000000..521cd2c --- /dev/null +++ b/Projects/Data/Repository/Sources/Device/DeviceRepositoryImpl.swift @@ -0,0 +1,45 @@ +// +// DeviceRepositoryImpl.swift +// Repository +// +// FCM ๋””๋ฐ”์ด์Šค ํ† ํฐ ๋“ฑ๋ก/ํ•ด์ œ โ€” POST/DELETE /api/v1/devices. +// + +import Foundation + +import DomainInterface +import Entity +import Model +import Service + +import LogMacro +import Moya + +@preconcurrency import AsyncMoya + +public final class DeviceRepositoryImpl: DeviceInterface, @unchecked Sendable { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.authorized + ) { + self.provider = provider + } + + public func registerDevice(fcmToken: String, platform: DevicePlatform) async throws { + let _: BaseResponseDTO = try await provider.request( + .register( + body: DeviceRegisterRequest( + fcmToken: fcmToken, + platform: platform.rawValue + ) + ) + ) + } + + public func unregisterDevice(fcmToken: String) async throws { + let _: BaseResponseDTO = try await provider.request( + .unregister(fcmToken: fcmToken) + ) + } +} diff --git a/Projects/Data/Service/Sources/Device/DeviceRegisterRequest.swift b/Projects/Data/Service/Sources/Device/DeviceRegisterRequest.swift new file mode 100644 index 0000000..fa99740 --- /dev/null +++ b/Projects/Data/Service/Sources/Device/DeviceRegisterRequest.swift @@ -0,0 +1,18 @@ +// +// DeviceRegisterRequest.swift +// Service +// +// POST /api/v1/devices ์š”์ฒญ ๋ฐ”๋”” (camelCase ๋„๋ฉ”์ธ). +// + +import Foundation + +public struct DeviceRegisterRequest: Encodable { + public let fcmToken: String + public let platform: String + + public init(fcmToken: String, platform: String) { + self.fcmToken = fcmToken + self.platform = platform + } +} diff --git a/Projects/Data/Service/Sources/Device/DeviceService.swift b/Projects/Data/Service/Sources/Device/DeviceService.swift new file mode 100644 index 0000000..59ecc34 --- /dev/null +++ b/Projects/Data/Service/Sources/Device/DeviceService.swift @@ -0,0 +1,70 @@ +// +// DeviceService.swift +// Service +// +// FCM ๋””๋ฐ”์ด์Šค ํ† ํฐ ๋“ฑ๋ก/ํ•ด์ œ โ€” POST/DELETE /api/v1/devices. +// - register: JSON ๋ฐ”๋”” (fcmToken, platform) +// - unregister: ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ (fcmToken) +// + +import Foundation + +import API +import Foundations + +import AsyncMoya + +public enum DeviceService { + /// POST /api/v1/devices + case register(body: DeviceRegisterRequest) + /// DELETE /api/v1/devices?fcmToken=... + case unregister(fcmToken: String) +} + +extension DeviceService: BaseTargetType { + public typealias Domain = PieckeDomain + + public var domain: PieckeDomain { .device } + + public var urlPath: String { "" } + + public var error: [Int: AsyncMoya.NetworkError]? { nil } + + public var method: Moya.Method { + switch self { + case .register: + return .post + case .unregister: + return .delete + } + } + + public var parameters: [String: Any]? { + switch self { + case let .register(body): + return body.toDictionary + case let .unregister(fcmToken): + return ["fcmToken": fcmToken] + } + } + + /// unregister ๋Š” ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ, register ๋Š” JSON ๋ฐ”๋””๋กœ ์ธ์ฝ”๋”ฉ. + public var task: Moya.Task { + switch self { + case let .register(body): + return .requestParameters( + parameters: body.toDictionary ?? [:], + encoding: JSONEncoding.default + ) + case let .unregister(fcmToken): + return .requestParameters( + parameters: ["fcmToken": fcmToken], + encoding: URLEncoding.queryString + ) + } + } + + public var headers: [String: String]? { + APIHeader.baseHeader + } +} diff --git a/Projects/Data/Service/Sources/Notification/NotificationService.swift b/Projects/Data/Service/Sources/Notification/NotificationService.swift index 5eb4a9b..ef53011 100644 --- a/Projects/Data/Service/Sources/Notification/NotificationService.swift +++ b/Projects/Data/Service/Sources/Notification/NotificationService.swift @@ -42,7 +42,7 @@ extension NotificationService: BaseTargetType { case .list, .detail: return .get case .read, .readAll: - return .post + return .patch } } diff --git a/Projects/Domain/DomainInterface/Sources/Device/DeviceInterface.swift b/Projects/Domain/DomainInterface/Sources/Device/DeviceInterface.swift new file mode 100644 index 0000000..b774c2a --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Device/DeviceInterface.swift @@ -0,0 +1,42 @@ +// +// DeviceInterface.swift +// DomainInterface +// +// FCM ๋””๋ฐ”์ด์Šค ํ† ํฐ ๋“ฑ๋ก/ํ•ด์ œ Repository ์ธํ„ฐํŽ˜์ด์Šค. +// + +import Entity +import Foundation +import WeaveDI + +public protocol DeviceInterface: Sendable { + /// ๋กœ๊ทธ์ธ ์งํ›„ / ํ† ํฐ ๊ฐฑ์‹  ์‹œ FCM ํ† ํฐ ๋“ฑ๋ก. + func registerDevice(fcmToken: String, platform: DevicePlatform) async throws + /// ๋กœ๊ทธ์•„์›ƒ ์‹œ FCM ํ† ํฐ ํ•ด์ œ (idempotent). + func unregisterDevice(fcmToken: String) async throws +} + +public struct DefaultDeviceRepositoryImpl: DeviceInterface { + public init() {} + public func registerDevice(fcmToken _: String, platform _: DevicePlatform) async throws {} + public func unregisterDevice(fcmToken _: String) async throws {} +} + +public struct DeviceRepositoryDependency: DependencyKey { + public static var liveValue: DeviceInterface { + UnifiedDI.resolve(DeviceInterface.self) ?? DefaultDeviceRepositoryImpl() + } + + public static var testValue: DeviceInterface { + UnifiedDI.resolve(DeviceInterface.self) ?? DefaultDeviceRepositoryImpl() + } + + public static var previewValue: DeviceInterface = liveValue +} + +public extension DependencyValues { + var deviceRepository: DeviceInterface { + get { self[DeviceRepositoryDependency.self] } + set { self[DeviceRepositoryDependency.self] = newValue } + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Notification/DefaultNotificationRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Notification/DefaultNotificationRepositoryImpl.swift index 3fdebbc..2327e41 100644 --- a/Projects/Domain/DomainInterface/Sources/Notification/DefaultNotificationRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Notification/DefaultNotificationRepositoryImpl.swift @@ -25,6 +25,7 @@ public struct DefaultNotificationRepositoryImpl: NotificationInterface { title: "", body: "", referenceId: nil, + perspectiveId: nil, isRead: false, createdAt: nil, readAt: nil diff --git a/Projects/Domain/Entity/Sources/Deeplink/PickeDeeplink.swift b/Projects/Domain/Entity/Sources/Deeplink/PickeDeeplink.swift new file mode 100644 index 0000000..7bb51a2 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Deeplink/PickeDeeplink.swift @@ -0,0 +1,106 @@ +// +// PickeDeeplink.swift +// Picke +// +// ์•Œ๋ฆผ โ†’ ํ™”๋ฉด ์ด๋™ ๋ชฉ์ ์ง€. (TimeSpot-iOS DeeplinkRouter ํŒจํ„ด) +// ํ‘ธ์‹œ data ํŽ˜์ด๋กœ๋“œ / ์œ ๋‹ˆ๋ฒ„์„ค ๋งํฌ URL / ์ธ์•ฑ ์•Œ๋ฆผ(detailCode) ์„ธ ๊ฒฝ๋กœ๋ฅผ ํ•˜๋‚˜๋กœ ํก์ˆ˜. +// + +import Foundation + +public enum PickeDeeplink: Equatable, Sendable { + /// NEW_BATTLE โ†’ ๋ฐฐํ‹€ ์ƒ์„ธ. + case battle(battleId: Int) + /// COMMENT_LIKE / NEW_COMMENT โ†’ ๊ด€์  ํ™”๋ฉด + commentId ์Šคํฌ๋กค/ํ•˜์ด๋ผ์ดํŠธ. + case perspective(perspectiveId: Int, commentId: Int?) + + /// ์ฝœ๋“œ ์Šคํƒ€ํŠธ ๋Œ€๊ธฐ ๋”ฅ๋งํฌ ์ €์žฅ์šฉ ๋ฌธ์ž์—ด ์ธ์ฝ”๋”ฉ (PickeDeeplinkParser.parse(urlString:) ๋กœ ๋ณต์›). + public var encoded: String { + switch self { + case let .battle(battleId): + return "battle/\(battleId)" + case let .perspective(perspectiveId, commentId): + if let commentId { + return "perspective/\(perspectiveId)?commentId=\(commentId)" + } + return "perspective/\(perspectiveId)" + } + } +} + +public enum PickeDeeplinkParser { + /// ํ‘ธ์‹œ payload(userInfo) โ†’ ๋”ฅ๋งํฌ. iOS ๋Š” notification+data ๋ผ top-level ์— data ํ‚ค๊ฐ€ ์กด์žฌ. + public static func parse(pushPayload userInfo: [AnyHashable: Any]) -> PickeDeeplink? { + switch userInfo["type"] as? String { + case "BATTLE": + if let battleId = intValue(userInfo["battleId"]) { + return .battle(battleId: battleId) + } + case "COMMENT": + if let perspectiveId = intValue(userInfo["perspectiveId"]) { + return .perspective( + perspectiveId: perspectiveId, + commentId: intValue(userInfo["commentId"]) + ) + } + default: + break + } + // fallback: url(์œ ๋‹ˆ๋ฒ„์„ค ๋งํฌ) ํŒŒ์‹ฑ. + if let url = userInfo["url"] as? String { + return parse(urlString: url) + } + return nil + } + + /// https://picke.store/battle/55 ยท https://picke.store/perspective/45?commentId=678 + public static func parse(urlString: String) -> PickeDeeplink? { + guard let components = URLComponents(string: urlString) else { return nil } + let path = components.path.split(separator: "/").map(String.init) + guard path.count >= 2 else { return nil } + + switch path[0] { + case "battle", "battles": + if let battleId = Int(path[1]) { return .battle(battleId: battleId) } + case "perspective", "perspectives": + if let perspectiveId = Int(path[1]) { + let commentId = components.queryItems? + .first { $0.name == "commentId" }?.value + .flatMap(Int.init) + return .perspective(perspectiveId: perspectiveId, commentId: commentId) + } + default: + break + } + return nil + } + + /// ์ธ์•ฑ ์•Œ๋ฆผํ•จ ํ•ญ๋ชฉ(detailCode) โ†’ ๋”ฅ๋งํฌ. + public static func parse( + detailCode: String, + referenceId: Int?, + perspectiveId: Int? + ) -> PickeDeeplink? { + switch detailCode { + case "NEW_BATTLE": + return referenceId.map { .battle(battleId: $0) } + case "COMMENT_LIKE", "NEW_COMMENT": + return perspectiveId.map { .perspective(perspectiveId: $0, commentId: referenceId) } + default: + // CREDIT_EARNED / POLICY_CHANGE / PROMOTION ๋“ฑ์€ ์ด๋™ ์—†์Œ. + return nil + } + } + + /// FCM/APNs data ๊ฐ’์€ ๋ฌธ์ž์—ด์ผ ์ˆ˜ ์žˆ์–ด String/Int ๋ชจ๋‘ ํ—ˆ์šฉ. + private static func intValue(_ value: Any?) -> Int? { + if let int = value as? Int { return int } + if let string = value as? String { return Int(string) } + return nil + } +} + +public extension Notification.Name { + /// ํ‘ธ์‹œ/์ธ์•ฑ ์•Œ๋ฆผ ํƒญ์œผ๋กœ ๋ฐœ์ƒํ•œ ํ™”๋ฉด ์ด๋™ ์š”์ฒญ. userInfo["deeplink"] = PickeDeeplink.encoded + static let pickeDeeplink = Notification.Name("PickeDeeplink") +} diff --git a/Projects/Domain/Entity/Sources/Device/DevicePlatform.swift b/Projects/Domain/Entity/Sources/Device/DevicePlatform.swift new file mode 100644 index 0000000..f79ab47 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Device/DevicePlatform.swift @@ -0,0 +1,13 @@ +// +// DevicePlatform.swift +// Entity +// +// FCM ๋””๋ฐ”์ด์Šค ๋“ฑ๋ก ํ”Œ๋žซํผ โ€” POST /api/v1/devices `platform`. +// + +import Foundation + +public enum DevicePlatform: String, Equatable, Sendable { + case ios = "IOS" + case android = "ANDROID" +} diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationDetail.swift b/Projects/Domain/Entity/Sources/Notification/NotificationDetail.swift index f06b0d3..a18b047 100644 --- a/Projects/Domain/Entity/Sources/Notification/NotificationDetail.swift +++ b/Projects/Domain/Entity/Sources/Notification/NotificationDetail.swift @@ -14,6 +14,8 @@ public struct NotificationDetail: Equatable, Identifiable { public let title: String public let body: String public let referenceId: Int? + /// COMMENT_LIKE / NEW_COMMENT ์—์„œ ๊ด€์ (๋Œ“๊ธ€) ํ™”๋ฉด ์ด๋™์šฉ. ๊ทธ ์™ธ nil. + public let perspectiveId: Int? public let isRead: Bool public let createdAt: Date? public let readAt: Date? @@ -27,6 +29,7 @@ public struct NotificationDetail: Equatable, Identifiable { title: String, body: String, referenceId: Int?, + perspectiveId: Int?, isRead: Bool, createdAt: Date?, readAt: Date? @@ -37,6 +40,7 @@ public struct NotificationDetail: Equatable, Identifiable { self.title = title self.body = body self.referenceId = referenceId + self.perspectiveId = perspectiveId self.isRead = isRead self.createdAt = createdAt self.readAt = readAt diff --git a/Projects/Domain/Entity/Sources/Notification/NotificationItem.swift b/Projects/Domain/Entity/Sources/Notification/NotificationItem.swift index 3ff2d98..36ea58e 100644 --- a/Projects/Domain/Entity/Sources/Notification/NotificationItem.swift +++ b/Projects/Domain/Entity/Sources/Notification/NotificationItem.swift @@ -16,8 +16,10 @@ public struct NotificationItem: Equatable, Identifiable { public let title: String /// ๋ณธ๋ฌธ (์˜ˆ: "โ€œAI๊ฐ€ ๋งŒ๋“  ๊ทธ๋ฆผ๋„ ์˜ˆ์ˆ ์ธ๊ฐ€?โ€์— ์ง€๊ธˆ ์ฐธ์—ฌํ•ด๋ณด์„ธ์š”!"). public let body: String - /// ์—ฐ๊ด€ ๋ฆฌ์†Œ์Šค id (๋ฐฐํ‹€/๊ณต์ง€ ๋“ฑ). ์—†์„ ์ˆ˜ ์žˆ์Œ. + /// ์—ฐ๊ด€ ๋ฆฌ์†Œ์Šค id (๋ฐฐํ‹€=battleId / ๋‹ต๊ธ€=commentId ๋“ฑ). ์—†์„ ์ˆ˜ ์žˆ์Œ. public let referenceId: Int? + /// COMMENT_LIKE / NEW_COMMENT ์—์„œ ๊ด€์ (๋Œ“๊ธ€) ํ™”๋ฉด ์ด๋™์šฉ. ๊ทธ ์™ธ nil. + public let perspectiveId: Int? public let isRead: Bool public let createdAt: Date? @@ -30,6 +32,7 @@ public struct NotificationItem: Equatable, Identifiable { title: String, body: String, referenceId: Int?, + perspectiveId: Int?, isRead: Bool, createdAt: Date? ) { @@ -39,6 +42,7 @@ public struct NotificationItem: Equatable, Identifiable { self.title = title self.body = body self.referenceId = referenceId + self.perspectiveId = perspectiveId self.isRead = isRead self.createdAt = createdAt } @@ -62,6 +66,7 @@ public struct NotificationItem: Equatable, Identifiable { title: title, body: body, referenceId: referenceId, + perspectiveId: perspectiveId, isRead: true, createdAt: createdAt ) diff --git a/Projects/Domain/UseCase/Sources/Device/DeviceUseCase.swift b/Projects/Domain/UseCase/Sources/Device/DeviceUseCase.swift new file mode 100644 index 0000000..1590fe1 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Device/DeviceUseCase.swift @@ -0,0 +1,50 @@ +// +// DeviceUseCase.swift +// UseCase +// +// FCM ๋””๋ฐ”์ด์Šค ํ† ํฐ ๋“ฑ๋ก/ํ•ด์ œ UseCase. +// + +import Foundation + +import DomainInterface +import Entity + +import ComposableArchitecture + +/// APNs ๋””๋ฐ”์ด์Šค ํ† ํฐ ๋ณด๊ด€์†Œ (UserDefaults). App(์ˆ˜์‹ )ยทPresentation(๋กœ๊ทธ์•„์›ƒ ํ•ด์ œ) ๊ณต์šฉ. +public enum DeviceTokenStorage { + private static let key = "PickeDeviceToken" + + public static var token: String? { + get { UserDefaults.standard.string(forKey: key) } + set { UserDefaults.standard.set(newValue, forKey: key) } + } +} + +public struct DeviceUseCaseImpl: DeviceInterface { + @Dependency(\.deviceRepository) private var deviceRepository + + public init() {} + + public func registerDevice(fcmToken: String, platform: DevicePlatform) async throws { + try await deviceRepository.registerDevice(fcmToken: fcmToken, platform: platform) + } + + public func unregisterDevice(fcmToken: String) async throws { + try await deviceRepository.unregisterDevice(fcmToken: fcmToken) + } +} + +extension DeviceUseCaseImpl: DependencyKey { + public static var liveValue = DeviceUseCaseImpl() + public static var testValue = DeviceUseCaseImpl() + public static var previewValue = DeviceUseCaseImpl() +} + +public extension DependencyValues { + var deviceUseCase: DeviceUseCaseImpl { + get { self[DeviceUseCaseImpl.self] } + set { self[DeviceUseCaseImpl.self] = newValue } + } +} diff --git a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift index 0dbe160..4b5d948 100644 --- a/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift +++ b/Projects/Presentation/Home/Sources/Coordinator/Reducer/HomeCoordinator.swift @@ -38,6 +38,8 @@ public struct HomeCoordinator { public enum View { case backAction case backToRootAction + /// ๋”ฅ๋งํฌ(์•Œ๋ฆผ) โ†’ ๋ฐฐํ‹€ ์ƒ์„ธ(์ฑ„ํŒ…) ์ง„์ž…. + case openBattle(battleId: Int) } public enum AsyncAction: Equatable {} @@ -101,6 +103,11 @@ extension HomeCoordinator { case .backToRootAction: state.routes.goBackToRoot() return .none + case let .openBattle(battleId): + // ์ค‘๋ณต ์Šคํƒ ๋ฐฉ์ง€ ํ›„ ๋ฐฐํ‹€(์ฑ„ํŒ…) ์ƒ์„ธ ์ง„์ž…. + state.routes.goBackToRoot() + state.routes.push(.chat(.init(battleId: battleId))) + return .none } } } diff --git a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift index 5e59675..e701013 100644 --- a/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift +++ b/Projects/Presentation/MainTab/Sources/Reducer/MainTabCoordinator.swift @@ -126,10 +126,14 @@ public struct MainTabCoordinator { state.selectedTab = state.previousTab return .none - // ์„ค์ • โ†’ ๋กœ๊ทธ์•„์›ƒ/ํƒˆํ‡ด ์™„๋ฃŒ โ†’ App ์œผ๋กœ ์„ธ์…˜ ์ข…๋ฃŒ ์ „ํŒŒ + // ์„ค์ • โ†’ ๋กœ๊ทธ์•„์›ƒ ์™„๋ฃŒ โ†’ App ์œผ๋กœ ์„ธ์…˜ ์ข…๋ฃŒ ์ „ํŒŒ case .myPage(.router(.routeAction(_, action: .settings(.delegate(.sessionEnded))))): return .send(.delegate(.sessionEnded)) + // ํšŒ์› ํƒˆํ‡ด ์™„๋ฃŒ โ†’ App ์œผ๋กœ ์„ธ์…˜ ์ข…๋ฃŒ ์ „ํŒŒ + case .myPage(.router(.routeAction(_, action: .withdraw(.delegate(.sessionEnded))))): + return .send(.delegate(.sessionEnded)) + // ํ™ˆ "๋”๋ณด๊ธฐ" โ†’ ํƒ์ƒ‰ ํƒญ์œผ๋กœ ์ด๋™ case .home(.router(.routeAction(_, action: .home(.delegate(.moveToExplore))))): if state.selectedTab != Tab.explore.rawValue { state.previousTab = state.selectedTab } diff --git a/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift b/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift index cb8370c..5689b15 100644 --- a/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift +++ b/Projects/Presentation/Notification/Sources/Main/Reducer/NotificationFeature.swift @@ -133,13 +133,33 @@ extension NotificationFeature { return .send(.async(.markAll)) case let .notificationTapped(item): - // ์ƒ์„ธ ํ™”๋ฉด ์—ฐ๊ฒฐ ์ „: ํƒญ ์‹œ ์ฝ์Œ ์ฒ˜๋ฆฌ๋งŒ. - guard !item.isRead else { return .none } - if let index = state.items.firstIndex(where: { $0.id == item.id }) { - state.items[index] = state.items[index].markedAsRead() + var effects: [Effect] = [] + + // ๋ฏธ์ฝ์Œ์ผ ๋•Œ๋งŒ ์ฝ์Œ ์ฒ˜๋ฆฌ. + if !item.isRead { + if let index = state.items.firstIndex(where: { $0.id == item.id }) { + state.items[index] = state.items[index].markedAsRead() + } + updateUnreadBadge(state: &state) + effects.append(.send(.async(.markRead(notificationId: item.notificationId)))) } - updateUnreadBadge(state: &state) - return .send(.async(.markRead(notificationId: item.notificationId))) + + // detailCode ๊ธฐ๋ฐ˜ ๋”ฅ๋งํฌ ๋ผ์šฐํŒ… (์ด๋ฏธ ์ฝ์€ ํ•ญ๋ชฉ๋„ ์ด๋™). + if let deeplink = PickeDeeplinkParser.parse( + detailCode: item.detailCode, + referenceId: item.referenceId, + perspectiveId: item.perspectiveId + ) { + effects.append(.run { _ in + NotificationCenter.default.post( + name: .pickeDeeplink, + object: nil, + userInfo: ["deeplink": deeplink.encoded] + ) + }) + } + + return .merge(effects) } } diff --git a/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift b/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift index 2c6389e..e0d57f6 100644 --- a/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift +++ b/Projects/Presentation/Notification/Sources/Main/View/NotificationView.swift @@ -2,8 +2,7 @@ // NotificationView.swift // Notification // -// ์•Œ๋ฆผ๋ฐ›๊ธฐ UI โ€” picke.pen `์•Œ๋ฆผ๋ฐ›๊ธฐ`. -// App Bar(๋’ค๋กœ/์•Œ๋ฆผ/๋ชจ๋‘ ์ฝ์Œ) + ์นดํ…Œ๊ณ ๋ฆฌ ํƒญ + ์•Œ๋ฆผ ์นด๋“œ ๋ฆฌ์ŠคํŠธ + ๋ฌดํ•œ ์Šคํฌ๋กค. +// // import SwiftUI diff --git a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift index a777b26..95c9420 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/Reducer/ProfileCoordinator.swift @@ -97,9 +97,9 @@ extension ProfileCoordinator { case .routeAction(_, action: .recap(.delegate(.dismiss))): return .send(.view(.backAction)) - // ์„ค์ • ์•„์ด์ฝ˜ โ†’ ์„ค์ • ํ™”๋ฉด ์ง„์ž….๋ฆฌ๊ณ  - case .routeAction(_, action: .profile(.delegate(.openSettings))): - state.routes.push(.settings(.init())) + // ์„ค์ • ์•„์ด์ฝ˜ โ†’ ์„ค์ • ํ™”๋ฉด ์ง„์ž…. + case let .routeAction(_, action: .profile(.delegate(.openSettings(nickname)))): + state.routes.push(.settings(.init(nickname: nickname))) return .none // ์•Œ๋ฆผ(์ข…) ์•„์ด์ฝ˜ โ†’ ์•Œ๋ฆผ๋ฐ›๊ธฐ ํ™”๋ฉด ์ง„์ž…. @@ -134,6 +134,15 @@ extension ProfileCoordinator { state.routes.push(.web(.init(url: TermsDocument.service.urlString))) return .none + // ํšŒ์› ํƒˆํ‡ด โ†’ ํƒˆํ‡ด ์‚ฌ์œ  ํ™”๋ฉด ์ง„์ž…. + case let .routeAction(_, action: .settings(.delegate(.openWithdraw(nickname)))): + state.routes.push(.withdraw(.init(nickname: nickname))) + return .none + + // ํƒˆํ‡ด ์‚ฌ์œ  "ํ”ฝ์ผ€๋กœ ๋‹ค์‹œ ๋Œ์•„๊ฐ€๊ธฐ" โ†’ ๋’ค๋กœ. + case .routeAction(_, action: .withdraw(.delegate(.dismiss))): + return .send(.view(.backAction)) + // ์›น๋ทฐ ๋’ค๋กœ. case .routeAction(_, action: .web(.backToRoot)): return .send(.view(.backAction)) @@ -193,6 +202,7 @@ extension ProfileCoordinator { case contentActivity(ContentActivityFeature) case notice(NoticeFeature) case notificationSetting(NotificationSettingFeature) + case withdraw(WithdrawReasonFeature) case battleProposal(BattleProposalFeature) case recap(RecapFeature) case notification(NotificationCoordinator) diff --git a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift index 48a0ccc..d01821b 100644 --- a/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift +++ b/Projects/Presentation/Profile/Sources/Coordinator/View/ProfileCoordinatorView.swift @@ -36,6 +36,8 @@ public struct ProfileCoordinatorView: View { NoticeView(store: noticeStore) case let .notificationSetting(notificationStore): NotificationSettingView(store: notificationStore) + case let .withdraw(withdrawStore): + WithdrawReasonView(store: withdrawStore) case let .battleProposal(battleProposalStore): BattleProposalView(store: battleProposalStore) case let .recap(recapStore): diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift index 1e7c31b..179a340 100644 --- a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileFeature.swift @@ -99,7 +99,7 @@ public struct ProfileFeature { /// ์•Œ๋ฆผํ•จ ์ด๋™. case openNotification /// ์„ค์ • ํ™”๋ฉด ์ด๋™. - case openSettings + case openSettings(nickname: String) /// ํ”„๋กœํ•„ ์นด๋“œ ํƒญ โ†’ ํŽธ์ง‘. case editProfile /// ํฌ์ธํŠธ ์ถฉ์ „. @@ -143,7 +143,7 @@ public struct ProfileFeature { extension ProfileFeature { private func handleViewAction( - state _: inout State, + state: inout State, action: View ) -> Effect { switch action { @@ -157,7 +157,7 @@ extension ProfileFeature { return .send(.delegate(.openNotification)) case .settingsTapped: - return .send(.delegate(.openSettings)) + return .send(.delegate(.openSettings(nickname: state.nickname))) case .profileTapped: return .send(.delegate(.editProfile)) diff --git a/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift b/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift index 8169ea9..c61d85e 100644 --- a/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift +++ b/Projects/Presentation/Profile/Sources/Settings/Reducer/SettingsFeature.swift @@ -33,17 +33,19 @@ public struct SettingsFeature { /// ํ™•์ธ ํŒ์—… ๋Œ€๊ธฐ ๋™์ž‘ (confirm ์‹œ ๋ฌด์—‡์„ ์‹คํ–‰ํ• ์ง€). public enum PendingAction: Equatable { case logout - case withdraw } @ObservableState public struct State: Equatable { + public var nickname: String public var menuItems: [MenuItem] = MenuItem.allCases public var pending: PendingAction? public var isProcessing: Bool = false @Presents public var customAlert: CustomAlertState? - public init() {} + public init(nickname: String = "") { + self.nickname = nickname + } } public enum Action: ViewAction, BindableAction { @@ -63,7 +65,6 @@ public struct SettingsFeature { public enum AsyncAction: Equatable { case performLogout - case performWithdraw } public enum InnerAction: Equatable { @@ -80,6 +81,8 @@ public struct SettingsFeature { case openNotificationSettings case openPrivacy case openTerms + /// ํšŒ์› ํƒˆํ‡ด โ†’ ํƒˆํ‡ด ์‚ฌ์œ  ํ™”๋ฉด์œผ๋กœ. + case openWithdraw(nickname: String) /// ๋กœ๊ทธ์•„์›ƒ/ํƒˆํ‡ด ์™„๋ฃŒ โ†’ ๋กœ๊ทธ์ธ ํ™”๋ฉด์œผ๋กœ. case sessionEnded } @@ -90,6 +93,7 @@ public struct SettingsFeature { @Dependency(\.authUseCase) private var authUseCase @Dependency(\.keychainManager) private var keychainManager + @Dependency(\.deviceUseCase) private var deviceUseCase public var body: some Reducer { BindingReducer() @@ -142,9 +146,7 @@ extension SettingsFeature { state.customAlert = .logout() return .none case .withdraw: - state.pending = .withdraw - state.customAlert = .withdraw() - return .none + return .send(.delegate(.openWithdraw(nickname: state.nickname))) } } } @@ -157,7 +159,11 @@ extension SettingsFeature { case .performLogout: guard !state.isProcessing else { return .none } state.isProcessing = true - return .run { send in + return .run { [deviceUseCase] send in + // Keychain ์ดˆ๊ธฐํ™” ์ „(์ธ์ฆ ์œ ํšจ) ์— ๋””๋ฐ”์ด์Šค ํ† ํฐ ํ•ด์ œ. + if let token = DeviceTokenStorage.token, !token.isEmpty { + try? await deviceUseCase.unregisterDevice(fcmToken: token) + } do { _ = try await authUseCase.logout() } catch { @@ -166,20 +172,6 @@ extension SettingsFeature { await send(.inner(.sessionCleared)) } .cancellable(id: CancelID.auth, cancelInFlight: true) - - case .performWithdraw: - guard !state.isProcessing else { return .none } - state.isProcessing = true - let token = keychainManager.refreshToken() ?? keychainManager.accessToken() ?? "" - return .run { send in - do { - _ = try await authUseCase.withDraw(token: token) - } catch { - Log.error("[SettingsFeature] withdraw failed: \(error.localizedDescription)") - } - await send(.inner(.sessionCleared)) - } - .cancellable(id: CancelID.auth, cancelInFlight: true) } } @@ -212,8 +204,6 @@ extension SettingsFeature { switch pending { case .logout: return .send(.async(.performLogout)) - case .withdraw: - return .send(.async(.performWithdraw)) case .none: return .none } @@ -245,6 +235,8 @@ extension SettingsFeature { return .none case .openTerms: return .none + case .openWithdraw: + return .none case .sessionEnded: return .none } diff --git a/Projects/Presentation/Profile/Sources/Withdraw/Reducer/WithdrawReasonFeature.swift b/Projects/Presentation/Profile/Sources/Withdraw/Reducer/WithdrawReasonFeature.swift new file mode 100644 index 0000000..ac2db6a --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Withdraw/Reducer/WithdrawReasonFeature.swift @@ -0,0 +1,195 @@ +// +// WithdrawReasonFeature.swift +// Profile +// +// ํšŒ์› ํƒˆํ‡ด โ€” picke.pen `ํƒˆํ‡ดํ•˜๊ธฐ`. +// ํƒˆํ‡ด ์‚ฌ์œ (๋ณต์ˆ˜ ์„ ํƒ) ์ˆ˜์ง‘ + ์ œ์ถœํ•˜๊ธฐ/๋Œ์•„๊ฐ€๊ธฐ. +// ์ œ์ถœ ์‹œ AuthUseCase.withDraw ํ˜ธ์ถœ โ†’ Keychain ์ดˆ๊ธฐํ™” โ†’ ์„ธ์…˜ ์ข…๋ฃŒ ์ „ํŒŒ. +// + +import Foundation + +import ComposableArchitecture +import DesignSystem +import LogMacro +import UseCase + +@Reducer +public struct WithdrawReasonFeature { + public init() {} + + /// ํƒˆํ‡ด ์‚ฌ์œ  โ€” picke.pen ์ˆœ์„œ. + public enum Reason: String, CaseIterable, Equatable, Identifiable { + case notFrequent = "์ž์ฃผ ์ด์šฉํ•˜์ง€ ์•Š์•„์š”" + case noTopic = "๋ณด๊ณ  ์‹ถ์€ ๋ฐฐํ‹€ ์ฃผ์ œ๊ฐ€ ์—†์–ด์š”" + case notFit = "๋ฐฐํ‹€ ๋ฐฉ์‹์ด ์ œ๊ฒŒ ์ž˜ ๋งž์ง€ ์•Š์•„์š”" + case inconvenient = "์„œ๋น„์Šค ์ด์šฉ์ด ๋ถˆํŽธํ•ด์š”" + case noTime = "์ด์šฉํ•  ์‹œ๊ฐ„์ด ์—†์–ด์š”" + + public var id: String { rawValue } + } + + @ObservableState + public struct State: Equatable { + public var nickname: String + public var selectedReasons: Set = [] + public var isProcessing: Bool = false + @Presents public var customAlert: CustomAlertState? + + public init(nickname: String = "") { + self.nickname = nickname + } + } + + public enum Action: ViewAction { + case view(View) + case async(AsyncAction) + case inner(InnerAction) + case scope(ScopeAction) + case delegate(DelegateAction) + } + + @CasePathable + public enum View { + case reasonTapped(Reason) + case submitTapped + case backTapped + } + + public enum AsyncAction: Equatable { + case performWithdraw + } + + public enum InnerAction: Equatable { + case sessionCleared + } + + @CasePathable + public enum ScopeAction: Equatable { + case customAlert(PresentationAction) + } + + public enum DelegateAction: Equatable { + /// ํ”ฝ์ผ€๋กœ ๋‹ค์‹œ ๋Œ์•„๊ฐ€๊ธฐ โ†’ ์„ค์ •์œผ๋กœ ๋ณต๊ท€. + case dismiss + /// ํƒˆํ‡ด ์™„๋ฃŒ โ†’ ๋กœ๊ทธ์ธ ํ™”๋ฉด์œผ๋กœ. + case sessionEnded + } + + nonisolated enum CancelID: Hashable { + case withdraw + } + + @Dependency(\.authUseCase) private var authUseCase + @Dependency(\.keychainManager) private var keychainManager + @Dependency(\.deviceUseCase) private var deviceUseCase + + public var body: some Reducer { + Reduce { state, action in + switch action { + case let .view(viewAction): + return handleViewAction(state: &state, action: viewAction) + + case let .async(asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case let .inner(innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case let .scope(scopeAction): + return handleScopeAction(state: &state, action: scopeAction) + + case .delegate: + return .none + } + } + .ifLet(\.$customAlert, action: \.scope.customAlert) { + CustomConfirmAlert() + } + } +} + +extension WithdrawReasonFeature { + private func handleViewAction( + state: inout State, + action: View + ) -> Effect { + switch action { + case let .reasonTapped(reason): + if state.selectedReasons.contains(reason) { + state.selectedReasons.remove(reason) + } else { + state.selectedReasons.insert(reason) + } + return .none + + case .submitTapped: + // ์ œ์ถœํ•˜๊ธฐ โ†’ ์˜๊ตฌ ์‚ญ์ œ ํ™•์ธ ํŒ์—… โ†’ ํ™•์ธ ์‹œ ํƒˆํ‡ด ์ง„ํ–‰. + state.customAlert = .withdraw() + return .none + + case .backTapped: + return .send(.delegate(.dismiss)) + } + } + + private func handleScopeAction( + state: inout State, + action: ScopeAction + ) -> Effect { + switch action { + case let .customAlert(alertAction): + switch alertAction { + case .presented(.confirmTapped): + state.customAlert = nil + return .send(.async(.performWithdraw)) + + case .presented(.cancelTapped): + state.customAlert = nil + return .none + + case .dismiss: + state.customAlert = nil + return .none + } + } + } + + private func handleAsyncAction( + state: inout State, + action: AsyncAction + ) -> Effect { + switch action { + case .performWithdraw: + guard !state.isProcessing else { return .none } + state.isProcessing = true + let token = keychainManager.refreshToken() ?? keychainManager.accessToken() ?? "" + return .run { [deviceUseCase] send in + // Keychain ์ดˆ๊ธฐํ™” ์ „(์ธ์ฆ ์œ ํšจ) ์— ๋””๋ฐ”์ด์Šค ํ† ํฐ ํ•ด์ œ. + if let deviceToken = DeviceTokenStorage.token, !deviceToken.isEmpty { + try? await deviceUseCase.unregisterDevice(fcmToken: deviceToken) + } + do { + _ = try await authUseCase.withDraw(token: token) + } catch { + Log.error("[WithdrawReasonFeature] withdraw failed: \(error.localizedDescription)") + } + await send(.inner(.sessionCleared)) + } + .cancellable(id: CancelID.withdraw, cancelInFlight: true) + } + } + + private func handleInnerAction( + state: inout State, + action: InnerAction + ) -> Effect { + switch action { + case .sessionCleared: + state.isProcessing = false + // ์„œ๋ฒ„ ํ˜ธ์ถœ ์„ฑ๊ณต/์‹คํŒจ์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ ๋กœ์ปฌ ์„ธ์…˜์€ ์ •๋ฆฌํ•˜๊ณ  ๋กœ๊ทธ์ธ์œผ๋กœ ์ „ํ™˜. + keychainManager.clear() + return .send(.delegate(.sessionEnded)) + } + } +} diff --git a/Projects/Presentation/Profile/Sources/Withdraw/View/WithdrawReasonView.swift b/Projects/Presentation/Profile/Sources/Withdraw/View/WithdrawReasonView.swift new file mode 100644 index 0000000..420d6f6 --- /dev/null +++ b/Projects/Presentation/Profile/Sources/Withdraw/View/WithdrawReasonView.swift @@ -0,0 +1,146 @@ +// +// WithdrawReasonView.swift +// Profile +// +// ํšŒ์› ํƒˆํ‡ด UI โ€” picke.pen `ํƒˆํ‡ดํ•˜๊ธฐ`. +// ํƒ€์ดํ‹€ + ์•ˆ๋‚ด๋ฌธ + ํƒˆํ‡ด ์‚ฌ์œ (๋ณต์ˆ˜ ์„ ํƒ) + ์ œ์ถœํ•˜๊ธฐ/ํ”ฝ์ผ€๋กœ ๋‹ค์‹œ ๋Œ์•„๊ฐ€๊ธฐ. +// + +import SwiftUI + +import ComposableArchitecture +import DesignSystem + +@ViewAction(for: WithdrawReasonFeature.self) +public struct WithdrawReasonView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + PickeNavigationBar(onBack: { send(.backTapped) }) + .foregroundStyle(.gray500) + + title() + subtitle() + reasonList() + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.beige200.ignoresSafeArea()) + .safeAreaInset(edge: .bottom, spacing: 0) { bottomButtons() } + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .customAlert($store.scope(state: \.customAlert, action: \.scope.customAlert)) + } +} + +private extension WithdrawReasonView { + // MARK: ํƒ€์ดํ‹€ + + @ViewBuilder + func title() -> some View { + Text("\(store.nickname.isEmpty ? "ํšŒ์›" : store.nickname)๋‹˜ ์ •๋ง ๋– ๋‚˜์‹œ๋‚˜์š”? ์•„์‰ฌ์›Œ์š” ๐Ÿฅฒ") + .pretendardFont(family: .Bold, size: 18) + .foregroundStyle(.primary500) + .kerning(-0.45) + .padding(.horizontal, 20) + } + + // MARK: ์•ˆ๋‚ด๋ฌธ + + @ViewBuilder + func subtitle() -> some View { + Text("์ง€๊ธˆ๊นŒ์ง€ ํ”ฝ์ผ€๋ฅผ ์ด์šฉํ•ด์ฃผ์…”์„œ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค.\n๋” ๋‚˜์€ ์„œ๋น„์Šค๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด, ํƒˆํ‡ด ์ด์œ ๋ฅผ ์•Œ๋ ค์ฃผ์„ธ์š”.") + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(.gray400) + .lineSpacing(4) + .padding(.horizontal, 20) + } + + // MARK: ํƒˆํ‡ด ์‚ฌ์œ  + + @ViewBuilder + func reasonList() -> some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(WithdrawReasonFeature.Reason.allCases) { reason in + reasonRow(reason) + } + } + .padding(.horizontal, 20) + } + + @ViewBuilder + func reasonRow(_ reason: WithdrawReasonFeature.Reason) -> some View { + let isSelected = store.selectedReasons.contains(reason) + + Button { + send(.reasonTapped(reason)) + } label: { + HStack(spacing: 8) { + checkbox(isSelected: isSelected) + + Text(reason.rawValue) + .pretendardFont(family: .Medium, size: 14) + .foregroundStyle(isSelected ? .gray800 : .gray300) + + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func checkbox(isSelected: Bool) -> some View { + ZStack { + Circle() + .fill(isSelected ? Color.primary500 : Color.primary50) + .overlay( + Circle().stroke(isSelected ? Color.primary700 : Color.primary100, lineWidth: 1) + ) + + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.beige50) + } + } + .frame(width: 24, height: 24) + } + + // MARK: ํ•˜๋‹จ ๋ฒ„ํŠผ + + @ViewBuilder + func bottomButtons() -> some View { + HStack(spacing: 0) { + Button { + send(.submitTapped) + } label: { + Text("์ œ์ถœํ•˜๊ธฐ") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.primary500) + .frame(maxWidth: .infinity) + .frame(height: 60) + .background(Color.secondary50) + } + .buttonStyle(.plain) + .disabled(store.isProcessing) + + Button { + send(.backTapped) + } label: { + Text("ํ”ฝ์ผ€๋กœ ๋‹ค์‹œ ๋Œ์•„๊ฐ€๊ธฐ") + .pretendardFont(family: .SemiBold, size: 16) + .foregroundStyle(.beige50) + .frame(maxWidth: .infinity) + .frame(height: 60) + .background(Color.primary500) + } + .buttonStyle(.plain) + } + } +} diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/Contents.json b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/Contents.json index cba9a2a..1e97412 100644 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/Contents.json +++ b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "PicK.svg", + "filename" : "แ„‘แ…ตแ†จแ„แ…ฆ แ„…แ…ฉแ„€แ…ฉ.svg", "idiom" : "universal" } ], diff --git a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/PicK.svg b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/PicK.svg deleted file mode 100644 index b7b5c8c..0000000 --- a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/PicK.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git "a/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/\355\224\275\354\274\200 \353\241\234\352\263\240.svg" "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/\355\224\275\354\274\200 \353\241\234\352\263\240.svg" new file mode 100644 index 0000000..4c9c160 --- /dev/null +++ "b/Projects/Shared/DesignSystem/Resources/ImageAssets.xcassets/logo/loginLogo.imageset/\355\224\275\354\274\200 \353\241\234\352\263\240.svg" @@ -0,0 +1,4 @@ + + + + diff --git a/README.md b/README.md index b160810..1b2fd0b 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,23 @@ ln -s AGENTS.md CLAUDE.md - ํ† ํ”ฝ ๊ฒ€์ƒ‰ ### ๐Ÿ‘ค ๋งˆ์ดํŽ˜์ด์ง€ -- ๋‚ด ์ฝ˜ํ…์ธ  ํ™œ๋™ / ํ† ๋ก  ๊ธฐ๋ก -- ๋‚˜์˜ ์ฒ ํ•™์ž ์œ ํ˜• -- ํฌ์ธํŠธ ๋‚ด์—ญ / ์•Œ๋ฆผ / ์„ค์ • +- ํ”„๋กœํ•„ ์นด๋“œ / ๋ณด์œ  ํฌ์ธํŠธ + **๋ฌด๋ฃŒ ์ถฉ์ „**(๋ฆฌ์›Œ๋“œ ๊ด‘๊ณ ) +- ํฌ์ธํŠธ ๋‚ด์—ญ, ๋‚ด ๋ฐฐํ‹€ ๊ธฐ๋ก, ๋‚ด ์ฝ˜ํ…์ธ  ํ™œ๋™(๋Œ“๊ธ€/์ข‹์•„์š”), ๊ณต์ง€์‚ฌํ•ญยท์ด๋ฒคํŠธ +- **๋‚˜์˜ ์ฒ ํ•™์ž ์œ ํ˜•(recap)** โ€” ๋ฐฐํ‹€ 5๊ฐœ ๋ฏธ๋งŒ ์‹œ ์ž ๊ธˆ ํ™”๋ฉด ๋ถ„๊ธฐ, ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋ ˆ์ด๋” ์ฐจํŠธ + ๊ณต์œ  +- ๋ฐฐํ‹€ ์ฃผ์ œ ์ œ์•ˆ, ์„ค์ •ยทํƒˆํ‡ดยท์•Œ๋ฆผ ์„ค์ • + +### ๐Ÿ”” ์•Œ๋ฆผ (Notification) +- **์•Œ๋ฆผ๋ฐ›๊ธฐ** ๋ชฉ๋ก โ€” ์นดํ…Œ๊ณ ๋ฆฌ ํƒญ(์ „์ฒดยท์ฝ˜ํ…์ธ ยท๊ณต์ง€์‚ฌํ•ญยท์ด๋ฒคํŠธ) + ๋ฌดํ•œ ์Šคํฌ๋กค +- ํƒญ ์‹œ ์ฝ์Œ ์ฒ˜๋ฆฌ / **๋ชจ๋‘ ์ฝ์Œ**, `GETยทPOST /api/v1/notifications` +- **๋ฏธ์ฝ์Œ ๋นจ๊ฐ„์ ** โ€” ์ „์—ญ ๊ณต์œ  ์ƒํƒœ(`HasUnreadNotification`)๋กœ ํ™ˆยทํ”„๋กœํ•„ ์ข… ์•„์ด์ฝ˜์— ํ‘œ์‹œ, ๋ชจ๋‘ ์ฝ์Œ ์‹œ ์ œ๊ฑฐ + +### ๐Ÿ’ฐ ๋ฌด๋ฃŒ ์ถฉ์ „ (๋ฆฌ์›Œ๋“œ ๊ด‘๊ณ ) +- **GoogleMobileAds ๋ฆฌ์›Œ๋“œ ๋™์˜์ƒ** ์‹œ์ฒญ โ†’ ํฌ์ธํŠธ ์ถฉ์ „ (๊ด‘๊ณ  ์œ ๋‹› ID ๋Š” `REWARD_AD_UNIT` config ์ฃผ์ž…) +- `RewardedAdClient`(UseCase) โ€” ๋กœ๋“œยทํ‘œ์‹œยท๋ณด์ƒ ์ฝœ๋ฐฑ์„ async ๋กœ ์ถ”์ƒํ™” + +### ๐Ÿ“Š ํ–‰๋™ ๋ถ„์„ (Mixpanel) +- `AnalyticsUseCase` โ€” ํƒ€์ž… ์•ˆ์ „ ์ด๋ฒคํŠธ + PICKรฉ ํ•ต์‹ฌ ์ด๋ฒคํŠธ ๋ช…์„ธ์„œ ์ค€์ˆ˜(์ด๋ฒคํŠธ ํ†ตํ•ฉ ์ „๋žต) +- `sign_up` / `battle_step`(pre_voteยทaudio_endยทpost_vote) / `report_action` / `community_action` / `ad_revenue` + ๋กœ๊ทธ์ธ ์‹œ `identify` ## ๐Ÿ— ํ”„๋กœ์ ํŠธ ์•„ํ‚คํ…์ฒ˜ @@ -90,17 +104,19 @@ Picke-iOS/ โ”‚ โ”‚ โ”œโ”€โ”€ Hifi/ # ํƒ์ƒ‰ยท๊ฒ€์ƒ‰ ๊ธฐ๋ฐ˜ Hi-Fi ํ™”๋ฉด โ”‚ โ”‚ โ”œโ”€โ”€ Home/ # ํ™ˆ ํ”ผ๋“œ / ์ถ”์ฒœ / ์Šค์ผˆ๋ ˆํ†ค โ”‚ โ”‚ โ”œโ”€โ”€ MainTab/ # ํƒญ ๋ผ์šฐํŒ… / GNB +โ”‚ โ”‚ โ”œโ”€โ”€ Notification/ # ์•Œ๋ฆผ๋ฐ›๊ธฐ ๋ชฉ๋ก / ์นดํ…Œ๊ณ ๋ฆฌ ํƒญ / ๋ฏธ์ฝ์Œ ๋ฑƒ์ง€ +โ”‚ โ”‚ โ”œโ”€โ”€ Profile/ # ๋งˆ์ดํŽ˜์ด์ง€ / ํฌ์ธํŠธ / ์„ค์ • / ๋ฐฐํ‹€์ œ์•ˆ / ๋ฐฐํ‹€๊ธฐ๋ก / ์ฝ˜ํ…์ธ ํ™œ๋™ / ๊ณต์ง€ / ๋ฆฌ์บก / ๋ฌด๋ฃŒ์ถฉ์ „ โ”‚ โ”‚ โ”œโ”€โ”€ Splash/ # ์Šคํ”Œ๋ž˜์‹œ โ”‚ โ”‚ โ”œโ”€โ”€ Web/ # ์•ฝ๊ด€ / ์™ธ๋ถ€ ๋งํฌ WebView โ”‚ โ”‚ โ””โ”€โ”€ Presentation/ # ๊ณตํ†ต ํ”„๋ ˆ์  ํ…Œ์ด์…˜ ์œ ํ‹ธ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Domain/ # ๐Ÿ”ฅ Business Logic Layer -โ”‚ โ”‚ โ”œโ”€โ”€ Entity/ # Auth / Battle / Comment / Home / OAuth / Share / Error ์—”ํ‹ฐํ‹ฐ +โ”‚ โ”‚ โ”œโ”€โ”€ Entity/ # Auth / Battle / Comment / Home / OAuth / Profile / Notification / Share / Error ์—”ํ‹ฐํ‹ฐ โ”‚ โ”‚ โ”œโ”€โ”€ DomainInterface/ # Repository / Manager ์ธํ„ฐํŽ˜์ด์Šค -โ”‚ โ”‚ โ””โ”€โ”€ UseCase/ # Auth / Battle / Comment / Home / OAuth / Perspective / Search ์œ ์Šค์ผ€์ด์Šค +โ”‚ โ”‚ โ””โ”€โ”€ UseCase/ # Auth / Battle / Comment / Home / OAuth / Profile / Notification / Analytics / Ad ์œ ์Šค์ผ€์ด์Šค โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Data/ # ๐Ÿ“ก Data Layer -โ”‚ โ”‚ โ”œโ”€โ”€ API/ # Base / Auth / Battle / Comment / Home / Perspective / Search endpoint +โ”‚ โ”‚ โ”œโ”€โ”€ API/ # Base / Auth / Battle / Comment / Home / Perspective / Profile / Notification endpoint โ”‚ โ”‚ โ”œโ”€โ”€ Service/ # Moya TargetType + ์š”์ฒญ ๋ฐ”๋”” โ”‚ โ”‚ โ”œโ”€โ”€ Model/ # BaseResponseDTO + DTO โ†’ Entity ๋งคํผ โ”‚ โ”‚ โ””โ”€โ”€ Repository/ # RepositoryImpl + OAuth / AudioPlayer ๊ตฌํ˜„ diff --git a/fastlane/metadata/copyright.txt b/fastlane/metadata/copyright.txt new file mode 100644 index 0000000..caa469d --- /dev/null +++ b/fastlane/metadata/copyright.txt @@ -0,0 +1 @@ +@Picke diff --git a/fastlane/metadata/ko/apple_tv_privacy_policy.txt b/fastlane/metadata/ko/apple_tv_privacy_policy.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/apple_tv_privacy_policy.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/ko/description.txt b/fastlane/metadata/ko/description.txt new file mode 100644 index 0000000..e36bdcd --- /dev/null +++ b/fastlane/metadata/ko/description.txt @@ -0,0 +1,32 @@ +์–ด์ œ ๋ณธ ์‡ผ์ธ  ์ค‘ ๊ธฐ์–ต๋‚˜๋Š” ๊ฑฐ ์žˆ์œผ์„ธ์š”? + +์ˆํผ์€ ๊ณ„์† ๋ณด๋Š”๋ฐ, ์ •์ž‘ ๋‚ด ์ƒ๊ฐ์€ ์–ด๋”” ๊ฐ”๋Š”์ง€ ๋ชจ๋ฅด๊ฒ ๋‹ค๋ฉด โ€” ํ”ฝ์ผ€๊ฐ€ ๋”ฑ ๋งž์•„์š”. +PicKรฉ(ํ”ฝ์ผ€)๋Š” ์ฒ ํ•™์ž๋“ค์˜ ๋ชฉ์†Œ๋ฆฌ๋กœ ๋“ฃ๊ณ , ๋‚ด ์ž…์žฅ์„ ์ •ํ•˜๊ณ , ์ƒ๊ฐ์„ ๋‚จ๊ธฐ๋Š” ์˜ค๋””์˜ค ํ† ๋ก  ์•ฑ์ด์—์š”. + + ๋ฐฐํ‹€ โ€” ์—ญ์‚ฌ ์† ๊ฑฐ์žฅ๋“ค์˜ ๋…ผ์Ÿ์„ ์ง์ ‘ ๋“ค์–ด์š” +์†Œํฌ๋ผํ…Œ์Šค vs ๋‹ˆ์ฒด, ์นธํŠธ vs ๋งˆ๋ฅดํฌ์Šค. +๊ฑฐ์žฅ๋“ค์ด ์ง์ ‘ ๋ง์„ ๊ฑธ์–ด์š”. ๊ทธ๋ƒฅ ๋“ฃ๋‹ค ๋ณด๋ฉด ์–ด๋А์ƒˆ ๋” ๋‹จ๋‹จํ•œ ๋‚ด ์ƒ๊ฐ์ด ์ƒ๊ฒจ ์žˆ์„ ๊ฑฐ์˜ˆ์š”. +"๋ˆ„๊ฐ€ ์ด๊ธฐ๋‚˜ ๋“ค์–ด๋ณผ๊นŒ?" ํ•˜๋Š” ๋งˆ์Œ์ด๋ฉด ์ถฉ๋ถ„ํ•ด์š”. + +์ฒ ํ•™์ž ์œ ํ˜• โ€” ๋‚˜๋Š” ๋ˆ„๊ตฌ๋ž‘ ๊ฐ€์žฅ ๋‹ฎ์•˜์„๊นŒ? +ํˆฌํ‘œ๊ฐ€ ์Œ“์ด๋ฉด ํ”ฝ์ผ€๋งŒ์˜ ๋กœ์ง์ด ๋‚ด ๊ฐ€์น˜๊ด€์„ ๋ถ„์„ํ•ด์š”. +์นธํŠธ, ๋‹ˆ์ฒด, ์„๊ฐ€๋ชจ๋‹ˆ, ๊ณต์žโ€ฆ ๋‚ด๊ฐ€ ์–ด๋–ค ์ฒ ํ•™์ž์ฒ˜๋Ÿผ ์ƒ๊ฐํ•˜๋Š”์ง€ ํ™•์ธํ•ด ๋ณด์„ธ์š”. + +๋‚ด๊ณต ์‹œ์Šคํ…œ โ€” ์ฐธ์—ฌํ• ์ˆ˜๋ก ์Œ“์—ฌ์š” +๋ฐฐํ‹€์— ์ฐธ์—ฌํ•˜๊ณ , ๋Œ“๊ธ€ ๋‚จ๊ธฐ๊ณ , ์ถœ์„ํ•˜๋ฉด ๋‚ด๊ณต์ด ์Œ“์—ฌ์š”. +๋ชจ์€ ๋‚ด๊ณต์œผ๋กœ ๋‹ค์Œ ๋ฐฐํ‹€ ์ฃผ์ œ๋ฅผ ์ง์ ‘ ์ œ์•ˆํ•  ์ˆ˜ ์žˆ์–ด์š”. + +๊นŠ์€ ์ƒ๊ฐ์ด ์–ด๋ ต๋‹ค๊ณ  ๋А๊ผˆ๋‹ค๋ฉด, ๊ทธ๊ฑด ์žฌ๋ฏธ์—†๊ฒŒ ๋ฐฐ์›Œ์„œ์˜ˆ์š”. +ํ”ฝ์ผ€์—์„  ๊ทธ๋ƒฅ ๋“ค์œผ๋ฉด ๋ผ์š”. ์ƒ๊ฐ์€ ์ €์ ˆ๋กœ ๋”ฐ๋ผ์™€์š”. +์ง€๊ธˆ ๋ฐ”๋กœ ์ฒซ ๋ฐฐํ‹€์— ์ฐธ์—ฌํ•ด ๋ณด์„ธ์š”. + +"์–ด์ œ ๋ณธ ์ˆ˜๋งŽ์€ ์ฝ˜ํ…์ธ  ์ค‘, ๋‹น์‹ ์˜ '์ƒ๊ฐ'์œผ๋กœ ๋‚จ์€ ๊ฒƒ์€ ๋ฌด์—‡์ธ๊ฐ€์š”?" + +์Ÿ์•„์ง€๋Š” ์ˆํผ๊ณผ ์ž๊ทน์ ์ธ ์ •๋ณด๋“ค ์†์—์„œ ์‚ฌ์œ ํ•  ํ‹ˆ ์—†์ด ์†Œ๋น„ํ•˜๊ธฐ์—๋งŒ ๋ฐ”์˜์…จ๋‚˜์š”? ์ด์ œ PICKรฉ(ํ”ผ์ผ€)์—์„œ 5๋ถ„๊ฐ„์˜ ์ง€์  ์œ ํฌ๋ฅผ ํ†ตํ•ด ์žƒ์–ด๋ฒ„๋ฆฐ '๋‚˜์˜ ๊ด€์ '์„ ์ฐพ์•„๋ณด์„ธ์š”. + +[PICKรฉ๋งŒ์˜ ํ•ต์‹ฌ ๊ฒฝํ—˜] + +1. ์ง€์„ฑ์ธ๊ณผ์˜ 1:1 ๋Œ€ํ™”ํ˜• ๋…ผ์Ÿ ๋ฐฐํ‹€ ์—ญ์‚ฌ ์† ๊ฑฐ์žฅ๋“ค์ด ๋‹น์‹ ์—๊ฒŒ ์ง์ ‘ ๋ง์„ ๊ฑด๋„ต๋‹ˆ๋‹ค. ๋‹จ์ˆœํžˆ ๋“ฃ๊ธฐ๋งŒ ํ•˜๋Š” ์ฝ˜ํ…์ธ ๊ฐ€ ์•„๋‹Œ, ์‹ค์‹œ๊ฐ„ ๋ถ„๊ธฐ์— ๋”ฐ๋ผ ์‹œ๋‚˜๋ฆฌ์˜ค๊ฐ€ ๋ณ€ํ•˜๋Š” ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ๋ฐฐํ‹€์— ์ฐธ์—ฌํ•˜์„ธ์š”. "๋ˆ„๊ฐ€ ์ด๊ธฐ๋‚˜ ๋“ค์–ด๋ณผ๊นŒ?" ํ•˜๋Š” ๋งˆ์Œ์ด๋ฉด ์ถฉ๋ถ„ํ•ฉ๋‹ˆ๋‹ค. +2.์›ํ„ฐ์น˜ ํˆฌํ‘œ๋กœ ํ™•์ธํ•˜๋Š” ์ƒ๊ฐ์˜ ๋ณ€ํ™” ์ฝ˜ํ…์ธ  ๊ฐ์ƒ ์ „๊ณผ ํ›„, ๋‹น์‹ ์˜ ์„ ํƒ์€ ๊ฐ™์„๊นŒ์š”? ๋ณต์žกํ•œ ๋Œ“๊ธ€ ๋Œ€์‹  ๊ฐ€๋ฒผ์šด ํƒญ ํ•œ ๋ฒˆ์œผ๋กœ ๋‹น์‹ ์˜ ์˜๊ฒฌ์„ ํ‘œํ˜„ํ•˜๊ณ , ๋‹ค๋ฅธ ์œ ์ €๋“ค๊ณผ์˜ ์‹ค์‹œ๊ฐ„ ํˆฌํ‘œ ํ˜„ํ™ฉ์„ ๋น„๊ตํ•ด ๋ณด์„ธ์š”. +3. ๋ฐ์ดํ„ฐ๋กœ ์ฆ๋ช…ํ•˜๋Š” ๋‚˜์˜ ์ฒ ํ•™์ž ์œ ํ˜• ํˆฌํ‘œ์™€ ํ™œ๋™ ๋ฐ์ดํ„ฐ๊ฐ€ ์Œ“์ด๋ฉด ํ”ฝ์ผ€๋งŒ์˜ ์ •๋ฐ€ํ•œ ๋กœ์ง์ด ๋‹น์‹ ์˜ ๊ฐ€์น˜๊ด€์„ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค. ์นธํŠธ, ๋‹ˆ์ฒด, ์†Œํฌ๋ผํ…Œ์Šค ๋“ฑ ๋‹น์‹ ๊ณผ ๊ฐ€์žฅ ๋‹ฎ์€ ์ฒ ํ•™์ž ์œ ํ˜•์„ ํ™•์ธํ•˜๊ณ  ๋‚˜๋งŒ์˜ ์„ฑํ–ฅ ๋ฆฌํฌํŠธ๋ฅผ ์†Œ์žฅํ•˜์„ธ์š”. +4.๋‚ด๊ณต(ํฌ์ธํŠธ) ์‹œ์Šคํ…œ๊ณผ ์ปค๋ฎค๋‹ˆํ‹ฐ ๋ฐฐํ‹€์— ์ฐธ์—ฌํ•˜๊ณ  ์Šน๋ฆฌํ•˜์—ฌ '๋‚ด๊ณต'์„ ์Œ“์œผ์„ธ์š”. ๋ชจ์€ ํฌ์ธํŠธ๋กœ ๋‹ค์Œ ๋ฐฐํ‹€์˜ ์ฃผ์ œ๋ฅผ ์ง์ ‘ ์ œ์•ˆํ•˜๊ฑฐ๋‚˜, ๋‚˜์˜ ๋“ฑ๊ธ‰์„ ์˜ฌ๋ ค ์ง€์  ์˜ํ–ฅ๋ ฅ์„ ์ฆ๋ช…ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. diff --git a/fastlane/metadata/ko/keywords.txt b/fastlane/metadata/ko/keywords.txt new file mode 100644 index 0000000..3770e70 --- /dev/null +++ b/fastlane/metadata/ko/keywords.txt @@ -0,0 +1 @@ +Picke, picke, PICKE, ํ”ผ์ผ€ , ์ฒ ํ•™ diff --git a/fastlane/metadata/ko/marketing_url.txt b/fastlane/metadata/ko/marketing_url.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/marketing_url.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/ko/name.txt b/fastlane/metadata/ko/name.txt new file mode 100644 index 0000000..237311d --- /dev/null +++ b/fastlane/metadata/ko/name.txt @@ -0,0 +1 @@ +Picke diff --git a/fastlane/metadata/ko/privacy_url.txt b/fastlane/metadata/ko/privacy_url.txt new file mode 100644 index 0000000..7a6cc5c --- /dev/null +++ b/fastlane/metadata/ko/privacy_url.txt @@ -0,0 +1 @@ +https://www.notion.so/3566effee51c81898de5f91d52ab7391?source=copy_link diff --git a/fastlane/metadata/ko/promotional_text.txt b/fastlane/metadata/ko/promotional_text.txt new file mode 100644 index 0000000..64e5c94 --- /dev/null +++ b/fastlane/metadata/ko/promotional_text.txt @@ -0,0 +1,3 @@ +"์–ด์ œ ๋ณธ ์‡ผ์ธ  ์ค‘ ๊ธฐ์–ต๋‚˜๋Š” ๊ฑฐ, ์žˆ์–ด์š”?" +๊ฑฐ์žฅ๋“ค์ด ์ง์ ‘ ๋ง์„ ๊ฑฐ๋Š” ์˜ค๋””์˜ค ํ† ๋ก  ์•ฑ. ๊ทธ๋ƒฅ ๋“ฃ๋‹ค ๋ณด๋ฉด ๋” ๋‹จ๋‹จํ•œ ๋‚ด ์ƒ๊ฐ์ด ์ƒ๊ฒจ์š”. +๋“ฃ๊ณ , ์ •ํ•˜๊ณ , ๋‚จ๊ธฐ์„ธ์š”. ์ƒ๊ฐ์€ ์ €์ ˆ๋กœ ๋”ฐ๋ผ์˜ต๋‹ˆ๋‹ค. diff --git a/fastlane/metadata/ko/release_notes.txt b/fastlane/metadata/ko/release_notes.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/release_notes.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/ko/subtitle.txt b/fastlane/metadata/ko/subtitle.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/ko/subtitle.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/ko/support_url.txt b/fastlane/metadata/ko/support_url.txt new file mode 100644 index 0000000..7a6cc5c --- /dev/null +++ b/fastlane/metadata/ko/support_url.txt @@ -0,0 +1 @@ +https://www.notion.so/3566effee51c81898de5f91d52ab7391?source=copy_link diff --git a/fastlane/metadata/primary_category.txt b/fastlane/metadata/primary_category.txt new file mode 100644 index 0000000..cc52552 --- /dev/null +++ b/fastlane/metadata/primary_category.txt @@ -0,0 +1 @@ +LIFESTYLE diff --git a/fastlane/metadata/primary_first_sub_category.txt b/fastlane/metadata/primary_first_sub_category.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/primary_first_sub_category.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/primary_second_sub_category.txt b/fastlane/metadata/primary_second_sub_category.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/primary_second_sub_category.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/review_information/demo_password.txt b/fastlane/metadata/review_information/demo_password.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/review_information/demo_password.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/review_information/demo_user.txt b/fastlane/metadata/review_information/demo_user.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/review_information/demo_user.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/review_information/email_address.txt b/fastlane/metadata/review_information/email_address.txt new file mode 100644 index 0000000..f270004 --- /dev/null +++ b/fastlane/metadata/review_information/email_address.txt @@ -0,0 +1 @@ +shuwj81@icloud.com diff --git a/fastlane/metadata/review_information/first_name.txt b/fastlane/metadata/review_information/first_name.txt new file mode 100644 index 0000000..1ef1d2f --- /dev/null +++ b/fastlane/metadata/review_information/first_name.txt @@ -0,0 +1 @@ +์›์ง€ diff --git a/fastlane/metadata/review_information/last_name.txt b/fastlane/metadata/review_information/last_name.txt new file mode 100644 index 0000000..a963e41 --- /dev/null +++ b/fastlane/metadata/review_information/last_name.txt @@ -0,0 +1 @@ +์„œ diff --git a/fastlane/metadata/review_information/notes.txt b/fastlane/metadata/review_information/notes.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/review_information/notes.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/review_information/phone_number.txt b/fastlane/metadata/review_information/phone_number.txt new file mode 100644 index 0000000..e86729f --- /dev/null +++ b/fastlane/metadata/review_information/phone_number.txt @@ -0,0 +1 @@ ++821094375187 diff --git a/fastlane/metadata/secondary_category.txt b/fastlane/metadata/secondary_category.txt new file mode 100644 index 0000000..470bc83 --- /dev/null +++ b/fastlane/metadata/secondary_category.txt @@ -0,0 +1 @@ +SOCIAL_NETWORKING diff --git a/fastlane/metadata/secondary_first_sub_category.txt b/fastlane/metadata/secondary_first_sub_category.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/secondary_first_sub_category.txt @@ -0,0 +1 @@ + diff --git a/fastlane/metadata/secondary_second_sub_category.txt b/fastlane/metadata/secondary_second_sub_category.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fastlane/metadata/secondary_second_sub_category.txt @@ -0,0 +1 @@ + diff --git a/fastlane/screenshots/ko/0_APP_IPHONE_65_0.png b/fastlane/screenshots/ko/0_APP_IPHONE_65_0.png new file mode 100644 index 0000000..ea4a51a Binary files /dev/null and b/fastlane/screenshots/ko/0_APP_IPHONE_65_0.png differ diff --git a/fastlane/screenshots/ko/1_APP_IPHONE_65_1.png b/fastlane/screenshots/ko/1_APP_IPHONE_65_1.png new file mode 100644 index 0000000..53e259f Binary files /dev/null and b/fastlane/screenshots/ko/1_APP_IPHONE_65_1.png differ diff --git a/fastlane/screenshots/ko/2_APP_IPHONE_65_2.png b/fastlane/screenshots/ko/2_APP_IPHONE_65_2.png new file mode 100644 index 0000000..edcd7a5 Binary files /dev/null and b/fastlane/screenshots/ko/2_APP_IPHONE_65_2.png differ diff --git a/fastlane/screenshots/ko/3_APP_IPHONE_65_3.png b/fastlane/screenshots/ko/3_APP_IPHONE_65_3.png new file mode 100644 index 0000000..2f12976 Binary files /dev/null and b/fastlane/screenshots/ko/3_APP_IPHONE_65_3.png differ diff --git a/fastlane/screenshots/ko/4_APP_IPHONE_65_4.png b/fastlane/screenshots/ko/4_APP_IPHONE_65_4.png new file mode 100644 index 0000000..a3a1eb0 Binary files /dev/null and b/fastlane/screenshots/ko/4_APP_IPHONE_65_4.png differ