diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 27b9d3af5..167de5273 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,13 +1,14 @@ name: CI on: + pull_request: + paths: + - ".github/workflows/CI.yml" + - "**/*.swift" push: branches: - develop - master - pull_request: - branches: - - "*" env: CACHED_PACKAGE_DEPENDENCY_PATHS: ${{ github.workspace }}/.build diff --git a/Package.swift b/Package.swift index 01bc6f408..1e7a5772c 100644 --- a/Package.swift +++ b/Package.swift @@ -11,6 +11,7 @@ let packageSetting = PackageSettings( baseSettings: .settings( configurations: [ .debug(name: .debug), + .debug(name: .qa), .release(name: .release) ] ) diff --git a/Plugin/ConfigurationPlugin/ProjectDescriptionHelpers/Configuration+Ext.swift b/Plugin/ConfigurationPlugin/ProjectDescriptionHelpers/Configuration+Ext.swift index b300374f0..96724758f 100644 --- a/Plugin/ConfigurationPlugin/ProjectDescriptionHelpers/Configuration+Ext.swift +++ b/Plugin/ConfigurationPlugin/ProjectDescriptionHelpers/Configuration+Ext.swift @@ -3,6 +3,11 @@ import ProjectDescription public extension Array where Element == Configuration { static let `default`: [Configuration] = [ .debug(name: .debug, xcconfig: .relativeToRoot("Projects/App/XCConfig/Secrets.xcconfig")), + .debug(name: .qa, xcconfig: .relativeToRoot("Projects/App/XCConfig/Secrets.xcconfig")), .release(name: .release, xcconfig: .relativeToRoot("Projects/App/XCConfig/Secrets.xcconfig")) ] } + +public extension ProjectDescription.ConfigurationName { + static let qa = ConfigurationName.configuration("QA") +} diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index e51acd313..c73afcdfe 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -8,6 +8,7 @@ let settinges: Settings = base: env.baseSetting, configurations: [ .debug(name: .debug), + .debug(name: .qa), .release(name: .release) ], defaultSettings: .recommended @@ -56,9 +57,11 @@ let targets: [Target] = [ base: env.baseSetting, configurations: [ .debug(name: .debug, xcconfig: "XCConfig/Secrets.xcconfig"), + .debug(name: .qa, xcconfig: "XCConfig/Secrets.xcconfig"), .release(name: .release, xcconfig: "XCConfig/Secrets.xcconfig") ] - ) + ), + environmentVariables: ["NETWORK_LOG_LEVEL": "short"] ), .target( name: "\(env.name)Tests", @@ -76,7 +79,7 @@ let targets: [Target] = [ let schemes: [Scheme] = [ .scheme( - name: "\(env.name)Tests-DEBUG", + name: "\(env.name)-DEBUG", shared: true, buildAction: .buildAction(targets: ["\(env.name)"]), testAction: TestAction.targets( @@ -92,6 +95,16 @@ let schemes: [Scheme] = [ profileAction: .profileAction(configuration: .debug), analyzeAction: .analyzeAction(configuration: .debug) ), + .scheme( + name: "\(env.name)-QA", + shared: true, + buildAction: .buildAction(targets: ["\(env.name)"]), + testAction: nil, + runAction: .runAction(configuration: .qa), + archiveAction: .archiveAction(configuration: .qa), + profileAction: .profileAction(configuration: .qa), + analyzeAction: .analyzeAction(configuration: .qa) + ), .scheme( name: "\(env.name)-RELEASE", shared: true, diff --git a/Projects/App/Sources/Application/AppComponent+Base.swift b/Projects/App/Sources/Application/AppComponent+Base.swift index d2a763c8e..5387df404 100644 --- a/Projects/App/Sources/Application/AppComponent+Base.swift +++ b/Projects/App/Sources/Application/AppComponent+Base.swift @@ -3,12 +3,12 @@ import BaseFeatureInterface import Foundation public extension AppComponent { - var multiPurposePopUpFactory: any MultiPurposePopupFactory { + var multiPurposePopupFactory: any MultiPurposePopupFactory { MultiPurposePopupComponent(parent: self) } - var textPopUpFactory: any TextPopUpFactory { - TextPopUpComponent(parent: self) + var textPopupFactory: any TextPopupFactory { + TextPopupComponent(parent: self) } var containSongsFactory: any ContainSongsFactory { diff --git a/Projects/App/Sources/Application/AppComponent+Credit.swift b/Projects/App/Sources/Application/AppComponent+Credit.swift index 84a4a88f6..c59d3f88c 100644 --- a/Projects/App/Sources/Application/AppComponent+Credit.swift +++ b/Projects/App/Sources/Application/AppComponent+Credit.swift @@ -24,6 +24,12 @@ public extension AppComponent { } } + var fetchCreditProfileUseCase: any FetchCreditProfileUseCase { + shared { + FetchCreditProfileUseCaseImpl(creditRepository: creditRepository) + } + } + var songCreditFactory: any SongCreditFactory { SongCreditComponent(parent: self) } diff --git a/Projects/App/Sources/Application/AppComponent+MyInfo.swift b/Projects/App/Sources/Application/AppComponent+MyInfo.swift index 442aabc5a..9535416f7 100644 --- a/Projects/App/Sources/Application/AppComponent+MyInfo.swift +++ b/Projects/App/Sources/Application/AppComponent+MyInfo.swift @@ -34,4 +34,8 @@ extension AppComponent { var profilePopupFactory: any ProfilePopupFactory { ProfilePopupComponent(parent: self) } + + var playTypeTogglePopupFactory: any PlayTypeTogglePopupFactory { + PlayTypeTogglePopupComponent(parent: self) + } } diff --git a/Projects/App/Sources/Application/AppComponent+Playlist.swift b/Projects/App/Sources/Application/AppComponent+Playlist.swift index e6534e14a..cf39453fb 100644 --- a/Projects/App/Sources/Application/AppComponent+Playlist.swift +++ b/Projects/App/Sources/Application/AppComponent+Playlist.swift @@ -80,6 +80,12 @@ public extension AppComponent { } } + var fetchWMPlaylistDetailUseCase: any FetchWMPlaylistDetailUseCase { + shared { + FetchWMPlaylistDetailUseCaseImpl(playlistRepository: playlistRepository) + } + } + var createPlaylistUseCase: any CreatePlaylistUseCase { shared { CreatePlaylistUseCaseImpl(playlistRepository: playlistRepository) diff --git a/Projects/App/Sources/Application/AppDelegate.swift b/Projects/App/Sources/Application/AppDelegate.swift index 4ffa39c90..162350f4b 100644 --- a/Projects/App/Sources/Application/AppDelegate.swift +++ b/Projects/App/Sources/Application/AppDelegate.swift @@ -24,6 +24,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } else { LogManager.setUserID(userID: nil) } + initializeUserProperty() Analytics.logEvent(AnalyticsEventAppOpen, parameters: nil) @@ -56,20 +57,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } extension AppDelegate { - func application( - _ application: UIApplication, - didReceiveRemoteNotification userInfo: [AnyHashable: Any] - ) { - // If you are receiving a notification message while your app is in the background, - // this callback will not be fired till the user taps on the notification launching the application. - - // With swizzling disabled you must let Messaging know about the message, for Analytics - Messaging.messaging().appDidReceiveMessage(userInfo) - - // Print full message. - LogManager.printDebug("๐Ÿ””:: \(userInfo)") - } - /// [START receive_message] func application( _ application: UIApplication, @@ -116,9 +103,29 @@ extension AppDelegate { } } } + + private func initializeUserProperty() { + if let loginPlatform = PreferenceManager.userInfo?.platform { + LogManager.setUserProperty(property: .loginPlatform(platform: loginPlatform)) + } else { + LogManager.clearUserProperty(property: .loginPlatform(platform: "")) + } + + if let fruitTotal = PreferenceManager.userInfo?.itemCount { + LogManager.setUserProperty(property: .fruitTotal(count: fruitTotal)) + } else { + LogManager.clearUserProperty(property: .fruitTotal(count: -1)) + } + + if let playPlatform = PreferenceManager.songPlayPlatformType { + LogManager.setUserProperty(property: .songPlayPlatform(platform: playPlatform.display)) + } + + LogManager.setUserProperty(property: .playlistSongTotal(count: PlayState.shared.count)) + } } -#if DEBUG +#if DEBUG || QA extension UIWindow { override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { super.motionEnded(motion, with: event) diff --git a/Projects/App/Sources/Application/SceneDelegate.swift b/Projects/App/Sources/Application/SceneDelegate.swift index 17be8fd5c..42cc4a2b5 100644 --- a/Projects/App/Sources/Application/SceneDelegate.swift +++ b/Projects/App/Sources/Application/SceneDelegate.swift @@ -65,8 +65,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } LogManager.printDebug("URL: \(webpageURL.absoluteString)") - guard webpageURL.host == WM_UNIVERSALLINK_DOMAIN() || - webpageURL.host == WM_UNIVERSALLINK_TEST_DOMAIN() else { + guard webpageURL.host == WM_UNIVERSALLINK_DOMAIN() else { return } handleUniversalLink(url: webpageURL) diff --git a/Projects/App/Support/Info.plist b/Projects/App/Support/Info.plist index 8b9cb9383..118c93f85 100644 --- a/Projects/App/Support/Info.plist +++ b/Projects/App/Support/Info.plist @@ -55,8 +55,10 @@ LSApplicationQueriesSchemes - naversearchapp - naversearchthirdlogin + naversearchapp + naversearchthirdlogin + youtube + youtubemusic LSRequiresIPhoneOS @@ -70,10 +72,10 @@ BASE_DEV_URL $(BASE_DEV_URL) - BASE_IMAGE_URL - $(BASE_IMAGE_URL) BASE_PROD_URL $(BASE_PROD_URL) + CDN_DOMAIN_URL + $(CDN_DOMAIN_URL) GOOGLE_CLIENT_ID $(GOOGLE_CLIENT_ID) GOOGLE_URL_SCHEME @@ -100,22 +102,6 @@ $(WMDOMAIN_FAQ) WMDOMAIN_IMAGE $(WMDOMAIN_IMAGE) - WMDOMAIN_IMAGE_ARTIST_ROUND - $(WMDOMAIN_IMAGE_ARTIST_ROUND) - WMDOMAIN_IMAGE_ARTIST_SQUARE - $(WMDOMAIN_IMAGE_ARTIST_SQUARE) - WMDOMAIN_IMAGE_NEWS - $(WMDOMAIN_IMAGE_NEWS) - WMDOMAIN_IMAGE_NOTICE - $(WMDOMAIN_IMAGE_NOTICE) - WMDOMAIN_IMAGE_PLAYLIST - $(WMDOMAIN_IMAGE_PLAYLIST) - WMDOMAIN_IMAGE_PROFILE - $(WMDOMAIN_IMAGE_PROFILE) - WMDOMAIN_IMAGE_RECOMMEND_PLAYLIST_ROUND - $(WMDOMAIN_IMAGE_RECOMMEND_PLAYLIST_ROUND) - WMDOMAIN_IMAGE_RECOMMEND_PLAYLIST_SQUARE - $(WMDOMAIN_IMAGE_RECOMMEND_PLAYLIST_SQUARE) WMDOMAIN_LIKE $(WMDOMAIN_LIKE) WMDOMAIN_NOTICE diff --git a/Projects/Domains/ArtistDomain/Sources/ResponseDTO/ArtistDetailResponseDTO.swift b/Projects/Domains/ArtistDomain/Sources/ResponseDTO/ArtistDetailResponseDTO.swift index dbb50ca3d..90d255977 100644 --- a/Projects/Domains/ArtistDomain/Sources/ResponseDTO/ArtistDetailResponseDTO.swift +++ b/Projects/Domains/ArtistDomain/Sources/ResponseDTO/ArtistDetailResponseDTO.swift @@ -29,7 +29,7 @@ public extension ArtistDetailResponseDTO { let enName: String private enum CodingKeys: String, CodingKey { - case krName = "krShort" + case krName = "kr" case enName = "en" } } diff --git a/Projects/Domains/ArtistDomain/Sources/ResponseDTO/ArtistListResponseDTO.swift b/Projects/Domains/ArtistDomain/Sources/ResponseDTO/ArtistListResponseDTO.swift index 98f1ca88a..eec4fdb18 100644 --- a/Projects/Domains/ArtistDomain/Sources/ResponseDTO/ArtistListResponseDTO.swift +++ b/Projects/Domains/ArtistDomain/Sources/ResponseDTO/ArtistListResponseDTO.swift @@ -29,7 +29,7 @@ public extension ArtistListResponseDTO { let enName: String private enum CodingKeys: String, CodingKey { - case krName = "krShort" + case krName = "kr" case enName = "en" } } diff --git a/Projects/Domains/BaseDomain/Sources/DataSource/BaseRemoteDataSource.swift b/Projects/Domains/BaseDomain/Sources/DataSource/BaseRemoteDataSource.swift index 1c4501b4e..ad03cb542 100644 --- a/Projects/Domains/BaseDomain/Sources/DataSource/BaseRemoteDataSource.swift +++ b/Projects/Domains/BaseDomain/Sources/DataSource/BaseRemoteDataSource.swift @@ -25,7 +25,7 @@ open class BaseRemoteDataSource { ) { self.keychain = keychain - #if DEBUG + #if DEBUG || QA self.provider = provider ?? MoyaProvider(plugins: [ JwtPlugin(keychain: keychain), BasePlugin(keychain: keychain), @@ -107,7 +107,15 @@ private extension BaseRemoteDataSource { } func reissueToken() -> Completable { - let provider = refreshProvider ?? MoyaProvider(plugins: [JwtPlugin(keychain: keychain), CustomLoggingPlugin()]) + #if DEBUG || QA + let provider = refreshProvider ?? MoyaProvider(plugins: [ + JwtPlugin(keychain: keychain), + CustomLoggingPlugin() + ]) + #else + let provider = refreshProvider ?? MoyaProvider(plugins: [JwtPlugin(keychain: keychain)]) + #endif + if refreshProvider == nil { refreshProvider = provider } diff --git a/Projects/Domains/BaseDomain/Sources/Logging/CustomLoggingPlugin.swift b/Projects/Domains/BaseDomain/Sources/Logging/CustomLoggingPlugin.swift index 7403fb91f..dc06aa1b5 100644 --- a/Projects/Domains/BaseDomain/Sources/Logging/CustomLoggingPlugin.swift +++ b/Projects/Domains/BaseDomain/Sources/Logging/CustomLoggingPlugin.swift @@ -1,18 +1,20 @@ -// -// CustomLoggingPlugin.swift -// BaseDomain -// -// Created by KTH on 2024/03/04. -// Copyright ยฉ 2024 yongbeomkwak. All rights reserved. -// - import Foundation import Moya +import OSLog -#if DEBUG +#if DEBUG || QA + private enum NetworkLogLevel: String { + case short + case detail + } public final class CustomLoggingPlugin: PluginType { - public init() {} + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "NETWORK") + private let logLevel: NetworkLogLevel + + public init() { + self.logLevel = CustomLoggingPlugin.getLogLevelFromArguments() ?? .detail + } public func willSend(_ request: RequestType, target: TargetType) { guard let httpRequest = request.request else { @@ -30,7 +32,14 @@ import Moya log.append("\(bodyString)\n") } log.append("---------------- END \(method) -----------------------\n") - print(log) + + switch logLevel { + case .short: + let log = "[๐Ÿ›œ Request] [\(method)] [\(target)] \(url)" + logger.log(level: .debug, "\(log)") + case .detail: + logger.log(level: .debug, "\(log)") + } } public func didReceive(_ result: Result, target: TargetType) { @@ -56,7 +65,14 @@ import Moya log.append("\(reString)\n") } log.append("------------------- END HTTP (\(response.data.count)-byte body) -------------------\n") - print(log) + + switch logLevel { + case .short: + let log = "[๐Ÿ›œ Response] [\(statusCode)] [\(target)] \(url)" + logger.log(level: .debug, "\(log)") + case .detail: + logger.log(level: .debug, "\(log)") + } } func onFail(_ error: MoyaError, target: TargetType) { @@ -68,7 +84,18 @@ import Moya log.append("<-- \(error.errorCode) \(target)\n") log.append("\(error.failureReason ?? error.errorDescription ?? "unknown error")\n") log.append("<-- END HTTP\n") - print(log) + + logger.log("\(log)") + } + } + + extension CustomLoggingPlugin { + /// Environment Variables ์—์„œ ๋กœ๊ทธ ๋ ˆ๋ฒจ์„ ๊ฐ€์ ธ์˜ค๋Š” ๋ฉ”์†Œ๋“œ + /// Scheme์˜ Environment Variables ์— key : NETWORK_LOG_LEVEL, value : short ๋˜๋Š” detail + private static func getLogLevelFromArguments() -> NetworkLogLevel? { + guard let logLevelValue = ProcessInfo.processInfo.environment["NETWORK_LOG_LEVEL"] else { return nil } + guard let networkLogLevel = NetworkLogLevel(rawValue: logLevelValue) else { return nil } + return networkLogLevel } } diff --git a/Projects/Domains/BaseDomain/Sources/WMAPI/SecretURL.swift b/Projects/Domains/BaseDomain/Sources/WMAPI/SecretURL.swift index 41539e68a..9c1c42c18 100644 --- a/Projects/Domains/BaseDomain/Sources/WMAPI/SecretURL.swift +++ b/Projects/Domains/BaseDomain/Sources/WMAPI/SecretURL.swift @@ -17,7 +17,7 @@ public func config(key: String) -> String { // MARK: - BASE_URL public func BASE_URL() -> String { - #if DEBUG + #if DEBUG || QA return config(key: "BASE_DEV_URL") #else return config(key: "BASE_PROD_URL") diff --git a/Projects/Domains/ChartDomain/Sources/ResponseDTO/FetchChartRankingResponseDTO.swift b/Projects/Domains/ChartDomain/Sources/ResponseDTO/FetchChartRankingResponseDTO.swift index ba0c36850..5b8cc3795 100644 --- a/Projects/Domains/ChartDomain/Sources/ResponseDTO/FetchChartRankingResponseDTO.swift +++ b/Projects/Domains/ChartDomain/Sources/ResponseDTO/FetchChartRankingResponseDTO.swift @@ -30,7 +30,7 @@ public extension SingleChartRankingResponseDTO { public extension SingleChartRankingResponseDTO { func toDomain(type: ChartDateType) -> ChartEntity { return ChartEntity( - updatedAt: Date(timeIntervalSince1970: updatedAt).changeDateFormatForChart() + " ์—…๋ฐ์ดํŠธ", + updatedAt: Date(timeIntervalSince1970: updatedAt / 1000).changeDateFormatForChart() + " ์—…๋ฐ์ดํŠธ", songs: songs.map { return ChartRankingEntity( id: $0.songID, diff --git a/Projects/Domains/CreditDomain/Interface/DataSource/RemoteCreditDataSource.swift b/Projects/Domains/CreditDomain/Interface/DataSource/RemoteCreditDataSource.swift index 1cc4162ed..fe8e6f884 100644 --- a/Projects/Domains/CreditDomain/Interface/DataSource/RemoteCreditDataSource.swift +++ b/Projects/Domains/CreditDomain/Interface/DataSource/RemoteCreditDataSource.swift @@ -9,4 +9,6 @@ public protocol RemoteCreditDataSource { page: Int, limit: Int ) -> Single<[SongEntity]> + + func fetchCreditProfile(name: String) -> Single } diff --git a/Projects/Domains/CreditDomain/Interface/Entity/CreditProfileEntity.swift b/Projects/Domains/CreditDomain/Interface/Entity/CreditProfileEntity.swift new file mode 100644 index 000000000..7bfe31835 --- /dev/null +++ b/Projects/Domains/CreditDomain/Interface/Entity/CreditProfileEntity.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct CreditProfileEntity { + public init(name: String, imageURL: String?) { + self.name = name + self.imageURL = imageURL + } + + public let name: String + public let imageURL: String? +} diff --git a/Projects/Domains/CreditDomain/Interface/Repository/CreditRepository.swift b/Projects/Domains/CreditDomain/Interface/Repository/CreditRepository.swift index 920465675..ef3035664 100644 --- a/Projects/Domains/CreditDomain/Interface/Repository/CreditRepository.swift +++ b/Projects/Domains/CreditDomain/Interface/Repository/CreditRepository.swift @@ -9,4 +9,6 @@ public protocol CreditRepository { page: Int, limit: Int ) -> Single<[SongEntity]> + + func fetchCreditProfile(name: String) -> Single } diff --git a/Projects/Domains/CreditDomain/Interface/UseCase/FetchCreditProfileUseCase.swift b/Projects/Domains/CreditDomain/Interface/UseCase/FetchCreditProfileUseCase.swift new file mode 100644 index 000000000..5bec1b3b7 --- /dev/null +++ b/Projects/Domains/CreditDomain/Interface/UseCase/FetchCreditProfileUseCase.swift @@ -0,0 +1,5 @@ +import RxSwift + +public protocol FetchCreditProfileUseCase { + func execute(name: String) -> Single +} diff --git a/Projects/Domains/CreditDomain/Sources/API/CreditAPI.swift b/Projects/Domains/CreditDomain/Sources/API/CreditAPI.swift index 4a503e7fb..edecaba2b 100644 --- a/Projects/Domains/CreditDomain/Sources/API/CreditAPI.swift +++ b/Projects/Domains/CreditDomain/Sources/API/CreditAPI.swift @@ -12,6 +12,7 @@ public enum CreditAPI { page: Int, limit: Int ) + case fetchProfile(name: String) } extension CreditAPI: WMAPI { @@ -23,12 +24,14 @@ extension CreditAPI: WMAPI { switch self { case let .fetchCreditSongList(name, _, _, _): return "/\(name)/songs" + case let .fetchProfile(name): + return "/\(name)" } } public var method: Moya.Method { switch self { - case .fetchCreditSongList: + case .fetchCreditSongList, .fetchProfile: return .get } } @@ -41,12 +44,15 @@ extension CreditAPI: WMAPI { "page": page, "limit": limit ], encoding: URLEncoding.queryString) + + case .fetchProfile: + return .requestPlain } } public var jwtTokenType: JwtTokenType { switch self { - case .fetchCreditSongList: + case .fetchCreditSongList, .fetchProfile: return .none } } diff --git a/Projects/Domains/CreditDomain/Sources/DataSource/RemoteCreditDataSourceImpl.swift b/Projects/Domains/CreditDomain/Sources/DataSource/RemoteCreditDataSourceImpl.swift index 087dae52d..dbc298a38 100644 --- a/Projects/Domains/CreditDomain/Sources/DataSource/RemoteCreditDataSourceImpl.swift +++ b/Projects/Domains/CreditDomain/Sources/DataSource/RemoteCreditDataSourceImpl.swift @@ -15,4 +15,10 @@ public final class RemoteCreditDataSourceImpl: BaseRemoteDataSource, .map([FetchCreditSongListResponseDTO].self) .map { $0.toDomain() } } + + public func fetchCreditProfile(name: String) -> Single { + request(.fetchProfile(name: name)) + .map(FetchCreditProfileResponseDTO.self) + .map { $0.toDomain() } + } } diff --git a/Projects/Domains/CreditDomain/Sources/Repository/CreditRepositoryImpl.swift b/Projects/Domains/CreditDomain/Sources/Repository/CreditRepositoryImpl.swift index 0a6800ed7..624c1c7fe 100644 --- a/Projects/Domains/CreditDomain/Sources/Repository/CreditRepositoryImpl.swift +++ b/Projects/Domains/CreditDomain/Sources/Repository/CreditRepositoryImpl.swift @@ -20,4 +20,8 @@ public final class CreditRepositoryImpl: CreditRepository { ) -> Single<[SongEntity]> { remoteCreditDataSource.fetchCreditSongList(name: name, order: order, page: page, limit: limit) } + + public func fetchCreditProfile(name: String) -> Single { + remoteCreditDataSource.fetchCreditProfile(name: name) + } } diff --git a/Projects/Domains/CreditDomain/Sources/ResponseDTO/FetchCreditProfileResponseDTO.swift b/Projects/Domains/CreditDomain/Sources/ResponseDTO/FetchCreditProfileResponseDTO.swift new file mode 100644 index 000000000..5831cfbcf --- /dev/null +++ b/Projects/Domains/CreditDomain/Sources/ResponseDTO/FetchCreditProfileResponseDTO.swift @@ -0,0 +1,21 @@ +import CreditDomainInterface +import Foundation + +struct FetchCreditProfileResponseDTO: Decodable { + let name: String + let imageURL: String? + + enum CodingKeys: String, CodingKey { + case name + case imageURL = "profileUrl" + } +} + +extension FetchCreditProfileResponseDTO { + func toDomain() -> CreditProfileEntity { + return CreditProfileEntity( + name: name, + imageURL: imageURL + ) + } +} diff --git a/Projects/Domains/CreditDomain/Sources/UseCase/FetchCreditProfileUseCaseImpl.swift b/Projects/Domains/CreditDomain/Sources/UseCase/FetchCreditProfileUseCaseImpl.swift new file mode 100644 index 000000000..7a3450f49 --- /dev/null +++ b/Projects/Domains/CreditDomain/Sources/UseCase/FetchCreditProfileUseCaseImpl.swift @@ -0,0 +1,16 @@ +import CreditDomainInterface +import RxSwift + +public final class FetchCreditProfileUseCaseImpl: FetchCreditProfileUseCase { + private let creditRepository: any CreditRepository + + public init( + creditRepository: any CreditRepository + ) { + self.creditRepository = creditRepository + } + + public func execute(name: String) -> Single { + creditRepository.fetchCreditProfile(name: name) + } +} diff --git a/Projects/Domains/CreditDomain/Testing/UseCase/FetchCreditProfileUseCaseSpy.swift b/Projects/Domains/CreditDomain/Testing/UseCase/FetchCreditProfileUseCaseSpy.swift new file mode 100644 index 000000000..fd207404c --- /dev/null +++ b/Projects/Domains/CreditDomain/Testing/UseCase/FetchCreditProfileUseCaseSpy.swift @@ -0,0 +1,17 @@ +import CreditDomainInterface +import RxSwift +import SongsDomainInterface + +public final class FetchCreditProfileUseCaseSpy: FetchCreditProfileUseCase { + public var callCount = 0 + public var handler: (String) -> Single = { _ in fatalError() } + + public init() {} + + public func execute( + name: String + ) -> Single { + callCount += 1 + return handler(name) + } +} diff --git a/Projects/Domains/PlaylistDomain/Interface/DataSource/RemotePlaylistDataSource.swift b/Projects/Domains/PlaylistDomain/Interface/DataSource/RemotePlaylistDataSource.swift index e59932829..b4130c423 100644 --- a/Projects/Domains/PlaylistDomain/Interface/DataSource/RemotePlaylistDataSource.swift +++ b/Projects/Domains/PlaylistDomain/Interface/DataSource/RemotePlaylistDataSource.swift @@ -6,6 +6,7 @@ import SongsDomainInterface public protocol RemotePlaylistDataSource { func fetchRecommendPlaylist() -> Single<[RecommendPlaylistEntity]> func fetchPlaylistDetail(id: String, type: PlaylistType) -> Single + func fetchWMPlaylistDetail(id: String) -> Single func updateTitleAndPrivate(key: String, title: String?, isPrivate: Bool?) -> Completable func createPlaylist(title: String) -> Single func fetchPlaylistSongs(key: String) -> Single<[SongEntity]> diff --git a/Projects/Domains/PlaylistDomain/Interface/Entity/WMPlaylistDetailEntity.swift b/Projects/Domains/PlaylistDomain/Interface/Entity/WMPlaylistDetailEntity.swift new file mode 100644 index 000000000..e435b490a --- /dev/null +++ b/Projects/Domains/PlaylistDomain/Interface/Entity/WMPlaylistDetailEntity.swift @@ -0,0 +1,21 @@ +import Foundation +import SongsDomainInterface + +public struct WMPlaylistDetailEntity: Equatable { + public init( + key: String, + title: String, + songs: [SongEntity], + image: String, + playlistURL: String + ) { + self.key = key + self.title = title + self.songs = songs + self.image = image + self.playlistURL = playlistURL + } + + public let key, title, image, playlistURL: String + public var songs: [SongEntity] +} diff --git a/Projects/Domains/PlaylistDomain/Interface/Repository/PlaylistRepository.swift b/Projects/Domains/PlaylistDomain/Interface/Repository/PlaylistRepository.swift index c1a5cd8ee..c8d0e9eea 100644 --- a/Projects/Domains/PlaylistDomain/Interface/Repository/PlaylistRepository.swift +++ b/Projects/Domains/PlaylistDomain/Interface/Repository/PlaylistRepository.swift @@ -6,6 +6,7 @@ import SongsDomainInterface public protocol PlaylistRepository { func fetchRecommendPlaylist() -> Single<[RecommendPlaylistEntity]> func fetchPlaylistDetail(id: String, type: PlaylistType) -> Single + func fetchWMPlaylistDetail(id: String) -> Single func updateTitleAndPrivate(key: String, title: String?, isPrivate: Bool?) -> Completable func createPlaylist(title: String) -> Single func fetchPlaylistSongs(key: String) -> Single<[SongEntity]> diff --git a/Projects/Domains/PlaylistDomain/Interface/UseCase/FetchWMPlaylistDetailUseCase.swift b/Projects/Domains/PlaylistDomain/Interface/UseCase/FetchWMPlaylistDetailUseCase.swift new file mode 100644 index 000000000..4f3a3ad50 --- /dev/null +++ b/Projects/Domains/PlaylistDomain/Interface/UseCase/FetchWMPlaylistDetailUseCase.swift @@ -0,0 +1,6 @@ +import Foundation +import RxSwift + +public protocol FetchWMPlaylistDetailUseCase { + func execute(id: String) -> Single +} diff --git a/Projects/Domains/PlaylistDomain/Sources/API/PlaylistAPI.swift b/Projects/Domains/PlaylistDomain/Sources/API/PlaylistAPI.swift index adc218984..9262992a1 100644 --- a/Projects/Domains/PlaylistDomain/Sources/API/PlaylistAPI.swift +++ b/Projects/Domains/PlaylistDomain/Sources/API/PlaylistAPI.swift @@ -8,6 +8,7 @@ import PlaylistDomainInterface public enum PlaylistAPI { case fetchPlaylistDetail(id: String, type: PlaylistType) // ํ”Œ๋ฆฌ ์ƒ์„ธ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ + case fetchWMPlaylistDetail(id: String) // ์™๋ฎค ํ”Œ๋ฆฌ ์ƒ์„ธ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ case updateTitleAndPrivate(key: String, title: String?, isPrivate: Bool?) // title and private ์—…๋ฐ์ดํŠธ case createPlaylist(title: String) // ํ”Œ๋ฆฌ ์ƒ์„ฑ case fetchPlaylistSongs(key: String) // ์ „์ฒด ์žฌ์ƒ ์‹œ ๊ณก ๋ฐ์ดํ„ฐ๋งŒ ๊ฐ€์ ธ์˜ค๊ธฐ @@ -33,12 +34,10 @@ extension PlaylistAPI: WMAPI { return "/recommend/list" case let .fetchPlaylistDetail(id: id, type: type): - switch type { - case .unknown, .my: - return "/\(id)" - case .wmRecommend: - return "/recommend/\(id)" - } + return "/\(id)" + + case let .fetchWMPlaylistDetail(id: id): + return "/recommend/\(id)" case let .updateTitleAndPrivate(key: key, _, _): return "/\(key)" @@ -65,7 +64,8 @@ extension PlaylistAPI: WMAPI { public var method: Moya.Method { switch self { - case .fetchRecommendPlaylist, .fetchPlaylistDetail, .fetchPlaylistSongs, .checkSubscription, + case .fetchRecommendPlaylist, .fetchPlaylistDetail, .fetchWMPlaylistDetail, .fetchPlaylistSongs, + .checkSubscription, .requestPlaylistOwnerID: return .get @@ -85,7 +85,8 @@ extension PlaylistAPI: WMAPI { public var task: Moya.Task { switch self { - case .fetchRecommendPlaylist, .fetchPlaylistDetail, .fetchPlaylistSongs, .subscribePlaylist, .checkSubscription, + case .fetchRecommendPlaylist, .fetchPlaylistDetail, .fetchWMPlaylistDetail, .fetchPlaylistSongs, + .subscribePlaylist, .checkSubscription, .requestPlaylistOwnerID: return .requestPlain @@ -124,7 +125,7 @@ extension PlaylistAPI: WMAPI { public var jwtTokenType: JwtTokenType { switch self { - case .fetchRecommendPlaylist: + case .fetchRecommendPlaylist, .fetchWMPlaylistDetail: return .none case let .fetchPlaylistDetail(_, type): diff --git a/Projects/Domains/PlaylistDomain/Sources/DataSource/RemotePlaylistDataSourceImpl.swift b/Projects/Domains/PlaylistDomain/Sources/DataSource/RemotePlaylistDataSourceImpl.swift index afe05c298..b53b551a4 100644 --- a/Projects/Domains/PlaylistDomain/Sources/DataSource/RemotePlaylistDataSourceImpl.swift +++ b/Projects/Domains/PlaylistDomain/Sources/DataSource/RemotePlaylistDataSourceImpl.swift @@ -22,6 +22,12 @@ public final class RemotePlaylistDataSourceImpl: BaseRemoteDataSource Single { + request(.fetchWMPlaylistDetail(id: id)) + .map(WMPlaylistDetailResponseDTO.self) + .map { $0.toDomain() } + } + public func updateTitleAndPrivate(key: String, title: String?, isPrivate: Bool?) -> Completable { request(.updateTitleAndPrivate(key: key, title: title, isPrivate: isPrivate)) .asCompletable() diff --git a/Projects/Domains/PlaylistDomain/Sources/Repository/PlaylistRepositoryImpl.swift b/Projects/Domains/PlaylistDomain/Sources/Repository/PlaylistRepositoryImpl.swift index 7f19d4b69..fd21dbf66 100644 --- a/Projects/Domains/PlaylistDomain/Sources/Repository/PlaylistRepositoryImpl.swift +++ b/Projects/Domains/PlaylistDomain/Sources/Repository/PlaylistRepositoryImpl.swift @@ -21,6 +21,10 @@ public final class PlaylistRepositoryImpl: PlaylistRepository { remotePlaylistDataSource.fetchPlaylistDetail(id: id, type: type) } + public func fetchWMPlaylistDetail(id: String) -> Single { + remotePlaylistDataSource.fetchWMPlaylistDetail(id: id) + } + public func updateTitleAndPrivate(key: String, title: String?, isPrivate: Bool?) -> Completable { remotePlaylistDataSource.updateTitleAndPrivate(key: key, title: title, isPrivate: isPrivate) } diff --git a/Projects/Domains/PlaylistDomain/Sources/ResponseDTO/WmPlaylistDetailResponseDTO.swift b/Projects/Domains/PlaylistDomain/Sources/ResponseDTO/WmPlaylistDetailResponseDTO.swift new file mode 100644 index 000000000..8a81ac91b --- /dev/null +++ b/Projects/Domains/PlaylistDomain/Sources/ResponseDTO/WmPlaylistDetailResponseDTO.swift @@ -0,0 +1,32 @@ +import Foundation +import PlaylistDomainInterface +import SongsDomain +import SongsDomainInterface + +public struct WMPlaylistDetailResponseDTO: Decodable { + public let key: String? + public let title: String + public let songs: [SingleSongResponseDTO]? + public let imageURL: String + public let playlistURL: String + + enum CodingKeys: String, CodingKey { + case key + case title + case songs + case imageURL = "imageUrl" + case playlistURL = "playlistUrl" + } +} + +public extension WMPlaylistDetailResponseDTO { + func toDomain() -> WMPlaylistDetailEntity { + WMPlaylistDetailEntity( + key: key ?? "", + title: title, + songs: (songs ?? []).map { $0.toDomain() }, + image: imageURL, + playlistURL: playlistURL + ) + } +} diff --git a/Projects/Domains/PlaylistDomain/Sources/UseCase/FetchPlaylistDetailUseCaseImpl.swift b/Projects/Domains/PlaylistDomain/Sources/UseCase/FetchPlaylistDetailUseCaseImpl.swift index ceb2cd73a..cc84d35e5 100644 --- a/Projects/Domains/PlaylistDomain/Sources/UseCase/FetchPlaylistDetailUseCaseImpl.swift +++ b/Projects/Domains/PlaylistDomain/Sources/UseCase/FetchPlaylistDetailUseCaseImpl.swift @@ -1,11 +1,3 @@ -// -// FetchArtistListUseCaseImpl.swift -// DataModule -// -// Created by KTH on 2023/02/08. -// Copyright ยฉ 2023 yongbeomkwak. All rights reserved. -// - import Foundation import PlaylistDomainInterface import RxSwift diff --git a/Projects/Domains/PlaylistDomain/Sources/UseCase/FetchWMPlaylistDetailUseCaseImpl.swift b/Projects/Domains/PlaylistDomain/Sources/UseCase/FetchWMPlaylistDetailUseCaseImpl.swift new file mode 100644 index 000000000..419decf16 --- /dev/null +++ b/Projects/Domains/PlaylistDomain/Sources/UseCase/FetchWMPlaylistDetailUseCaseImpl.swift @@ -0,0 +1,17 @@ +import Foundation +import PlaylistDomainInterface +import RxSwift + +public struct FetchWMPlaylistDetailUseCaseImpl: FetchWMPlaylistDetailUseCase { + private let playlistRepository: any PlaylistRepository + + public init( + playlistRepository: PlaylistRepository + ) { + self.playlistRepository = playlistRepository + } + + public func execute(id: String) -> Single { + playlistRepository.fetchWMPlaylistDetail(id: id) + } +} diff --git a/Projects/Domains/SearchDomain/Interface/Enum/Option.swift b/Projects/Domains/SearchDomain/Interface/Enum/Option.swift index e6f91ad43..bee7fa6ea 100644 --- a/Projects/Domains/SearchDomain/Interface/Enum/Option.swift +++ b/Projects/Domains/SearchDomain/Interface/Enum/Option.swift @@ -25,7 +25,7 @@ public enum FilterType: String, Encodable, SearchOptionType { public enum SortType: String, Encodable, SearchOptionType { case latest case oldest - case alphabetical + case relevance case popular public var title: String { @@ -34,8 +34,8 @@ public enum SortType: String, Encodable, SearchOptionType { "์ตœ์‹ ์ˆœ" case .oldest: "๊ณผ๊ฑฐ์ˆœ" - case .alphabetical: - "๊ฐ€๋‚˜๋‹ค์ˆœ" + case .relevance: + "๊ด€๋ จ๋„์ˆœ" case .popular: "์ธ๊ธฐ์ˆœ" } diff --git a/Projects/Domains/UserDomain/Interface/Entity/PlaylistEntity.swift b/Projects/Domains/UserDomain/Interface/Entity/PlaylistEntity.swift index 2468db8bf..023dcd975 100644 --- a/Projects/Domains/UserDomain/Interface/Entity/PlaylistEntity.swift +++ b/Projects/Domains/UserDomain/Interface/Entity/PlaylistEntity.swift @@ -8,11 +8,13 @@ public struct PlaylistEntity: Equatable { image: String, songCount: Int, userId: String, + private: Bool, isSelected: Bool = false ) { self.key = key self.title = title self.image = image + self.private = `private` self.isSelected = isSelected self.songCount = songCount self.userId = userId @@ -20,5 +22,5 @@ public struct PlaylistEntity: Equatable { public let key, title, image, userId: String public let songCount: Int - public var isSelected: Bool + public var `private`, isSelected: Bool } diff --git a/Projects/Domains/UserDomain/Sources/ResponseDTO/PlaylistResponseDTO.swift b/Projects/Domains/UserDomain/Sources/ResponseDTO/PlaylistResponseDTO.swift index 249d42c2f..4250207fe 100644 --- a/Projects/Domains/UserDomain/Sources/ResponseDTO/PlaylistResponseDTO.swift +++ b/Projects/Domains/UserDomain/Sources/ResponseDTO/PlaylistResponseDTO.swift @@ -16,6 +16,7 @@ public struct PlaylistResponseDTO: Decodable { public let user: PlaylistResponseDTO.User public let imageUrl: String public let songCount: Int + public let `private`: Bool public struct User: Decodable { public let handle: String @@ -30,7 +31,8 @@ public extension PlaylistResponseDTO { title: title, image: imageUrl, songCount: songCount, - userId: user.handle + userId: user.handle, + private: `private` ) } } diff --git a/Projects/Domains/UserDomain/Testing/FetchPlaylistUseCaseStub.swift b/Projects/Domains/UserDomain/Testing/FetchPlaylistUseCaseStub.swift index 530fe9e9f..eb31b2137 100644 --- a/Projects/Domains/UserDomain/Testing/FetchPlaylistUseCaseStub.swift +++ b/Projects/Domains/UserDomain/Testing/FetchPlaylistUseCaseStub.swift @@ -4,15 +4,36 @@ import UserDomainInterface public struct FetchPlaylistUseCaseStub: FetchPlaylistUseCase { let items: [PlaylistEntity] = [ - .init(key: "123", title: "์šฐ์ค‘์ถฉํ•œ ์žฅ๋งˆ์ฒ  ์—ฌ๋ฆ„์— ๋“ฃ๊ธฐ ์ข‹์€ ์ผ๋ณธ ์‹œํ‹ฐํŒ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ", image: "", songCount: 0, userId: ""), - .init(key: "1234", title: "๋น„๋‚ด๋ฆฌ๋Š” ๋„์‹œ, ์„ธ๋ จ๋œ ๋ฌด๋“œ ๊ฐ๊ฐ์ ์ธ ํŒ์†กโ˜”๏ธ ๋ถ„์œ„๊ธฐ ์žˆ๋Š” ๋…ธ๋ž˜ ๋ชจ์Œ", image: "", songCount: 1, userId: ""), - .init(key: "1424", title: "[๐๐ฅ๐š๐ฒ๐ฅ๐ข๐ฌ๐ญ] แ„‹แ…งแ„…แ…ณแ†ท แ„‡แ…กแ†ท, แ„แ…ฌแ„€แ…ณแ†ซแ„€แ…ตแ†ฏ์— ๊ฝ‚๋Š” แ„‘แ…ณแ†ฏแ„…แ…ฆแ„‹แ…ตแ„…แ…ตแ„‰แ…ณแ„แ…ณ๐Ÿšƒ", image: "", songCount: 200, userId: ""), + .init( + key: "123", + title: "์šฐ์ค‘์ถฉํ•œ ์žฅ๋งˆ์ฒ  ์—ฌ๋ฆ„์— ๋“ฃ๊ธฐ ์ข‹์€ ์ผ๋ณธ ์‹œํ‹ฐํŒ ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ", + image: "", + songCount: 0, + userId: "", + private: true + ), + .init( + key: "1234", + title: "๋น„๋‚ด๋ฆฌ๋Š” ๋„์‹œ, ์„ธ๋ จ๋œ ๋ฌด๋“œ ๊ฐ๊ฐ์ ์ธ ํŒ์†กโ˜”๏ธ ๋ถ„์œ„๊ธฐ ์žˆ๋Š” ๋…ธ๋ž˜ ๋ชจ์Œ", + image: "", + songCount: 1, + userId: "", + private: true + ), + .init( + key: "1424", + title: "[๐๐ฅ๐š๐ฒ๐ฅ๐ข๐ฌ๐ญ] แ„‹แ…งแ„…แ…ณแ†ท แ„‡แ…กแ†ท, แ„แ…ฌแ„€แ…ณแ†ซแ„€แ…ตแ†ฏ์— ๊ฝ‚๋Š” แ„‘แ…ณแ†ฏแ„…แ…ฆแ„‹แ…ตแ„…แ…ตแ„‰แ…ณแ„แ…ณ๐Ÿšƒ", + image: "", + songCount: 200, + userId: "", + private: false + ), .init( key: "1324", title: "๐๐ฅ๐š๐ฒ๐ฅ๐ข๐ฌ๐ญ ๋ฒŒ์จ ์—ฌ๋ฆ„์ด์•ผ? ๋‚ด ๋ฐฉ์„ ์ฒญ๋Ÿ‰ํ•œ ์บ˜๋ฆฌํฌ๋‹ˆ์•„ ํ•ด๋ณ€์œผ๋กœ ์‹ ๋‚˜๋Š” ์—ฌ๋ฆ„ ํŒ์†ก ๐’๐ฎ๐ฆ๐ฆ๐ž๐ซ ๐ข๐ฌ ๐œ๐จ๐ฆ๐ข๐ง๐  ๐ŸŒด", image: "", songCount: 1000, - userId: "" + userId: "", private: true ) ] diff --git a/Projects/Features/ArtistFeature/Resources/Artist.storyboard b/Projects/Features/ArtistFeature/Resources/Artist.storyboard index 9f9175a06..3b918801a 100644 --- a/Projects/Features/ArtistFeature/Resources/Artist.storyboard +++ b/Projects/Features/ArtistFeature/Resources/Artist.storyboard @@ -138,16 +138,13 @@ - + @@ -227,9 +223,10 @@ - - - + + + + @@ -245,16 +242,13 @@ - + - - - @@ -262,8 +256,6 @@ - - @@ -393,13 +385,17 @@ - + - @@ -53,23 +74,23 @@ - - - + + + + + - + + - - - - + diff --git a/Projects/Features/BaseFeature/Sources/Analytics/ContainSongsAnalyticsLog.swift b/Projects/Features/BaseFeature/Sources/Analytics/ContainSongsAnalyticsLog.swift new file mode 100644 index 000000000..2179de282 --- /dev/null +++ b/Projects/Features/BaseFeature/Sources/Analytics/ContainSongsAnalyticsLog.swift @@ -0,0 +1,14 @@ +import LogManager + +enum ContainSongsAnalyticsLog: AnalyticsLogType { + case clickCreatePlaylistButton(location: CreatePlaylistLocation) + case completeAddMusics(playlistId: String, count: Int) +} + +enum CreatePlaylistLocation: String, AnalyticsLogEnumParametable { + case addMusics = "add_musics" + + var description: String { + self.rawValue + } +} diff --git a/Projects/Features/BaseFeature/Sources/Components/ContainSongsComponent.swift b/Projects/Features/BaseFeature/Sources/Components/ContainSongsComponent.swift index cafc5e2c3..f8abe6a35 100644 --- a/Projects/Features/BaseFeature/Sources/Components/ContainSongsComponent.swift +++ b/Projects/Features/BaseFeature/Sources/Components/ContainSongsComponent.swift @@ -8,20 +8,20 @@ import UIKit import UserDomainInterface public protocol ContainSongsDependency: Dependency { - var multiPurposePopUpFactory: any MultiPurposePopupFactory { get } + var multiPurposePopupFactory: any MultiPurposePopupFactory { get } var fetchPlayListUseCase: any FetchPlaylistUseCase { get } var addSongIntoPlaylistUseCase: any AddSongIntoPlaylistUseCase { get } var createPlaylistUseCase: any CreatePlaylistUseCase { get } var fetchPlaylistCreationPriceUseCase: any FetchPlaylistCreationPriceUseCase { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } var logoutUseCase: any LogoutUseCase { get } } public final class ContainSongsComponent: Component, ContainSongsFactory { public func makeView(songs: [String]) -> UIViewController { return ContainSongsViewController.viewController( - multiPurposePopUpFactory: dependency.multiPurposePopUpFactory, - textPopUpFactory: dependency.textPopUpFactory, + multiPurposePopupFactory: dependency.multiPurposePopupFactory, + textPopupFactory: dependency.textPopupFactory, viewModel: .init( songs: songs, createPlaylistUseCase: dependency.createPlaylistUseCase, diff --git a/Projects/Features/BaseFeature/Sources/Components/TextPopUpComponent.swift b/Projects/Features/BaseFeature/Sources/Components/TextPopUpComponent.swift index 611aa3c8e..545b6aa10 100644 --- a/Projects/Features/BaseFeature/Sources/Components/TextPopUpComponent.swift +++ b/Projects/Features/BaseFeature/Sources/Components/TextPopUpComponent.swift @@ -2,7 +2,7 @@ import BaseFeatureInterface import NeedleFoundation import UIKit -public final class TextPopUpComponent: Component, TextPopUpFactory { +public final class TextPopupComponent: Component, TextPopupFactory { public func makeView( text: String?, cancelButtonIsHidden: Bool, diff --git a/Projects/Features/BaseFeature/Sources/Etc/PlayState/PlayState+Playlist.swift b/Projects/Features/BaseFeature/Sources/Etc/PlayState/PlayState+Playlist.swift index 80ed7deb6..cf85ac66b 100644 --- a/Projects/Features/BaseFeature/Sources/Etc/PlayState/PlayState+Playlist.swift +++ b/Projects/Features/BaseFeature/Sources/Etc/PlayState/PlayState+Playlist.swift @@ -12,10 +12,6 @@ import SongsDomainInterface import Utility final class Playlist { - private enum Const { - static let maximumListCount = 50 - } - @Published private(set) var list: [PlaylistItem] = [] init(list: [PlaylistItem] = []) { @@ -31,26 +27,18 @@ final class Playlist { func append(_ item: PlaylistItem) { list.append(item) - - processListMaximumCount() } func append(_ items: [PlaylistItem]) { list.append(contentsOf: items) - - processListMaximumCount() } func insert(_ newElement: PlaylistItem, at: Int) { list.insert(newElement, at: at) - - processListMaximumCount() } func update(contentsOf items: [PlaylistItem]) { list = items - - processListMaximumCount() } func remove(at index: Int) { @@ -82,6 +70,10 @@ final class Playlist { return list.contains(item) } + func contains(id: String) -> Bool { + return list.contains { $0.id == id } + } + func reorderPlaylist(from: Int, to: Int) { let movedData = list[from] list.remove(at: from) @@ -93,11 +85,3 @@ final class Playlist { return list.firstIndex(of: item) } } - -private extension Playlist { - func processListMaximumCount() { - if list.count > Const.maximumListCount { - list = Array(list.suffix(Const.maximumListCount)) - } - } -} diff --git a/Projects/Features/BaseFeature/Sources/Etc/PlayState/PlayState.swift b/Projects/Features/BaseFeature/Sources/Etc/PlayState/PlayState.swift index d614bb05a..fd0cf6938 100644 --- a/Projects/Features/BaseFeature/Sources/Etc/PlayState/PlayState.swift +++ b/Projects/Features/BaseFeature/Sources/Etc/PlayState/PlayState.swift @@ -8,6 +8,7 @@ import Combine import Foundation +import LogManager import SongsDomainInterface import Utility @@ -31,16 +32,16 @@ public final class PlayState { } deinit { - DEBUG_LOG("๐Ÿš€:: \(Self.self) deinit") + LogManager.printDebug("๐Ÿš€:: \(Self.self) deinit") NotificationCenter.default.removeObserver(self) } /// ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ์— ๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์ƒ๊ฒผ์„ ๋•Œ, ๋กœ์ปฌ DB๋ฅผ ๋ฎ์–ด์”๋‹ˆ๋‹ค. private func subscribePlayListChanges() { playlist.subscribeListChanges() - .map { $0.suffix(50) } .map { Array($0) } .sink { [weak self] playlistItems in + LogManager.setUserProperty(property: .playlistSongTotal(count: playlistItems.count)) self?.updatePlaylistChangesToLocalDB(playList: playlistItems) } .store(in: &subscription) @@ -122,6 +123,10 @@ public final class PlayState { return playlist.contains(item) } + public func contains(id: String) -> Bool { + return playlist.contains(id: id) + } + public func reorderPlaylist(from: Int, to: Int) { playlist.reorderPlaylist(from: from, to: to) } diff --git a/Projects/Features/BaseFeature/Sources/GlobalState/PlayListPresenterGlobalState.swift b/Projects/Features/BaseFeature/Sources/GlobalState/PlayListPresenterGlobalState.swift index c81300aba..b029b9d55 100644 --- a/Projects/Features/BaseFeature/Sources/GlobalState/PlayListPresenterGlobalState.swift +++ b/Projects/Features/BaseFeature/Sources/GlobalState/PlayListPresenterGlobalState.swift @@ -2,20 +2,25 @@ import Foundation import RxSwift public protocol PlayListPresenterGlobalStateProtocol { - var presentPlayListObservable: Observable { get } + var presentPlayListObservable: Observable { get } + func presentPlayList(currentSongID: String?) func presentPlayList() } public final class PlayListPresenterGlobalState: PlayListPresenterGlobalStateProtocol { - private let presentPlayListSubject = PublishSubject() - public var presentPlayListObservable: Observable { + private let presentPlayListSubject = PublishSubject() + public var presentPlayListObservable: Observable { presentPlayListSubject } public init() {} + public func presentPlayList(currentSongID: String?) { + presentPlayListSubject.onNext(currentSongID) + } + public func presentPlayList() { - presentPlayListSubject.onNext(()) + presentPlayList(currentSongID: nil) } } diff --git a/Projects/Features/BaseFeature/Sources/Protocols/EditSheetViewType.swift b/Projects/Features/BaseFeature/Sources/Protocols/EditSheetViewType.swift index 0848b4111..66f680f61 100644 --- a/Projects/Features/BaseFeature/Sources/Protocols/EditSheetViewType.swift +++ b/Projects/Features/BaseFeature/Sources/Protocols/EditSheetViewType.swift @@ -51,7 +51,7 @@ public extension EditSheetViewType where Self: UIViewController { bottomSheetView.present(in: view) // ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ ๋ทฐ์ปจ์—์„œ ํ•ด๋‹น ๋…ธํ‹ฐ๋ฅผ ์ˆ˜์‹ , ํŒ์—…์ด ์˜ฌ๋ผ์˜ค๋ฉด ๋ฏธ๋‹ˆ ํ”Œ๋ ˆ์ด์–ด๋ฅผ ์ˆจ๊น๋‹ˆ๋‹ค. - NotificationCenter.default.post(name: .willShowSongCart, object: nil) + NotificationCenter.default.post(name: .shouldHidePlaylistFloatingButton, object: nil) } /// ํŽธ์ง‘ํ•˜๊ธฐ ํŒ์—…์„ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. @@ -66,6 +66,6 @@ public extension EditSheetViewType where Self: UIViewController { self.bottomSheetView = nil // ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ ๋ทฐ์ปจ์—์„œ ํ•ด๋‹น ๋…ธํ‹ฐ๋ฅผ ์ˆ˜์‹ , ํŒ์—…์ด ์˜ฌ๋ผ์˜ค๋ฉด ๋ฏธ๋‹ˆ ํ”Œ๋ ˆ์ด์–ด๋ฅผ ๋‹ค์‹œ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. - NotificationCenter.default.post(name: .willHideSongCart, object: nil) + NotificationCenter.default.post(name: .shouldShowPlaylistFloatingButton, object: nil) } } diff --git a/Projects/Features/BaseFeature/Sources/Protocols/PlaylistEditSheetViewType.swift b/Projects/Features/BaseFeature/Sources/Protocols/PlaylistEditSheetViewType.swift index 1f8ba768e..0e1366615 100644 --- a/Projects/Features/BaseFeature/Sources/Protocols/PlaylistEditSheetViewType.swift +++ b/Projects/Features/BaseFeature/Sources/Protocols/PlaylistEditSheetViewType.swift @@ -42,7 +42,7 @@ public extension PlaylistEditSheetViewType where Self: UIViewController { bottomSheetView.present(in: view) // ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ ๋ทฐ์ปจ์—์„œ ํ•ด๋‹น ๋…ธํ‹ฐ๋ฅผ ์ˆ˜์‹ , ํŒ์—…์ด ์˜ฌ๋ผ์˜ค๋ฉด ๋ฏธ๋‹ˆ ํ”Œ๋ ˆ์ด์–ด๋ฅผ ์ˆจ๊น๋‹ˆ๋‹ค. - NotificationCenter.default.post(name: .willShowSongCart, object: nil) + NotificationCenter.default.post(name: .shouldHidePlaylistFloatingButton, object: nil) } /// ํŽธ์ง‘ํ•˜๊ธฐ ํŒ์—…์„ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. @@ -57,6 +57,6 @@ public extension PlaylistEditSheetViewType where Self: UIViewController { self.bottomSheetView = nil // ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ ๋ทฐ์ปจ์—์„œ ํ•ด๋‹น ๋…ธํ‹ฐ๋ฅผ ์ˆ˜์‹ , ํŒ์—…์ด ์˜ฌ๋ผ์˜ค๋ฉด ๋ฏธ๋‹ˆ ํ”Œ๋ ˆ์ด์–ด๋ฅผ ๋‹ค์‹œ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. - NotificationCenter.default.post(name: .willHideSongCart, object: nil) + NotificationCenter.default.post(name: .shouldShowPlaylistFloatingButton, object: nil) } } diff --git a/Projects/Features/BaseFeature/Sources/Protocols/SongCartViewType.swift b/Projects/Features/BaseFeature/Sources/Protocols/SongCartViewType.swift index 0661ebdd0..1a696743d 100644 --- a/Projects/Features/BaseFeature/Sources/Protocols/SongCartViewType.swift +++ b/Projects/Features/BaseFeature/Sources/Protocols/SongCartViewType.swift @@ -77,7 +77,7 @@ public extension SongCartViewType where Self: UIViewController { bottomSheetView.present(in: view) // ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ ๋ทฐ์ปจ์—์„œ ํ•ด๋‹น ๋…ธํ‹ฐ๋ฅผ ์ˆ˜์‹ , ํŒ์—…์ด ์˜ฌ๋ผ์˜ค๋ฉด ๋ฏธ๋‹ˆ ํ”Œ๋ ˆ์ด์–ด๋ฅผ ์ˆจ๊น๋‹ˆ๋‹ค. - NotificationCenter.default.post(name: .willShowSongCart, object: nil) + NotificationCenter.default.post(name: .shouldHidePlaylistFloatingButton, object: nil) } /// ๋…ธ๋ž˜ ๋‹ด๊ธฐ ํŒ์—…์„ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. @@ -92,6 +92,6 @@ public extension SongCartViewType where Self: UIViewController { self.bottomSheetView = nil // ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ ๋ทฐ์ปจ์—์„œ ํ•ด๋‹น ๋…ธํ‹ฐ๋ฅผ ์ˆ˜์‹ , ํŒ์—…์ด ์˜ฌ๋ผ์˜ค๋ฉด ๋ฏธ๋‹ˆ ํ”Œ๋ ˆ์ด์–ด๋ฅผ ๋‹ค์‹œ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. - NotificationCenter.default.post(name: .willHideSongCart, object: nil) + NotificationCenter.default.post(name: .shouldShowPlaylistFloatingButton, object: nil) } } diff --git a/Projects/Features/BaseFeature/Sources/Protocols/WMBottomSheetViewType.swift b/Projects/Features/BaseFeature/Sources/Protocols/WMBottomSheetViewType.swift index a81b7e208..2f01b0108 100644 --- a/Projects/Features/BaseFeature/Sources/Protocols/WMBottomSheetViewType.swift +++ b/Projects/Features/BaseFeature/Sources/Protocols/WMBottomSheetViewType.swift @@ -37,7 +37,7 @@ public extension WMBottomSheetViewType where Self: UIViewController { bottomSheetView.present(in: view) // ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ ๋ทฐ์ปจ์—์„œ ํ•ด๋‹น ๋…ธํ‹ฐ๋ฅผ ์ˆ˜์‹ , ํŒ์—…์ด ์˜ฌ๋ผ์˜ค๋ฉด ๋ฏธ๋‹ˆ ํ”Œ๋ ˆ์ด์–ด๋ฅผ ์ˆจ๊น๋‹ˆ๋‹ค. - NotificationCenter.default.post(name: .willShowSongCart, object: nil) + NotificationCenter.default.post(name: .shouldHidePlaylistFloatingButton, object: nil) } /// ํŽธ์ง‘ํ•˜๊ธฐ ํŒ์—…์„ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. @@ -52,7 +52,7 @@ public extension WMBottomSheetViewType where Self: UIViewController { // ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ ๋ทฐ์ปจ์—์„œ ํ•ด๋‹น ๋…ธํ‹ฐ๋ฅผ ์ˆ˜์‹ , ํŒ์—…์ด ์˜ฌ๋ผ์˜ค๋ฉด ๋ฏธ๋‹ˆ ํ”Œ๋ ˆ์ด์–ด๋ฅผ ๋‹ค์‹œ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. if postNoti { - NotificationCenter.default.post(name: .willHideSongCart, object: nil) + NotificationCenter.default.post(name: .shouldShowPlaylistFloatingButton, object: nil) } } } diff --git a/Projects/Features/BaseFeature/Sources/ViewControllers/ContainSongsViewController.swift b/Projects/Features/BaseFeature/Sources/ViewControllers/ContainSongsViewController.swift index e0e21d1d1..444f559b3 100644 --- a/Projects/Features/BaseFeature/Sources/ViewControllers/ContainSongsViewController.swift +++ b/Projects/Features/BaseFeature/Sources/ViewControllers/ContainSongsViewController.swift @@ -2,6 +2,7 @@ import BaseDomainInterface import BaseFeatureInterface import DesignSystem import Localization +import LogManager import NVActivityIndicatorView import PlaylistDomainInterface import RxSwift @@ -14,11 +15,11 @@ public final class ContainSongsViewController: BaseViewController, ViewControlle @IBOutlet weak var titleLabel: UILabel! @IBOutlet weak var tableView: UITableView! @IBOutlet weak var indicator: NVActivityIndicatorView! - @IBOutlet weak var songCountLabel: UILabel! - @IBOutlet weak var subTitleLabel: UILabel! - - var multiPurposePopUpFactory: MultiPurposePopupFactory! - var textPopUpFactory: TextPopUpFactory! + let containerView = UIView(frame: CGRect(x: .zero, y: .zero, width: APP_WIDTH(), height: 48)) + let songCountLabel: UILabel = UILabel() + let subTitleLabel: UILabel = UILabel() + var multiPurposePopupFactory: MultiPurposePopupFactory! + var textPopupFactory: TextPopupFactory! var viewModel: ContainSongsViewModel! lazy var input = ContainSongsViewModel.Input() @@ -29,6 +30,8 @@ public final class ContainSongsViewController: BaseViewController, ViewControlle override public func viewDidLoad() { super.viewDidLoad() + addSubviews() + setLayout() configureUI() inputBind() outputBind() @@ -36,22 +39,39 @@ public final class ContainSongsViewController: BaseViewController, ViewControlle } public static func viewController( - multiPurposePopUpFactory: MultiPurposePopupFactory, - textPopUpFactory: TextPopUpFactory, + multiPurposePopupFactory: MultiPurposePopupFactory, + textPopupFactory: TextPopupFactory, viewModel: ContainSongsViewModel ) -> ContainSongsViewController { let viewController = ContainSongsViewController.viewController( storyBoardName: "Base", bundle: Bundle.module ) - viewController.multiPurposePopUpFactory = multiPurposePopUpFactory - viewController.textPopUpFactory = textPopUpFactory + viewController.multiPurposePopupFactory = multiPurposePopupFactory + viewController.textPopupFactory = textPopupFactory viewController.viewModel = viewModel return viewController } } extension ContainSongsViewController { + private func addSubviews() { + containerView.addSubviews(songCountLabel, subTitleLabel) + tableView.tableHeaderView = containerView + } + + private func setLayout() { + subTitleLabel.snp.makeConstraints { + $0.leading.equalTo(songCountLabel.snp.trailing) + $0.centerY.equalTo(songCountLabel.snp.centerY) + } + + songCountLabel.snp.makeConstraints { + $0.leading.equalTo(closeButton.snp.leading) + $0.centerY.equalToSuperview() + } + } + private func inputBind() { tableView.rx.setDelegate(self).disposed(by: disposeBag) @@ -96,9 +116,9 @@ extension ContainSongsViewController { }) .bind(to: tableView.rx.items) { tableView, index, model -> UITableViewCell in guard let cell = tableView.dequeueReusableCell( - withIdentifier: "CurrentPlayListTableViewCell", + withIdentifier: "CurrentPlaylistTableViewCell", for: IndexPath(row: index, section: 0) - ) as? CurrentPlayListTableViewCell + ) as? CurrentPlaylistTableViewCell else { return UITableViewCell() } @@ -127,8 +147,7 @@ extension ContainSongsViewController { output.onLogout .bind(with: self) { owner, error in - let toastFont = DesignSystemFontFamily.Pretendard.light.font(size: 14) - owner.showToast(text: error.localizedDescription, font: toastFont) + owner.showToast(text: error.localizedDescription, options: [.tabBar]) owner.dismiss(animated: true) } .disposed(by: disposeBag) @@ -142,11 +161,11 @@ extension ContainSongsViewController { let (user, price) = (info.0, info.1) if user.itemCount < price { - owner.showToast(text: LocalizationStrings.lackOfMoney(price - user.itemCount), options: [.empty]) + owner.showToast(text: LocalizationStrings.lackOfMoney(price - user.itemCount), options: [.tabBar]) return } - let text = owner.textPopUpFactory.makeView( + let text = owner.textPopupFactory.makeView( text: "๋ฆฌ์ŠคํŠธ๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด์„œ๋Š”\n์Œํ‘œ ์—ด๋งค \(price)๊ฐœ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", cancelButtonIsHidden: false, confirmButtonText: "\(price)๊ฐœ ์‚ฌ์šฉ", @@ -162,14 +181,14 @@ extension ContainSongsViewController { output.showCreationPopup .bind(with: self) { owner, _ in - let multiPurposePopVc = owner.multiPurposePopUpFactory.makeView( + let multiPurposePopupVc = owner.multiPurposePopupFactory.makeView( type: .creation, key: "", completion: { text in owner.input.createPlaylist.onNext(text) } ) - owner.showBottomSheet(content: multiPurposePopVc, size: .fixed(296)) + owner.showBottomSheet(content: multiPurposePopupVc, size: .fixed(296)) } .disposed(by: disposeBag) } @@ -182,6 +201,7 @@ extension ContainSongsViewController { titleLabel.text = "๋ฆฌ์ŠคํŠธ์— ๋‹ด๊ธฐ" titleLabel.setTextWithAttributes(kernValue: -0.5) + // 24 , 12 songCountLabel.font = DesignSystemFontFamily.Pretendard.medium.font(size: 14) songCountLabel.textColor = DesignSystemAsset.PrimaryColor.point.color songCountLabel.text = "\(viewModel.songs.count)" @@ -204,7 +224,7 @@ extension ContainSongsViewController: UITableViewDelegate { } public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let header = ContainPlayListHeaderView(frame: CGRect(x: 0, y: 0, width: APP_WIDTH(), height: 140)) + let header = ContainPlaylistHeaderView(frame: CGRect(x: 0, y: 0, width: APP_WIDTH(), height: 140)) header.delegate = self return header } @@ -214,8 +234,9 @@ extension ContainSongsViewController: UITableViewDelegate { } } -extension ContainSongsViewController: ContainPlayListHeaderViewDelegate { +extension ContainSongsViewController: ContainPlaylistHeaderViewDelegate { public func action() { + LogManager.analytics(ContainSongsAnalyticsLog.clickCreatePlaylistButton(location: .addMusics)) input.creationButtonDidTap.onNext(()) } } diff --git a/Projects/Features/BaseFeature/Sources/ViewControllers/ContractViewController.swift b/Projects/Features/BaseFeature/Sources/ViewControllers/ContractViewController.swift index b355ca5b9..0e8fe94d8 100644 --- a/Projects/Features/BaseFeature/Sources/ViewControllers/ContractViewController.swift +++ b/Projects/Features/BaseFeature/Sources/ViewControllers/ContractViewController.swift @@ -7,10 +7,12 @@ // import DesignSystem +import LogManager import NVActivityIndicatorView import PDFKit import RxCocoa import RxSwift +import SnapKit import UIKit import Utility @@ -32,9 +34,9 @@ extension ContractType { var url: String { switch self { case .privacy: - return "\(BASE_IMAGE_URL())/static/document/privacy.pdf" + return "\(CDN_DOMAIN_URL())/document/privacy.pdf" case .service: - return "\(BASE_IMAGE_URL())/static/document/terms.pdf" + return "\(CDN_DOMAIN_URL())/document/terms.pdf" } } } @@ -47,16 +49,17 @@ public final class ContractViewController: UIViewController, ViewControllerFromS @IBOutlet weak var activityIndicator: NVActivityIndicatorView! var type: ContractType = .privacy - var disposeBag = DisposeBag() + private let disposeBag = DisposeBag() + + deinit { + LogManager.printDebug("โŒ \(Self.self) deinit") + } override public func viewDidLoad() { super.viewDidLoad() configureUI() bindRx() - } - - deinit { - DEBUG_LOG("โŒ \(Self.self) deinit") + loadPDF() } public static func viewController(type: ContractType) -> ContractViewController { @@ -66,8 +69,8 @@ public final class ContractViewController: UIViewController, ViewControllerFromS } } -extension ContractViewController { - private func bindRx() { +private extension ContractViewController { + func bindRx() { Observable.merge( closeButton.rx.tap.map { _ in () }, confirmButton.rx.tap.map { _ in () } @@ -79,19 +82,9 @@ extension ContractViewController { .disposed(by: disposeBag) } - private func loadPdf(document: PDFDocument) { - let pdfView = PDFView(frame: self.fakeView.bounds) - pdfView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - pdfView.autoScales = true - pdfView.displayMode = .singlePageContinuous - pdfView.displayDirection = .vertical - pdfView.document = document - self.fakeView.addSubview(pdfView) - activityIndicator.stopAnimating() - } - - private func configureUI() { - self.navigationController?.setNavigationBarHidden(true, animated: false) + func configureUI() { + navigationController?.setNavigationBarHidden(true, animated: false) + closeButton.setImage(DesignSystemAsset.Navigation.crossClose.image, for: .normal) activityIndicator.type = .circleStrokeSpin activityIndicator.color = DesignSystemAsset.PrimaryColor.point.color @@ -104,22 +97,52 @@ extension ContractViewController { string: "ํ™•์ธ", attributes: [ .font: DesignSystemFontFamily.Pretendard.medium.font(size: 18), - .foregroundColor: DesignSystemAsset.GrayColor.gray25.color, + .foregroundColor: DesignSystemAsset.BlueGrayColor.gray25.color, .kern: -0.5 ] ), for: .normal) - closeButton.setImage(DesignSystemAsset.Navigation.crossClose.image, for: .normal) titleLabel.text = type.title - titleLabel.textColor = DesignSystemAsset.GrayColor.gray900.color + titleLabel.textColor = DesignSystemAsset.BlueGrayColor.gray900.color titleLabel.font = DesignSystemFontFamily.Pretendard.medium.font(size: 16) titleLabel.setTextWithAttributes(kernValue: -0.5) + } +} +private extension ContractViewController { + func loadPDF() { DispatchQueue.global(qos: .default).async { - if let url = URL(string: self.type.url), let document = PDFDocument(url: url) { - DispatchQueue.main.async { - self.loadPdf(document: document) // UI ์ž‘์—…์ด๋ผ main ์Šค๋ ˆ๋“œ๋กœ - } + guard let url = URL(string: self.type.url), + let document = PDFDocument(url: url) else { + self.loadFailPDF() + return + } + self.configurePDF(document: document) + } + } + + func configurePDF(document: PDFDocument) { + DispatchQueue.main.async { + let pdfView = PDFView(frame: self.fakeView.bounds) + pdfView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + pdfView.autoScales = true + pdfView.displayMode = .singlePageContinuous + pdfView.displayDirection = .vertical + pdfView.document = document + self.fakeView.addSubview(pdfView) + self.activityIndicator.stopAnimating() + } + } + + func loadFailPDF() { + DispatchQueue.main.async { + self.activityIndicator.stopAnimating() + self.fakeView.backgroundColor = DesignSystemAsset.BlueGrayColor.gray100.color + let warningView = WMWarningView(text: "ํŒŒ์ผ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฐ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.") + self.view.addSubview(warningView) + warningView.snp.makeConstraints { + $0.top.equalTo(self.view.frame.height / 3) + $0.centerX.equalToSuperview() } } } diff --git a/Projects/Features/BaseFeature/Sources/ViewControllers/MultiPurposePopupViewController.swift b/Projects/Features/BaseFeature/Sources/ViewControllers/MultiPurposePopupViewController.swift index a2cefae3e..7e2740ebd 100644 --- a/Projects/Features/BaseFeature/Sources/ViewControllers/MultiPurposePopupViewController.swift +++ b/Projects/Features/BaseFeature/Sources/ViewControllers/MultiPurposePopupViewController.swift @@ -73,7 +73,7 @@ private extension MultiPurposePopupViewController { let errorColor = DesignSystemAsset.PrimaryColor.increase.color let passColor = DesignSystemAsset.PrimaryColor.decrease.color - owner.countLabel.text = "\(str.count)์ž" + owner.countLabel.text = "\(str.alphabetCharacterCeilCount)์ž" if str.isEmpty { owner.cancelButton.isHidden = true @@ -96,7 +96,7 @@ private extension MultiPurposePopupViewController { owner.countLabel.textColor = errorColor owner.saveButton.isEnabled = false - } else if str.count > owner.viewModel.type.textLimitCount { + } else if str.alphabetCharacterCeilCount > owner.viewModel.type.textLimitCount { owner.dividerView.backgroundColor = errorColor owner.confirmLabel.text = "๊ธ€์ž ์ˆ˜๋ฅผ ์ดˆ๊ณผํ•˜์˜€์Šต๋‹ˆ๋‹ค." owner.confirmLabel.textColor = errorColor @@ -183,9 +183,105 @@ extension MultiPurposePopupViewController: UITextFieldDelegate { replacementString string: String ) -> Bool { guard let char = string.cString(using: String.Encoding.utf8) else { return false } - let isBackSpace = strcmp(char, "\\b") + let isBackSpace: Bool = strcmp(char, "\\b") == -92 + + let currentText = textField.text ?? "" + let latinCharCount: Double = currentText.alphabetCharacterCount + + if let lastChar = currentText.last, + latinCharCount <= Double(viewModel.type.textLimitCount) { + // ์™„์„ฑ๋˜์ง€ ์•Š์€ ํ•œ๊ธ€์ธ ๊ฒฝ์šฐ + if lastChar.isIncompleteHangul { + return true + } + + // ์™„์„ฑ๋œ ํ•œ๊ธ€์ด์ง€๋งŒ, ์ถ”๊ฐ€๋กœ ์ž์Œ์ด ๊ฒฐํ•ฉ๋  ์ˆ˜ ์žˆ๋Š” ๊ฒฝ์šฐ + if !lastChar.isIncompleteHangul && + lastChar.canAddAdditionalJongseong { + return true + } + } + + guard isBackSpace || latinCharCount < Double(viewModel.type.textLimitCount) else { return false } - guard isBackSpace == -92 || (textField.text?.count ?? 0) < viewModel.type.textLimitCount else { return false } return true } } + +private extension String { + var alphabetCharacterCount: Double { + let count = reduce(0) { count, char in + return count + (char.isAlphabetCharacter ? 0.5 : 1) + } + return count + } + + var alphabetCharacterCeilCount: Int { + let count = reduce(0) { count, char in + return count + (char.isAlphabetCharacter ? 0.5 : 1) + } + return Int(ceil(count)) + } +} + +private extension Character { + var isAlphabetCharacter: Bool { + return self.unicodeScalars.allSatisfy { $0.isASCII && $0.properties.isAlphabetic } + } + + /// ์™„์„ฑ๋˜์ง€ ์•Š์€ ํ•œ๊ธ€ ์—ฌ๋ถ€ + var isIncompleteHangul: Bool { + guard let scalar = unicodeScalars.first else { return false } + + // ํ•œ๊ธ€ ๋ฒ”์œ„์— ์žˆ๋Š”์ง€ ํ™•์ธ (์œ ๋‹ˆ์ฝ”๋“œ ๊ฐ’ ๋ฒ”์œ„ ์ฒดํฌ) + let hangulBase: UInt32 = 0xAC00 + let hangulEnd: UInt32 = 0xD7A3 + + if scalar.value >= hangulBase && scalar.value <= hangulEnd { + let syllableIndex = (scalar.value - hangulBase) + let isCompleted = syllableIndex % 28 != 0 + return !isCompleted + } + + // ์™„์„ฑ๋˜์ง€ ์•Š์€ ์ž๋ชจ๋‚˜ ์กฐํ•ฉ ์ค‘์ธ ๊ฒฝ์šฐ + return (scalar.value >= 0x1100 && scalar.value <= 0x11FF) || // ์ดˆ์„ฑ ์ž๋ชจ (ํ˜„๋Œ€ ํ•œ๊ธ€์—์„œ ์‚ฌ์šฉํ•˜๋Š” ์ดˆ์„ฑ, ์ค‘์„ฑ, ์ข…์„ฑ ๋“ฑ์˜ ์กฐํ•ฉ์šฉ ์ž๋ชจ) + (scalar.value >= 0x3130 && scalar.value <= 0x318F) || // ํ˜ธํ™˜์šฉ ์ž๋ชจ (๊ตฌ์„ฑ๋œ ํ•œ๊ธ€ ์ž๋ชจ, ์˜› ํ•œ๊ธ€ ์ž๋ชจ ๋“ฑ) + (scalar.value >= 0xA960 && scalar.value <= 0xA97F) || // ํ™•์žฅ A (์˜› ํ•œ๊ธ€ ์ž๋ชจ์˜ ์ผ๋ถ€) + (scalar.value >= 0xD7B0 && scalar.value <= 0xD7FF) // ํ™•์žฅ B (์˜› ํ•œ๊ธ€ ์ž๋ชจ์˜ ์ผ๋ถ€) + } + + /// ํ•œ๊ธ€ ์Œ์ ˆ์ด ์ข…์„ฑ์„ ๊ฐ€์กŒ์œผ๋‚˜ ์ถ”๊ฐ€์ ์ธ ์ข…์„ฑ์ด ๋” ๊ฒฐํ•ฉ๋  ์ˆ˜ ์žˆ๋Š”์ง€ ์—ฌ๋ถ€ ํ™•์ธ + var canAddAdditionalJongseong: Bool { + guard let scalar = unicodeScalars.first else { return false } + + // ํ•œ๊ธ€ ์Œ์ ˆ ์œ ๋‹ˆ์ฝ”๋“œ ๋ฒ”์œ„: U+AC00 ~ U+D7A3 + let hangulBase: UInt32 = 0xAC00 + let hangulEnd: UInt32 = 0xD7A3 + + guard scalar.value >= hangulBase && scalar.value <= hangulEnd else { + return false + } + + // ์ข…์„ฑ์— ํ•ด๋‹นํ•˜๋Š” ์ธ๋ฑ์Šค๋ฅผ ๊ณ„์‚ฐ + let syllableIndex = scalar.value - hangulBase + let jongseongIndex = Int(syllableIndex % 28) + + // ์ข…์„ฑ์ด ์žˆ์„ ๋•Œ ์ถ”๊ฐ€ ์ข…์„ฑ์„ ๊ฐ€์งˆ ์ˆ˜ ์žˆ๋Š” ๊ฒฝ์šฐ๋ฅผ ํŒ๋ณ„ + let canHaveDoubleJongseong: Bool + + switch jongseongIndex { + case 1: // ใ„ฑ (U+11A8) + canHaveDoubleJongseong = true + case 4: // ใ„ด (U+11AB) + canHaveDoubleJongseong = true + case 8: // ใ„น (U+11AF) + canHaveDoubleJongseong = true + case 17: // ใ…‚ (U+11B7) + canHaveDoubleJongseong = true + default: + canHaveDoubleJongseong = false + } + + return canHaveDoubleJongseong + } +} diff --git a/Projects/Features/BaseFeature/Sources/ViewModels/ContainSongsViewModel.swift b/Projects/Features/BaseFeature/Sources/ViewModels/ContainSongsViewModel.swift index 355a7b40a..e8f8ca33e 100644 --- a/Projects/Features/BaseFeature/Sources/ViewModels/ContainSongsViewModel.swift +++ b/Projects/Features/BaseFeature/Sources/ViewModels/ContainSongsViewModel.swift @@ -3,6 +3,7 @@ import BaseDomainInterface import ErrorModule import Foundation import Localization +import LogManager import PlaylistDomainInterface import PriceDomainInterface import RxRelay @@ -131,6 +132,13 @@ public final class ContainSongsViewModel: ViewModelType { return self.addSongIntoPlaylistUseCase .execute(key: model.key, songs: self.songs) + .do(onSuccess: { _ in + let log = ContainSongsAnalyticsLog.completeAddMusics( + playlistId: model.key, + count: self.songs.count + ) + LogManager.analytics(log) + }) .catch { (error: Error) in let wmError = error.asWMError diff --git a/Projects/Features/BaseFeature/Sources/Views/ContainPlayListHeaderView.swift b/Projects/Features/BaseFeature/Sources/Views/ContainPlaylistHeaderView.swift similarity index 69% rename from Projects/Features/BaseFeature/Sources/Views/ContainPlayListHeaderView.swift rename to Projects/Features/BaseFeature/Sources/Views/ContainPlaylistHeaderView.swift index d0c1808fa..6af835b79 100644 --- a/Projects/Features/BaseFeature/Sources/Views/ContainPlayListHeaderView.swift +++ b/Projects/Features/BaseFeature/Sources/Views/ContainPlaylistHeaderView.swift @@ -1,24 +1,18 @@ -// -// ContainPlayListHeaderView.swift -// CommonFeature -// -// Created by yongbeomkwak on 2023/03/11. -// Copyright ยฉ 2023 yongbeomkwak. All rights reserved. -// - import DesignSystem import UIKit -public protocol ContainPlayListHeaderViewDelegate: AnyObject { +public protocol ContainPlaylistHeaderViewDelegate: AnyObject { func action() } -class ContainPlayListHeaderView: UIView { +class ContainPlaylistHeaderView: UIView { @IBOutlet weak var superView: UIView! @IBOutlet weak var button: UIButton! @IBOutlet weak var buttonImageView: UIImageView! - weak var delegate: ContainPlayListHeaderViewDelegate? + @IBOutlet weak var blurEffectViews: UIVisualEffectView! + + weak var delegate: ContainPlaylistHeaderViewDelegate? @IBAction func buttonAction(_ sender: Any) { self.delegate?.action() @@ -36,7 +30,7 @@ class ContainPlayListHeaderView: UIView { } private func setupView() { - if let view = Bundle.module.loadNibNamed("ContainPlayListHeaderView", owner: self, options: nil)! + if let view = Bundle.module.loadNibNamed("ContainPlaylistHeaderView", owner: self, options: nil)! .first as? UIView { view.frame = self.bounds view.layoutIfNeeded() // ๋“œ๋กœ์šฐ ์‚ฌ์ดํด์„ ํ˜ธ์ถœํ•  ๋•Œ ์“ฐ์ž„ @@ -49,15 +43,19 @@ class ContainPlayListHeaderView: UIView { string: "์ƒˆ ๋ฆฌ์ŠคํŠธ ๋งŒ๋“ค๊ธฐ", attributes: [ .font: DesignSystemFontFamily.Pretendard.medium.font(size: 14), - .foregroundColor: DesignSystemAsset.GrayColor.gray900.color, + .foregroundColor: DesignSystemAsset.BlueGrayColor.gray900.color, .kern: -0.5 ] ) - + self.backgroundColor = .clear superView.backgroundColor = .white.withAlphaComponent(0.4) superView.layer.cornerRadius = 8 - superView.layer.borderColor = DesignSystemAsset.GrayColor.gray200.color.cgColor + superView.layer.borderColor = DesignSystemAsset.BlueGrayColor.gray200.color.withAlphaComponent(0.4).cgColor superView.layer.borderWidth = 1 + + blurEffectViews.layer.cornerRadius = 8 + blurEffectViews.clipsToBounds = true + self.button.setAttributedTitle(attr, for: .normal) } } diff --git a/Projects/Features/BaseFeature/Sources/Views/CurrentPlayListTableViewCell.swift b/Projects/Features/BaseFeature/Sources/Views/CurrentPlayListTableViewCell.swift deleted file mode 100644 index 6061d52fe..000000000 --- a/Projects/Features/BaseFeature/Sources/Views/CurrentPlayListTableViewCell.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// CurrentPlayListTableViewCell.swift -// CommonFeature -// -// Created by yongbeomkwak on 2023/03/11. -// Copyright ยฉ 2023 yongbeomkwak. All rights reserved. -// - -import DesignSystem -import UIKit -import UserDomainInterface -import Utility - -class CurrentPlayListTableViewCell: UITableViewCell { - @IBOutlet weak var playListImageView: UIImageView! - @IBOutlet weak var playListNameLabel: UILabel! - @IBOutlet weak var playListCountLabel: UILabel! - - override func awakeFromNib() { - super.awakeFromNib() - self.backgroundColor = DesignSystemAsset.GrayColor.gray100.color - self.playListImageView.layer.cornerRadius = 4 - self.playListNameLabel.font = DesignSystemFontFamily.Pretendard.medium.font(size: 14) - self.playListNameLabel.textColor = DesignSystemAsset.GrayColor.gray900.color - self.playListNameLabel.setTextWithAttributes(kernValue: -0.5) - self.playListCountLabel.font = DesignSystemFontFamily.Pretendard.light.font(size: 12) - self.playListCountLabel.textColor = DesignSystemAsset.GrayColor.gray900.color - self.playListCountLabel.setTextWithAttributes(kernValue: -0.5) - } -} - -extension CurrentPlayListTableViewCell { - func update(model: PlaylistEntity) { - self.playListImageView.kf.setImage( - with: URL(string: model.image), - placeholder: nil, - options: [.transition(.fade(0.2))] - ) - self.playListNameLabel.text = model.title - self.playListCountLabel.text = "\(model.songCount)๊ณก" - } -} diff --git a/Projects/Features/BaseFeature/Sources/Views/CurrentPlaylistTableViewCell.swift b/Projects/Features/BaseFeature/Sources/Views/CurrentPlaylistTableViewCell.swift new file mode 100644 index 000000000..ed104346f --- /dev/null +++ b/Projects/Features/BaseFeature/Sources/Views/CurrentPlaylistTableViewCell.swift @@ -0,0 +1,37 @@ +import DesignSystem +import UIKit +import UserDomainInterface +import Utility + +class CurrentPlaylistTableViewCell: UITableViewCell { + @IBOutlet weak var playlistImageView: UIImageView! + @IBOutlet weak var playlistNameLabel: UILabel! + @IBOutlet weak var playlistCountLabel: UILabel! + @IBOutlet weak var lockImageView: UIImageView! + + override func awakeFromNib() { + super.awakeFromNib() + self.backgroundColor = DesignSystemAsset.BlueGrayColor.gray100.color + self.playlistImageView.layer.cornerRadius = 4 + self.playlistNameLabel.font = DesignSystemFontFamily.Pretendard.medium.font(size: 14) + self.playlistNameLabel.textColor = DesignSystemAsset.BlueGrayColor.gray900.color + self.playlistNameLabel.setTextWithAttributes(kernValue: -0.5) + self.playlistCountLabel.font = DesignSystemFontFamily.Pretendard.light.font(size: 12) + self.playlistCountLabel.textColor = DesignSystemAsset.BlueGrayColor.gray900.color + self.playlistCountLabel.setTextWithAttributes(kernValue: -0.5) + self.lockImageView.image = DesignSystemAsset.Storage.storageClose.image + } +} + +extension CurrentPlaylistTableViewCell { + func update(model: PlaylistEntity) { + self.playlistImageView.kf.setImage( + with: URL(string: model.image), + placeholder: nil, + options: [.transition(.fade(0.2))] + ) + self.playlistNameLabel.text = model.title + self.playlistCountLabel.text = "\(model.songCount)๊ณก" + self.lockImageView.isHidden = !model.private + } +} diff --git a/Projects/Features/BaseFeature/Sources/Views/WMBottomSheetView.swift b/Projects/Features/BaseFeature/Sources/Views/WMBottomSheetView.swift index 07955d527..c7e90835c 100644 --- a/Projects/Features/BaseFeature/Sources/Views/WMBottomSheetView.swift +++ b/Projects/Features/BaseFeature/Sources/Views/WMBottomSheetView.swift @@ -68,7 +68,7 @@ public extension UIViewController { ) bottomSheetView.present(in: self.view) - NotificationCenter.default.post(name: .willShowSongCart, object: nil) + NotificationCenter.default.post(name: .shouldHidePlaylistFloatingButton, object: nil) } func hideInlineBottomSheet() { @@ -77,6 +77,6 @@ public extension UIViewController { .last as? BottomSheetView else { return } bottomSheetView.dismiss() - NotificationCenter.default.post(name: .willHideSongCart, object: nil) + NotificationCenter.default.post(name: .shouldShowPlaylistFloatingButton, object: nil) } } diff --git a/Projects/Features/BaseFeature/Testing/TextPopUpComponentStub.swift b/Projects/Features/BaseFeature/Testing/TextPopUpComponentStub.swift index 0e51ccbbd..5c4324243 100644 --- a/Projects/Features/BaseFeature/Testing/TextPopUpComponentStub.swift +++ b/Projects/Features/BaseFeature/Testing/TextPopUpComponentStub.swift @@ -2,7 +2,7 @@ import BaseFeatureInterface import UIKit -public final class TextPopUpComponentStub: TextPopUpFactory { +public final class TextPopupComponentStub: TextPopupFactory { public func makeView( text: String?, cancelButtonIsHidden: Bool, diff --git a/Projects/Features/ChartFeature/Sources/Analytics/ChartAnalyticsLog.swift b/Projects/Features/ChartFeature/Sources/Analytics/ChartAnalyticsLog.swift new file mode 100644 index 000000000..86fb0bb66 --- /dev/null +++ b/Projects/Features/ChartFeature/Sources/Analytics/ChartAnalyticsLog.swift @@ -0,0 +1,13 @@ +import LogManager + +enum ChartAnalyticsLog: AnalyticsLogType { + enum ChartType: String, CaseIterable, AnalyticsLogEnumParametable { + case hourly + case daily + case weekly + case monthly + case total + } + + case selectChartType(type: ChartType) +} diff --git a/Projects/Features/ChartFeature/Sources/Components/ChartContentComponent.swift b/Projects/Features/ChartFeature/Sources/Components/ChartContentComponent.swift index d6039df59..7e6291d00 100644 --- a/Projects/Features/ChartFeature/Sources/Components/ChartContentComponent.swift +++ b/Projects/Features/ChartFeature/Sources/Components/ChartContentComponent.swift @@ -8,7 +8,7 @@ import SignInFeatureInterface public protocol ChartContentDependency: Dependency { var fetchChartRankingUseCase: any FetchChartRankingUseCase { get } var containSongsFactory: any ContainSongsFactory { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } var signInFactory: any SignInFactory { get } var songDetailPresenter: any SongDetailPresentable { get } } @@ -21,7 +21,7 @@ public final class ChartContentComponent: Component { fetchChartRankingUseCase: dependency.fetchChartRankingUseCase ), containSongsFactory: dependency.containSongsFactory, - textPopupFactory: dependency.textPopUpFactory, + textPopupFactory: dependency.textPopupFactory, signInFactory: dependency.signInFactory, songDetailPresenter: dependency.songDetailPresenter ) diff --git a/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartContentViewController.swift b/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartContentViewController.swift index 758aad4e3..a4621acd7 100644 --- a/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartContentViewController.swift +++ b/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartContentViewController.swift @@ -26,7 +26,7 @@ public final class ChartContentViewController: BaseViewController, ViewControlle private let disposeBag = DisposeBag() private var containSongsFactory: ContainSongsFactory! - private var textPopupFactory: TextPopUpFactory! + private var textPopupFactory: TextPopupFactory! private var signInFactory: SignInFactory! private var songDetailPresenter: SongDetailPresentable! @@ -47,7 +47,7 @@ public final class ChartContentViewController: BaseViewController, ViewControlle public static func viewController( viewModel: ChartContentViewModel, containSongsFactory: ContainSongsFactory, - textPopupFactory: TextPopUpFactory, + textPopupFactory: TextPopupFactory, signInFactory: SignInFactory, songDetailPresenter: SongDetailPresentable ) -> ChartContentViewController { @@ -141,12 +141,20 @@ private extension ChartContentViewController { output.groupPlaySongs .bind(with: self, onNext: { owner, source in - guard !source.isEmpty else { + guard !source.songs.isEmpty else { owner.output.showToast.onNext("์ฐจํŠธ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") return } - PlayState.shared.loadAndAppendSongsToPlaylist(source) - WakmusicYoutubePlayer(ids: source.map { $0.id }).play() + PlayState.shared.loadAndAppendSongsToPlaylist(source.songs) + if source.songs.allSatisfy({ $0.title.isContainShortsTagTitle }) { + WakmusicYoutubePlayer( + ids: source.songs.map { $0.id }, + title: source.playlistTitle, + playPlatform: .youtube + ).play() + } else { + WakmusicYoutubePlayer(ids: source.songs.map { $0.id }, title: source.playlistTitle).play() + } }) .disposed(by: disposeBag) @@ -167,6 +175,9 @@ private extension ChartContentViewController { confirmButtonText: nil, cancelButtonText: nil, completion: { + let log = CommonAnalyticsLog.clickLoginButton(entry: .addMusics) + LogManager.analytics(log) + let loginVC = owner.signInFactory.makeView() loginVC.modalPresentationStyle = .overFullScreen owner.present(loginVC, animated: true) @@ -189,7 +200,13 @@ private extension ChartContentViewController { extension ChartContentViewController: ChartContentTableViewCellDelegate { func tappedThumbnail(id: String) { - songDetailPresenter.present(id: id) + guard let tappedSong = output.dataSource.value + .first(where: { $0.id == id }) + else { return } + PlayState.shared.append(item: .init(id: tappedSong.id, title: tappedSong.title, artist: tappedSong.artist)) + let playlistIDs = PlayState.shared.currentPlaylist + .map(\.id) + songDetailPresenter.present(ids: playlistIDs, selectedID: tappedSong.id) } } @@ -218,11 +235,17 @@ extension ChartContentViewController: PlayButtonForChartViewDelegate { } if event == .allPlay { + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .chart, type: .all) + ) let viewController = ChartPlayPopupViewController() viewController.delegate = self showBottomSheet(content: viewController, size: .fixed(192 + SAFEAREA_BOTTOM_HEIGHT())) } else { + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .chart, type: .random) + ) input.shufflePlayTapped.onNext(()) } } @@ -230,6 +253,16 @@ extension ChartContentViewController: PlayButtonForChartViewDelegate { extension ChartContentViewController: ChartPlayPopupViewControllerDelegate { public func playTapped(type: HalfPlayType) { + switch type { + case .front: // 1-50 + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .chart, type: .range1to50) + ) + case .back: // 50-100 + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .chart, type: .range50to100) + ) + } input.halfPlayTapped.onNext(type) } } @@ -244,6 +277,8 @@ extension ChartContentViewController: SongCartViewDelegate { input.allSongSelected.onNext(flag) case .addSong: + let log = CommonAnalyticsLog.clickAddMusicsButton(location: .chart) + LogManager.analytics(log) if PreferenceManager.userInfo == nil { output.showLogin.onNext(()) return @@ -262,10 +297,6 @@ extension ChartContentViewController: SongCartViewDelegate { } case .addPlayList: - guard songs.count <= limit else { - output.showToast.onNext(LocalizationStrings.overFlowAddPlaylistWarning(songs.count - limit)) - return - } PlayState.shared.appendSongsToPlaylist(songs) output.showToast.onNext(LocalizationStrings.addList) input.allSongSelected.onNext(false) @@ -277,7 +308,14 @@ extension ChartContentViewController: SongCartViewDelegate { } PlayState.shared.loadAndAppendSongsToPlaylist(songs) input.allSongSelected.onNext(false) - WakmusicYoutubePlayer(ids: songs.map { $0.id }).play() + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .chart, type: .multiple) + ) + if songs.allSatisfy({ $0.title.isContainShortsTagTitle }) { + WakmusicYoutubePlayer(ids: songs.map { $0.id }, title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง", playPlatform: .youtube).play() + } else { + WakmusicYoutubePlayer(ids: songs.map { $0.id }, title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง").play() + } case .remove: return diff --git a/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartPlayPopupViewController.swift b/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartPlayPopupViewController.swift index bd7344020..ddbf839bb 100644 --- a/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartPlayPopupViewController.swift +++ b/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartPlayPopupViewController.swift @@ -11,6 +11,15 @@ public protocol ChartPlayPopupViewControllerDelegate: AnyObject { public enum HalfPlayType { case front case back + + var playlistTitleString: String { + switch self { + case .front: + return "์™๋ฎค์ฐจํŠธ TOP100 1์œ„ ~ 50์œ„" + case .back: + return "์™๋ฎค์ฐจํŠธ TOP100 51์œ„ ~ 100์œ„" + } + } } final class ChartPlayPopupViewController: UIViewController { diff --git a/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartViewController.swift b/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartViewController.swift index d05b1b295..1c704d919 100644 --- a/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartViewController.swift +++ b/Projects/Features/ChartFeature/Sources/ViewContrillers/ChartViewController.swift @@ -1,5 +1,6 @@ import BaseFeature import DesignSystem +import LogManager import Pageboy import Tabman import UIKit @@ -22,13 +23,37 @@ public final class ChartViewController: TabmanViewController, ViewControllerFrom return viewControllers }() - deinit { DEBUG_LOG("โŒ \(Self.self) Deinit") } + deinit { LogManager.printDebug("โŒ \(Self.self) Deinit") } override public func viewDidLoad() { super.viewDidLoad() configureUI() } + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + let log = CommonAnalyticsLog.viewPage(pageName: .chart) + LogManager.analytics(log) + } + + override public func pageboyViewController( + _ pageboyViewController: PageboyViewController, + didScrollToPageAt index: TabmanViewController.PageIndex, + direction: PageboyViewController.NavigationDirection, + animated: Bool + ) { + let chartType = ChartAnalyticsLog.ChartType.allCases[safe: index] ?? .hourly + let log = ChartAnalyticsLog.selectChartType(type: chartType) + LogManager.analytics(log) + + super.pageboyViewController( + pageboyViewController, + didScrollToPageAt: index, + direction: direction, + animated: animated + ) + } + public static func viewController(chartContentComponent: ChartContentComponent) -> ChartViewController { let viewController = ChartViewController.viewController( storyBoardName: "Chart", @@ -61,6 +86,7 @@ private extension ChartViewController { bar.layout.contentInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) bar.layout.contentMode = .fit bar.layout.transitionStyle = .progressive + bar.layout.interButtonSpacing = 0 // ๋ฒ„ํŠผ ๊ธ€์”จ ์ปค์Šคํ…€ bar.buttons.customize { button in diff --git a/Projects/Features/ChartFeature/Sources/ViewModels/ChartContentViewModel.swift b/Projects/Features/ChartFeature/Sources/ViewModels/ChartContentViewModel.swift index 892a22de4..4bba3ea9f 100644 --- a/Projects/Features/ChartFeature/Sources/ViewModels/ChartContentViewModel.swift +++ b/Projects/Features/ChartFeature/Sources/ViewModels/ChartContentViewModel.swift @@ -32,7 +32,7 @@ public final class ChartContentViewModel: ViewModelType { let updateTime: BehaviorRelay = BehaviorRelay(value: "") let indexOfSelectedSongs: BehaviorRelay<[Int]> = BehaviorRelay(value: []) let songEntityOfSelectedSongs: BehaviorRelay<[SongEntity]> = BehaviorRelay(value: []) - let groupPlaySongs: PublishSubject<[SongEntity]> = PublishSubject() + let groupPlaySongs: PublishSubject<(playlistTitle: String, songs: [SongEntity])> = PublishSubject() let showToast: PublishSubject = PublishSubject() let showLogin: PublishSubject = .init() } @@ -123,13 +123,13 @@ public final class ChartContentViewModel: ViewModelType { input.halfPlayTapped .withLatestFrom(output.dataSource) { ($0, $1) } - .map { [weak self] type, source -> [ChartRankingEntity] in - self?.halfSplitArray(array: source, type: type) ?? [] + .bind(with: self) { owner, tuple in + let (type, songs) = tuple + let chartsArray = owner.halfSplitArray(array: songs, type: type) + let songsArray = owner.toSongEntities(array: chartsArray) + let title = type.playlistTitleString + output.groupPlaySongs.onNext((title, songsArray)) } - .map { [weak self] entities -> [SongEntity] in - self?.toSongEntities(array: entities) ?? [] - } - .bind(to: output.groupPlaySongs) .disposed(by: disposeBag) input.shufflePlayTapped @@ -140,6 +140,7 @@ public final class ChartContentViewModel: ViewModelType { .map { [weak self] entities -> [SongEntity] in self?.toSongEntities(array: entities) ?? [] } + .map { ("์™๋ฎค์ฐจํŠธ TOP100 ๋žœ๋ค", $0) } .bind(to: output.groupPlaySongs) .disposed(by: disposeBag) diff --git a/Projects/Features/CreditSongListFeature/Demo/Sources/AppDelegate.swift b/Projects/Features/CreditSongListFeature/Demo/Sources/AppDelegate.swift index 2fe642032..6c7849559 100644 --- a/Projects/Features/CreditSongListFeature/Demo/Sources/AppDelegate.swift +++ b/Projects/Features/CreditSongListFeature/Demo/Sources/AppDelegate.swift @@ -1,8 +1,10 @@ +import BaseFeature import BaseFeatureInterface import CreditDomainTesting @testable import CreditSongListFeature import CreditSongListFeatureInterface import Inject +import RxSwift import SignInFeatureInterface import UIKit @@ -16,7 +18,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { ) -> Bool { window = UIWindow(frame: UIScreen.main.bounds) let creditSongListTabFactory = FakeCreditSongListTabFactory() - let reactor = CreditSongListReactor(workerName: "CLTH") + let reactor = CreditSongListReactor( + workerName: "CLTH", + fetchCreditProfileUseCase: FetchCreditProfileUseCaseSpy() + ) let viewController = CreditSongListViewController( reactor: reactor, creditSongListTabFactory: creditSongListTabFactory @@ -62,6 +67,7 @@ final class FakeCreditSongListTabItemFactory: CreditSongListTabItemFactory { let reactor = CreditSongListTabItemReactor( workerName: workerName, creditSortType: sortType, + songDetailPresenter: DummySongDetailPresenter(), fetchCreditSongListUseCase: fetchCreditSongListUseCase ) return Inject.ViewControllerHost( @@ -75,6 +81,16 @@ final class FakeCreditSongListTabItemFactory: CreditSongListTabItemFactory { } } +final class DummySongDetailPresenter: SongDetailPresentable { + var presentSongDetailObservable: RxSwift.Observable<(ids: [String], selectedID: String)> { + .empty() + } + + func present(id: String) {} + + func present(ids: [String], selectedID: String) {} +} + final class DummyContainSongsFactory: ContainSongsFactory { func makeView(songs: [String]) -> UIViewController { let viewController = UIViewController() @@ -91,7 +107,7 @@ final class DummySignInFactory: SignInFactory { } } -final class DummyTextPopupFactory: TextPopUpFactory { +final class DummyTextPopupFactory: TextPopupFactory { func makeView( text: String?, cancelButtonIsHidden: Bool, diff --git a/Projects/Features/CreditSongListFeature/Sources/CreditSongList/Component/CreditSongListComponent.swift b/Projects/Features/CreditSongListFeature/Sources/CreditSongList/Component/CreditSongListComponent.swift index f941afffe..00c6cec8e 100644 --- a/Projects/Features/CreditSongListFeature/Sources/CreditSongList/Component/CreditSongListComponent.swift +++ b/Projects/Features/CreditSongListFeature/Sources/CreditSongList/Component/CreditSongListComponent.swift @@ -1,14 +1,19 @@ +import CreditDomainInterface import CreditSongListFeatureInterface import NeedleFoundation import UIKit public protocol CreditSongListDependency: Dependency { var creditSongListTabFactory: any CreditSongListTabFactory { get } + var fetchCreditProfileUseCase: any FetchCreditProfileUseCase { get } } public final class CreditSongListComponent: Component, CreditSongListFactory { public func makeViewController(workerName: String) -> UIViewController { - let reactor = CreditSongListReactor(workerName: workerName) + let reactor = CreditSongListReactor( + workerName: workerName, + fetchCreditProfileUseCase: dependency.fetchCreditProfileUseCase + ) let viewController = CreditSongListViewController( reactor: reactor, creditSongListTabFactory: dependency.creditSongListTabFactory diff --git a/Projects/Features/CreditSongListFeature/Sources/CreditSongList/CreditSongListReactor.swift b/Projects/Features/CreditSongListFeature/Sources/CreditSongList/CreditSongListReactor.swift index 23eb7f0da..43ac6d899 100644 --- a/Projects/Features/CreditSongListFeature/Sources/CreditSongList/CreditSongListReactor.swift +++ b/Projects/Features/CreditSongListFeature/Sources/CreditSongList/CreditSongListReactor.swift @@ -1,27 +1,60 @@ +import CreditDomainInterface import ReactorKit final class CreditSongListReactor: Reactor { - enum Action {} - enum Mutation {} + enum Action { + case viewDidLoad + } + + enum Mutation { + case updateProfile(CreditProfileEntity) + } + struct State { var workerName: String + var profile: CreditProfileEntity } let initialState: State internal let workerName: String + private let fetchCreditProfileUseCase: FetchCreditProfileUseCase - init(workerName: String) { + init( + workerName: String, + fetchCreditProfileUseCase: FetchCreditProfileUseCase + ) { self.initialState = .init( - workerName: workerName + workerName: workerName, + profile: .init(name: "", imageURL: nil) ) self.workerName = workerName + self.fetchCreditProfileUseCase = fetchCreditProfileUseCase } func mutate(action: Action) -> Observable { - .empty() + switch action { + case .viewDidLoad: + return viewDidLoad() + } } func reduce(state: State, mutation: Mutation) -> State { - state + var newState = state + + switch mutation { + case let .updateProfile(data): + newState.profile = data + } + + return newState + } +} + +private extension CreditSongListReactor { + func viewDidLoad() -> Observable { + fetchCreditProfileUseCase + .execute(name: workerName) + .map { Mutation.updateProfile($0) } + .asObservable() } } diff --git a/Projects/Features/CreditSongListFeature/Sources/CreditSongList/CreditSongListViewController.swift b/Projects/Features/CreditSongListFeature/Sources/CreditSongList/CreditSongListViewController.swift index 7ed26e757..3b6b0f303 100644 --- a/Projects/Features/CreditSongListFeature/Sources/CreditSongList/CreditSongListViewController.swift +++ b/Projects/Features/CreditSongListFeature/Sources/CreditSongList/CreditSongListViewController.swift @@ -1,6 +1,7 @@ import BaseFeature import CreditSongListFeatureInterface import DesignSystem +import LogManager import RxSwift import SnapKit import UIKit @@ -37,6 +38,12 @@ final class CreditSongListViewController: BaseReactorViewController, @@ -18,12 +20,13 @@ public final class CreditSongListTabItemComponent: Component Void) case signIn + case dismiss(completion: () -> Void) } struct State { @@ -50,19 +53,23 @@ final class CreditSongListTabItemReactor: Reactor { private let signInIsRequiredSubject = PublishSubject() private var page: Int = 1 + private var isLastPage: Bool = false let initialState: State - private let workerName: String + let workerName: String private let creditSortType: CreditSongSortType + private let songDetailPresenter: any SongDetailPresentable private let fetchCreditSongListUseCase: any FetchCreditSongListUseCase init( workerName: String, creditSortType: CreditSongSortType, + songDetailPresenter: any SongDetailPresentable, fetchCreditSongListUseCase: any FetchCreditSongListUseCase ) { self.initialState = .init() self.workerName = workerName self.creditSortType = creditSortType + self.songDetailPresenter = songDetailPresenter self.fetchCreditSongListUseCase = fetchCreditSongListUseCase } @@ -72,6 +79,13 @@ final class CreditSongListTabItemReactor: Reactor { return viewDidLoad() case let .songDidTap(id): return songDidTap(id: id) + case let .songThumbnailDidTap(model): + PlayState.shared.append(item: .init(id: model.id, title: model.title, artist: model.artist)) + return navigateMutation(navigateType: .dismiss(completion: { [songDetailPresenter] in + let playlistIDs = PlayState.shared.currentPlaylist + .map(\.id) + songDetailPresenter.present(ids: playlistIDs, selectedID: model.id) + })) case .randomPlayButtonDidTap: return randomPlayButtonDidTap() case .allSelectButtonDidTap: @@ -124,6 +138,11 @@ final class CreditSongListTabItemReactor: Reactor { private extension CreditSongListTabItemReactor { func viewDidLoad() -> Observable { let initialCreditSongListObservable = fetchPaginatedCreditSongList() + .do(onNext: { [weak self] creditModels in + if creditModels.isEmpty || creditModels.count < Metric.pageLimit { + self?.isLastPage = true + } + }) .map(Mutation.updateSongs) return withLoadingMutation(observable: initialCreditSongListObservable) } @@ -143,7 +162,12 @@ private extension CreditSongListTabItemReactor { .shuffled() .prefix(50) let songs = Array(randomSongs) - return navigateMutation(navigateType: .playYoutube(ids: songs)) + let playPlatform = if currentState.songs.allSatisfy({ $0.title.isContainShortsTagTitle }) { + WakmusicYoutubePlayer.PlayPlatform.youtube + } else { + WakmusicYoutubePlayer.PlayPlatform.automatic + } + return navigateMutation(navigateType: .playYoutube(ids: songs, playPlatform: playPlatform)) } func allSelectButtonDidTap() -> Observable { @@ -161,6 +185,9 @@ private extension CreditSongListTabItemReactor { navigateType: .textPopup( text: LocalizationStrings.needLoginWarning, completion: { [signInIsRequiredSubject] in + let log = CommonAnalyticsLog.clickLoginButton(entry: .addMusics) + LogManager.analytics(log) + signInIsRequiredSubject.onNext(()) } ) @@ -186,13 +213,6 @@ private extension CreditSongListTabItemReactor { .filter { currentState.selectedSongs.contains($0.id) } .map { PlaylistItem(id: $0.id, title: $0.title, artist: $0.artist) } - if appendingSongs.count > Metric.availableLimit { - return .just(.updateToastMessage( - Localization.LocalizationStrings - .overFlowAddPlaylistWarning(appendingSongs.count - Metric.availableLimit) - )) - } - PlayState.shared.append(contentsOf: appendingSongs) return .just(.updateToastMessage(Localization.LocalizationStrings.addList)) } @@ -207,11 +227,26 @@ private extension CreditSongListTabItemReactor { )) } - return navigateMutation(navigateType: .playYoutube(ids: containTargetSongIDs)) + let isOnlyShorts = currentState.songs + .filter { currentState.selectedSongs.contains($0.id) } + .allSatisfy { $0.title.isContainShortsTagTitle } + let playPlatform = if isOnlyShorts { + WakmusicYoutubePlayer.PlayPlatform.youtube + } else { + WakmusicYoutubePlayer.PlayPlatform.automatic + } + + return navigateMutation(navigateType: .playYoutube(ids: containTargetSongIDs, playPlatform: playPlatform)) } func reachedBottom() -> Observable { + guard !isLastPage else { return .empty() } let initialCreditSongListObservable = fetchPaginatedCreditSongList() + .do(onNext: { [weak self] creditModels in + if creditModels.isEmpty || creditModels.count < Metric.pageLimit { + self?.isLastPage = true + } + }) .map { [weak self] in return $0 + (self?.currentState.songs ?? []) } .map(Mutation.updateSongs) return withLoadingMutation(observable: initialCreditSongListObservable) diff --git a/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/CreditSongListTabItemViewController.swift b/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/CreditSongListTabItemViewController.swift index 8fef34910..37acd281f 100644 --- a/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/CreditSongListTabItemViewController.swift +++ b/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/CreditSongListTabItemViewController.swift @@ -2,6 +2,7 @@ import BaseFeature import BaseFeatureInterface import CreditSongListFeatureInterface import DesignSystem +import LogManager import RxSwift import SignInFeatureInterface import Then @@ -39,6 +40,10 @@ final class CreditSongListTabItemViewController: > { [reactor] cell, _, model in let isSelected = reactor?.currentState.selectedSongs.contains(model.id) ?? false cell.update(model, isSelected: isSelected) + + cell.setThumbnailTapHandler { [reactor, model] in + reactor?.action.onNext(.songThumbnailDidTap(model: model)) + } } private lazy var creditSongHeaderRegistration = UICollectionView @@ -46,18 +51,21 @@ final class CreditSongListTabItemViewController: elementKind: UICollectionView.elementKindSectionHeader ) { [reactor] headerView, _, _ in headerView.setPlayButtonHandler { + let log = CommonAnalyticsLog.clickPlayButton(location: .creditSongList, type: .random) + LogManager.analytics(log) + reactor?.action.onNext(.randomPlayButtonDidTap) } } private let containSongsFactory: any ContainSongsFactory - private let textPopupFactory: any TextPopUpFactory + private let textPopupFactory: any TextPopupFactory private let signInFactory: any SignInFactory init( reactor: Reactor, containSongsFactory: any ContainSongsFactory, - textPopupFactory: any TextPopUpFactory, + textPopupFactory: any TextPopupFactory, signInFactory: any SignInFactory ) { self.containSongsFactory = containSongsFactory @@ -147,14 +155,16 @@ final class CreditSongListTabItemViewController: .compactMap { $0 } .bind(with: self) { owner, navigate in switch navigate { - case let .playYoutube(ids): - owner.playYoutube(ids: ids) + case let .playYoutube(ids, playPlatform): + owner.playYoutube(ids: ids, playPlatform: playPlatform) case let .containSongs(ids): owner.presentContainSongs(ids: ids) case let .textPopup(text, completion): owner.presentTextPopup(text: text, completion: completion) case .signIn: owner.presentSignIn() + case let .dismiss(completion): + owner.dismiss(completion: completion) } } .disposed(by: disposeBag) @@ -277,8 +287,10 @@ extension CreditSongListTabItemViewController { return dataSource } - private func playYoutube(ids: [String]) { - WakmusicYoutubePlayer(ids: ids).play() + private func playYoutube(ids: [String], playPlatform: WakmusicYoutubePlayer.PlayPlatform) { + let worker = reactor?.workerName ?? "์ž‘์—…์ž" + let title = "\(worker)๋‹˜๊ณผ ํ•จ๊ป˜ํ•˜๋Š” ๋žœ๋ฎค" + WakmusicYoutubePlayer(ids: ids, title: title, playPlatform: playPlatform).play() } private func presentContainSongs(ids: [String]) { @@ -305,6 +317,10 @@ extension CreditSongListTabItemViewController { viewController.modalPresentationStyle = .overFullScreen UIApplication.topVisibleViewController()?.present(viewController, animated: true) } + + private func dismiss(completion: @escaping () -> Void) { + UIApplication.keyRootViewController?.dismiss(animated: true, completion: completion) + } } private extension CGFloat { diff --git a/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/CreditSongListTabViewController.swift b/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/CreditSongListTabViewController.swift index df822de88..f408b43fa 100644 --- a/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/CreditSongListTabViewController.swift +++ b/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/CreditSongListTabViewController.swift @@ -74,6 +74,7 @@ private extension CreditSongListTabViewController { bar.layout.contentInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) bar.layout.contentMode = .fit bar.layout.transitionStyle = .progressive + bar.layout.interButtonSpacing = 0 bar.buttons.customize { button in button.tintColor = DesignSystemAsset.BlueGrayColor.blueGray400.color @@ -82,11 +83,12 @@ private extension CreditSongListTabViewController { button.selectedFont = DesignSystemFontFamily.Pretendard.bold.font(size: 16) } - bar.indicator.weight = .custom(value: 3) + bar.indicator.weight = .custom(value: 2) bar.indicator.tintColor = DesignSystemAsset.PrimaryColorV2.point.color bar.indicator.overscrollBehavior = .compress addBar(bar, dataSource: self, at: .custom(view: tabContainerView, layout: nil)) + bar.layer.zPosition = 1 } } diff --git a/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/View/CreditSongCollectionViewCell.swift b/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/View/CreditSongCollectionViewCell.swift index 6fd81b0c6..d3f08f864 100644 --- a/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/View/CreditSongCollectionViewCell.swift +++ b/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/View/CreditSongCollectionViewCell.swift @@ -1,6 +1,8 @@ import DesignSystem import Kingfisher import Lottie +import RxGesture +import RxSwift import SnapKit import SongsDomainInterface import Then @@ -45,10 +47,14 @@ final class CreditSongCollectionViewCell: UICollectionViewCell { $0.lineBreakMode = .byTruncatingTail } + private let disposeBag = DisposeBag() + private var handler: (() -> Void)? + override init(frame: CGRect) { super.init(frame: frame) addViews() setLayout() + bind() } @available(*, unavailable) @@ -56,6 +62,10 @@ final class CreditSongCollectionViewCell: UICollectionViewCell { fatalError("init(coder:) has not been implemented") } + func setThumbnailTapHandler(_ handler: @escaping () -> Void) { + self.handler = handler + } + func update(_ model: CreditSongModel, isSelected: Bool) { self.thumbnailImageView.kf.setImage( with: URL(string: WMImageAPI.fetchYoutubeThumbnail(id: model.id).toString), @@ -99,4 +109,13 @@ private extension CreditSongCollectionViewCell { $0.trailing.equalToSuperview().inset(20) } } + + func bind() { + thumbnailImageView.rx.tapGesture() + .when(.recognized) + .bind { [weak self] _ in + self?.handler?() + } + .disposed(by: disposeBag) + } } diff --git a/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/View/RandomPlayButton.swift b/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/View/RandomPlayButton.swift index e52944b5c..8fe971979 100644 --- a/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/View/RandomPlayButton.swift +++ b/Projects/Features/CreditSongListFeature/Sources/CreditSongListTab/View/RandomPlayButton.swift @@ -42,7 +42,7 @@ final class RandomPlayButton: UIButton { randomImageView.snp.makeConstraints { $0.centerY.equalToSuperview() $0.leading.equalToSuperview().inset(32) - $0.size.equalTo(24) + $0.size.equalTo(32) } playLabel.snp.makeConstraints { diff --git a/Projects/Features/FruitDrawFeature/Sources/Components/FruitDrawComponent.swift b/Projects/Features/FruitDrawFeature/Sources/Components/FruitDrawComponent.swift index e3f5e52e7..46fc83afc 100644 --- a/Projects/Features/FruitDrawFeature/Sources/Components/FruitDrawComponent.swift +++ b/Projects/Features/FruitDrawFeature/Sources/Components/FruitDrawComponent.swift @@ -9,7 +9,7 @@ import UserDomainInterface public protocol FruitDrawDependency: Dependency { var fetchFruitDrawStatusUseCase: any FetchFruitDrawStatusUseCase { get } var drawFruitUseCase: any DrawFruitUseCase { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } } public final class FruitDrawComponent: Component, FruitDrawFactory { @@ -19,7 +19,7 @@ public final class FruitDrawComponent: Component, FruitDraw fetchFruitDrawStatusUseCase: dependency.fetchFruitDrawStatusUseCase, drawFruitUseCase: dependency.drawFruitUseCase ), - textPopUpFactory: dependency.textPopUpFactory, + textPopupFactory: dependency.textPopupFactory, delegate: delegate ) } diff --git a/Projects/Features/FruitDrawFeature/Sources/Components/FruitStorageComponent.swift b/Projects/Features/FruitDrawFeature/Sources/Components/FruitStorageComponent.swift index 9167a0fa9..48233b20f 100644 --- a/Projects/Features/FruitDrawFeature/Sources/Components/FruitStorageComponent.swift +++ b/Projects/Features/FruitDrawFeature/Sources/Components/FruitStorageComponent.swift @@ -7,7 +7,7 @@ import UserDomainInterface public protocol FruitStorageDependency: Dependency { var fetchFruitListUseCase: any FetchFruitListUseCase { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } } public final class FruitStorageComponent: Component, FruitStorageFactory { @@ -16,7 +16,7 @@ public final class FruitStorageComponent: Component, Fru viewModel: .init( fetchFruitListUseCase: dependency.fetchFruitListUseCase ), - textPopUpFactory: dependency.textPopUpFactory + textPopupFactory: dependency.textPopupFactory ) } } diff --git a/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitDrawViewController.swift b/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitDrawViewController.swift index 74e6905fd..4f305f53f 100644 --- a/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitDrawViewController.swift +++ b/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitDrawViewController.swift @@ -3,6 +3,7 @@ import DesignSystem import FruitDrawFeatureInterface import LogManager import Lottie +import NVActivityIndicatorView import RxCocoa import RxSwift import SnapKit @@ -42,7 +43,6 @@ public final class FruitDrawViewController: UIViewController { } private let drawOrConfirmButton = UIButton(type: .system).then { - $0.setTitle("...", for: .normal) $0.setTitleColor(DesignSystemAsset.BlueGrayColor.blueGray25.color, for: .normal) $0.titleLabel?.font = DesignSystemFontFamily.Pretendard.medium.font(size: 18) $0.titleLabel?.setTextWithAttributes(alignment: .center) @@ -52,6 +52,11 @@ public final class FruitDrawViewController: UIViewController { $0.isEnabled = false } + private lazy var activityIndicator = NVActivityIndicatorView(frame: .zero).then { + $0.color = .white + $0.type = .circleStrokeSpin + } + private lazy var lottieAnimationView = LottieAnimationView( name: "Fruit_Draw", @@ -144,7 +149,7 @@ public final class FruitDrawViewController: UIViewController { } private let viewModel: FruitDrawViewModel - private let textPopUpFactory: TextPopUpFactory + private let textPopupFactory: TextPopupFactory private weak var delegate: FruitDrawViewControllerDelegate? lazy var input = FruitDrawViewModel.Input() @@ -157,11 +162,11 @@ public final class FruitDrawViewController: UIViewController { public init( viewModel: FruitDrawViewModel, - textPopUpFactory: TextPopUpFactory, + textPopupFactory: TextPopupFactory, delegate: FruitDrawViewControllerDelegate ) { self.viewModel = viewModel - self.textPopUpFactory = textPopUpFactory + self.textPopupFactory = textPopupFactory self.delegate = delegate super.init(nibName: nil, bundle: nil) } @@ -200,6 +205,7 @@ private extension FruitDrawViewController { owner.drawOrConfirmButton.backgroundColor = canDraw ? DesignSystemAsset.PrimaryColorV2.point.color : DesignSystemAsset.BlueGrayColor.gray300.color + owner.activityIndicator.stopAnimating() } .disposed(by: disposeBag) @@ -231,7 +237,7 @@ private extension FruitDrawViewController { output.occurredError .bind(with: self) { owner, message in owner.showBottomSheet( - content: owner.textPopUpFactory.makeView( + content: owner.textPopupFactory.makeView( text: message, cancelButtonIsHidden: true, confirmButtonText: "ํ™•์ธ", @@ -345,7 +351,8 @@ private extension FruitDrawViewController { descriptioniLabel, rewardFruitImageView, rewardDescriptioniLabel, - drawOrConfirmButton + drawOrConfirmButton, + activityIndicator ) navigationBarView.setLeftViews([dismissButton]) } @@ -384,6 +391,11 @@ private extension FruitDrawViewController { $0.height.equalTo(56) } + activityIndicator.snp.makeConstraints { + $0.center.equalTo(drawOrConfirmButton.snp.center) + $0.size.equalTo(20) + } + rewardFruitImageView.snp.makeConstraints { $0.top.equalTo(276.0.correctTop) $0.centerX.equalToSuperview() @@ -469,6 +481,13 @@ private extension FruitDrawViewController { func configureUI() { navigationController?.setNavigationBarHidden(true, animated: false) perform(#selector(startComponentAnimation), with: nil, afterDelay: 0.3) + rewardFruitImageView.addShadow( + offset: CGSize(width: 0, height: 2.5), + color: UIColor.black, + opacity: 0.1, + radius: 50 + ) + activityIndicator.startAnimating() } } diff --git a/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitInfoPopupViewController.swift b/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitInfoPopupViewController.swift index a8c168151..77aa05bcf 100644 --- a/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitInfoPopupViewController.swift +++ b/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitInfoPopupViewController.swift @@ -6,6 +6,7 @@ import UserDomainInterface import Utility public final class FruitInfoPopupViewController: UIViewController { + private let aroundView = UIView() private let popupContentView = UIView() private let backgroundImageView = UIImageView().then { @@ -60,7 +61,7 @@ public final class FruitInfoPopupViewController: UIViewController { private extension FruitInfoPopupViewController { func addSubViews() { - view.addSubview(popupContentView) + view.addSubviews(aroundView, popupContentView) popupContentView.addSubviews( backgroundImageView, descriptionLabel, @@ -72,6 +73,10 @@ private extension FruitInfoPopupViewController { func setLayout() { let is320 = APP_WIDTH() < 375 + aroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + popupContentView.snp.makeConstraints { $0.width.equalTo(is320 ? APP_WIDTH() - 40 : 335) $0.center.equalToSuperview() @@ -122,6 +127,21 @@ private extension FruitInfoPopupViewController { options: [.transition(.fade(0.2))] ) } + + noteImageView.addShadow( + offset: CGSize(width: 0, height: 2.5), + color: UIColor.black, + opacity: 0.1, + radius: 50 + ) + + let gesture = UITapGestureRecognizer(target: self, action: #selector(tappedAround(_:))) + aroundView.addGestureRecognizer(gesture) + aroundView.isUserInteractionEnabled = true + } + + @objc func tappedAround(_ sender: UITapGestureRecognizer) { + dismiss(animated: true) } func addAction() { diff --git a/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitStorageViewController.swift b/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitStorageViewController.swift index d6486bb93..8507faabe 100644 --- a/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitStorageViewController.swift +++ b/Projects/Features/FruitDrawFeature/Sources/ViewControllers/FruitStorageViewController.swift @@ -45,7 +45,7 @@ public final class FruitStorageViewController: UIViewController { } private let viewModel: FruitStorageViewModel - private let textPopUpFactory: TextPopUpFactory + private let textPopupFactory: TextPopupFactory lazy var input = FruitStorageViewModel.Input() lazy var output = viewModel.transform(from: input) @@ -57,10 +57,10 @@ public final class FruitStorageViewController: UIViewController { public init( viewModel: FruitStorageViewModel, - textPopUpFactory: TextPopUpFactory + textPopupFactory: TextPopupFactory ) { self.viewModel = viewModel - self.textPopUpFactory = textPopUpFactory + self.textPopupFactory = textPopupFactory super.init(nibName: nil, bundle: nil) } diff --git a/Projects/Features/FruitDrawFeature/Sources/Views/FruitListCell.swift b/Projects/Features/FruitDrawFeature/Sources/Views/FruitListCell.swift index 025caca1d..15bb5cbbb 100644 --- a/Projects/Features/FruitDrawFeature/Sources/Views/FruitListCell.swift +++ b/Projects/Features/FruitDrawFeature/Sources/Views/FruitListCell.swift @@ -18,18 +18,37 @@ public final class FruitListCell: UICollectionViewCell { $0.distribution = .fillEqually } + private let firstNoteContentView = UIView() + private let secondNoteContentView = UIView() + private let thirdNoteContentView = UIView() + private let firstNoteImageView = UIImageView().then { $0.contentMode = .scaleAspectFit } + private let firstNoteShadowImageView = UIImageView().then { + $0.contentMode = .scaleAspectFit + $0.image = DesignSystemAsset.FruitDraw.noteShadow.image + } + private let secondNoteImageView = UIImageView().then { $0.contentMode = .scaleAspectFit } + private let secondNoteShadowImageView = UIImageView().then { + $0.contentMode = .scaleAspectFit + $0.image = DesignSystemAsset.FruitDraw.noteShadow.image + } + private let thirdNoteImageView = UIImageView().then { $0.contentMode = .scaleAspectFit } + private let thirdNoteShadowImageView = UIImageView().then { + $0.contentMode = .scaleAspectFit + $0.image = DesignSystemAsset.FruitDraw.noteShadow.image + } + private var items: [FruitEntity] = [] weak var delegate: FruitListCellDelegate? @@ -54,10 +73,14 @@ extension FruitListCell { ) { self.items = model let notes = [firstNoteImageView, secondNoteImageView, thirdNoteImageView] + let shadows = [firstNoteShadowImageView, secondNoteShadowImageView, thirdNoteShadowImageView] notes.forEach { $0.alpha = 0 } + shadows.forEach { $0.alpha = 0 } for i in 0 ..< model.count { notes[i].alpha = 1 + shadows[i].alpha = 1 + if model[i].quantity == -1 { notes[i].image = totalCount > 15 ? DesignSystemAsset.FruitDraw.unidentifiedNote.image : @@ -76,19 +99,19 @@ extension FruitListCell { private extension FruitListCell { func addTapGestureRecognizers() { [firstNoteImageView, secondNoteImageView, thirdNoteImageView].forEach { - let gesture = UITapGestureRecognizer(target: self, action: #selector(imageViewTapped(_:))) + let gesture = UITapGestureRecognizer(target: self, action: #selector(tappedNoteImageView(_:))) $0.addGestureRecognizer(gesture) $0.isUserInteractionEnabled = true } } - @objc private func imageViewTapped(_ sender: UITapGestureRecognizer) { - if let tappedImageView = sender.view { - if tappedImageView == firstNoteImageView { + @objc private func tappedNoteImageView(_ sender: UITapGestureRecognizer) { + if let imageView = sender.view { + if imageView == firstNoteImageView { delegate?.itemSelected(item: items[0]) - } else if tappedImageView == secondNoteImageView { + } else if imageView == secondNoteImageView { delegate?.itemSelected(item: items[1]) - } else if tappedImageView == thirdNoteImageView { + } else if imageView == thirdNoteImageView { delegate?.itemSelected(item: items[2]) } } @@ -115,9 +138,14 @@ private extension FruitListCell { private extension FruitListCell { func addSubViews() { contentView.addSubviews(supportImageView, noteStackView) - noteStackView.addArrangedSubview(firstNoteImageView) - noteStackView.addArrangedSubview(secondNoteImageView) - noteStackView.addArrangedSubview(thirdNoteImageView) + + firstNoteContentView.addSubviews(firstNoteShadowImageView, firstNoteImageView) + secondNoteContentView.addSubviews(secondNoteShadowImageView, secondNoteImageView) + thirdNoteContentView.addSubviews(thirdNoteShadowImageView, thirdNoteImageView) + + noteStackView.addArrangedSubview(firstNoteContentView) + noteStackView.addArrangedSubview(secondNoteContentView) + noteStackView.addArrangedSubview(thirdNoteContentView) } func setLayout() { @@ -128,10 +156,23 @@ private extension FruitListCell { $0.height.equalTo(26) } + [firstNoteShadowImageView, secondNoteShadowImageView, thirdNoteShadowImageView].forEach { + $0.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.bottom.equalTo(supportImageView.snp.bottom).offset(-11) + } + } + + [firstNoteImageView, secondNoteImageView, thirdNoteImageView].forEach { + $0.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + noteStackView.snp.makeConstraints { $0.horizontalEdges.equalToSuperview().inset(20) $0.top.equalToSuperview() - $0.bottom.equalToSuperview().offset(-12) + $0.bottom.equalToSuperview().offset(-15) } } } diff --git a/Projects/Features/HomeFeature/Sources/Analytics/HomeAnalyticsLog.swift b/Projects/Features/HomeFeature/Sources/Analytics/HomeAnalyticsLog.swift index a89c6cc9f..7a84115d9 100644 --- a/Projects/Features/HomeFeature/Sources/Analytics/HomeAnalyticsLog.swift +++ b/Projects/Features/HomeFeature/Sources/Analytics/HomeAnalyticsLog.swift @@ -6,12 +6,11 @@ enum HomeAnalyticsLog: AnalyticsLogType { case clickRecentMusicsTitleButton case clickAllRecentMusicsButton case clickMusicItem(location: MusicItemLocation, id: String) - case clickMusicItemPlayButton(location: MusicItemLocation, id: String) } -enum MusicItemLocation: String, CustomStringConvertible { - case homeTop100 = "home-top100" - case homeRecent = "home-recent" +enum MusicItemLocation: String, AnalyticsLogEnumParametable { + case homeTop100 = "home_top100" + case homeRecent = "home_recent" var description: String { self.rawValue diff --git a/Projects/Features/HomeFeature/Sources/Analytics/NewSongsAnalyticsLog.swift b/Projects/Features/HomeFeature/Sources/Analytics/NewSongsAnalyticsLog.swift new file mode 100644 index 000000000..fd2737028 --- /dev/null +++ b/Projects/Features/HomeFeature/Sources/Analytics/NewSongsAnalyticsLog.swift @@ -0,0 +1,13 @@ +import LogManager + +enum NewSongsAnalyticsLog: AnalyticsLogType { + enum RecentMusicType: String, CaseIterable, AnalyticsLogEnumParametable { + case all + case woowakgood + case isegyeIdol = "isegye_idol" + case gomem + case academy + } + + case selectRecentMusicType(type: RecentMusicType) +} diff --git a/Projects/Features/HomeFeature/Sources/Components/NewSongsContentComponent.swift b/Projects/Features/HomeFeature/Sources/Components/NewSongsContentComponent.swift index 2cb80621b..530926b4c 100644 --- a/Projects/Features/HomeFeature/Sources/Components/NewSongsContentComponent.swift +++ b/Projects/Features/HomeFeature/Sources/Components/NewSongsContentComponent.swift @@ -11,7 +11,7 @@ public protocol NewSongsContentDependency: Dependency { var fetchNewSongsPlaylistUseCase: any FetchNewSongsPlaylistUseCase { get } var containSongsFactory: any ContainSongsFactory { get } var signInFactory: any SignInFactory { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } var songDetailPresenter: any SongDetailPresentable { get } } @@ -24,7 +24,7 @@ public final class NewSongsContentComponent: Component 1 { self.navigationController?.popToRootViewController(animated: true) } else { + guard let scrollView = self.scrollView else { return } scrollView.setContentOffset(CGPoint(x: 0, y: -STATUS_BAR_HEGHIT()), animated: true) } } diff --git a/Projects/Features/HomeFeature/Sources/ViewControllers/NewSongsContentViewController.swift b/Projects/Features/HomeFeature/Sources/ViewControllers/NewSongsContentViewController.swift index dffc46cbb..06f15d263 100644 --- a/Projects/Features/HomeFeature/Sources/ViewControllers/NewSongsContentViewController.swift +++ b/Projects/Features/HomeFeature/Sources/ViewControllers/NewSongsContentViewController.swift @@ -2,6 +2,7 @@ import BaseFeature import BaseFeatureInterface import DesignSystem import Localization +import LogManager import NVActivityIndicatorView import RxCocoa import RxSwift @@ -20,7 +21,7 @@ public class NewSongsContentViewController: UIViewController, ViewControllerFrom private let disposeBag = DisposeBag() private var containSongsFactory: ContainSongsFactory! - private var textPopupFactory: TextPopUpFactory! + private var textPopupFactory: TextPopupFactory! private var signInFactory: SignInFactory! private var songDetailPresenter: SongDetailPresentable! @@ -45,7 +46,7 @@ public class NewSongsContentViewController: UIViewController, ViewControllerFrom public static func viewController( viewModel: NewSongsContentViewModel, containSongsFactory: ContainSongsFactory, - textPopupFactory: TextPopUpFactory, + textPopupFactory: TextPopupFactory, signInFactory: SignInFactory, songDetailPresenter: SongDetailPresentable ) -> NewSongsContentViewController { @@ -189,6 +190,9 @@ private extension NewSongsContentViewController { confirmButtonText: nil, cancelButtonText: nil, completion: { + let log = CommonAnalyticsLog.clickLoginButton(entry: .addMusics) + LogManager.analytics(log) + let loginVC = owner.signInFactory.makeView() loginVC.modalPresentationStyle = .overFullScreen owner.present(loginVC, animated: true) @@ -213,7 +217,13 @@ private extension NewSongsContentViewController { extension NewSongsContentViewController: NewSongsCellDelegate { func tappedThumbnail(id: String) { - songDetailPresenter.present(id: id) + guard let tappedSong = output.dataSource.value + .first(where: { $0.id == id }) + else { return } + PlayState.shared.append(item: .init(id: tappedSong.id, title: tappedSong.title, artist: tappedSong.artist)) + let playlistIDs = PlayState.shared.currentPlaylist + .map(\.id) + songDetailPresenter.present(ids: playlistIDs, selectedID: id) } } @@ -241,7 +251,10 @@ extension NewSongsContentViewController: SingleActionButtonViewDelegate { output.showToast.onNext("ํ•ด๋‹น ๊ธฐ๋Šฅ์€ ์ค€๋น„ ์ค‘์ž…๋‹ˆ๋‹ค.") return } - UIApplication.shared.open(url) + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let listID = components.queryItems?.first(where: { $0.name == "list" })?.value { + WakmusicYoutubePlayer(listID: listID).play() + } let songs: [SongEntity] = output.dataSource.value.map { return SongEntity( @@ -252,6 +265,9 @@ extension NewSongsContentViewController: SingleActionButtonViewDelegate { date: "\($0.date)" ) } + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .recentMusic, type: .all) + ) PlayState.shared.loadAndAppendSongsToPlaylist(songs) } } @@ -266,6 +282,9 @@ extension NewSongsContentViewController: SongCartViewDelegate { input.allSongSelected.onNext(flag) case .addSong: + let log = CommonAnalyticsLog.clickAddMusicsButton(location: .recentMusic) + LogManager.analytics(log) + if PreferenceManager.userInfo == nil { output.showLogin.onNext(()) return @@ -284,10 +303,6 @@ extension NewSongsContentViewController: SongCartViewDelegate { } case .addPlayList: - guard songs.count <= limit else { - output.showToast.onNext(LocalizationStrings.overFlowAddPlaylistWarning(songs.count - limit)) - return - } PlayState.shared.appendSongsToPlaylist(songs) output.showToast.onNext(LocalizationStrings.addList) input.allSongSelected.onNext(false) @@ -299,7 +314,14 @@ extension NewSongsContentViewController: SongCartViewDelegate { } PlayState.shared.loadAndAppendSongsToPlaylist(songs) input.allSongSelected.onNext(false) - WakmusicYoutubePlayer(ids: songs.map { $0.id }).play() + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .recentMusic, type: .multiple) + ) + if songs.allSatisfy({ $0.title.isContainShortsTagTitle }) { + WakmusicYoutubePlayer(ids: songs.map { $0.id }, title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง", playPlatform: .youtube).play() + } else { + WakmusicYoutubePlayer(ids: songs.map { $0.id }, title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง").play() + } case .remove: return diff --git a/Projects/Features/HomeFeature/Sources/ViewControllers/NewSongsViewController.swift b/Projects/Features/HomeFeature/Sources/ViewControllers/NewSongsViewController.swift index a9929d742..770c84f49 100644 --- a/Projects/Features/HomeFeature/Sources/ViewControllers/NewSongsViewController.swift +++ b/Projects/Features/HomeFeature/Sources/ViewControllers/NewSongsViewController.swift @@ -7,6 +7,7 @@ // import DesignSystem +import LogManager import Pageboy import SongsDomainInterface import Tabman @@ -26,7 +27,7 @@ public class NewSongsViewController: TabmanViewController, ViewControllerFromSto return viewControllers }() - deinit { DEBUG_LOG("โŒ \(Self.self) Deinit") } + deinit { LogManager.printDebug("โŒ \(Self.self) Deinit") } override public func viewDidLoad() { super.viewDidLoad() @@ -34,6 +35,30 @@ public class NewSongsViewController: TabmanViewController, ViewControllerFromSto configurePage() } + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + let log = CommonAnalyticsLog.viewPage(pageName: .recentMusic) + LogManager.analytics(log) + } + + override public func pageboyViewController( + _ pageboyViewController: PageboyViewController, + didScrollToPageAt index: TabmanViewController.PageIndex, + direction: PageboyViewController.NavigationDirection, + animated: Bool + ) { + let type = NewSongsAnalyticsLog.RecentMusicType.allCases[safe: index] ?? .all + let log = NewSongsAnalyticsLog.selectRecentMusicType(type: type) + LogManager.analytics(log) + + super.pageboyViewController( + pageboyViewController, + didScrollToPageAt: index, + direction: direction, + animated: animated + ) + } + public static func viewController( newSongsContentComponent: NewSongsContentComponent ) -> NewSongsViewController { @@ -68,6 +93,7 @@ extension NewSongsViewController { bar.layout.contentInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) bar.layout.contentMode = .fit bar.layout.transitionStyle = .progressive + bar.layout.interButtonSpacing = 0 // ๋ฒ„ํŠผ ๊ธ€์”จ ์ปค์Šคํ…€ bar.buttons.customize { button in diff --git a/Projects/Features/LyricHighlightingFeature/Sources/Components/LyricDecoratingComponent.swift b/Projects/Features/LyricHighlightingFeature/Sources/Components/LyricDecoratingComponent.swift index 376c7c06a..3bd9f12aa 100644 --- a/Projects/Features/LyricHighlightingFeature/Sources/Components/LyricDecoratingComponent.swift +++ b/Projects/Features/LyricHighlightingFeature/Sources/Components/LyricDecoratingComponent.swift @@ -6,7 +6,7 @@ import NeedleFoundation public protocol LyricDecoratingDependency: Dependency { var fetchLyricDecoratingBackgroundUseCase: any FetchLyricDecoratingBackgroundUseCase { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } } public final class LyricDecoratingComponent: Component { @@ -17,7 +17,7 @@ public final class LyricDecoratingComponent: Component UICollectionViewLayout { diff --git a/Projects/Features/LyricHighlightingFeature/Sources/ViewModels/LyricHighlightingViewModel.swift b/Projects/Features/LyricHighlightingFeature/Sources/ViewModels/LyricHighlightingViewModel.swift index 02b0466b3..a23e71a48 100644 --- a/Projects/Features/LyricHighlightingFeature/Sources/ViewModels/LyricHighlightingViewModel.swift +++ b/Projects/Features/LyricHighlightingFeature/Sources/ViewModels/LyricHighlightingViewModel.swift @@ -26,6 +26,7 @@ public final class LyricHighlightingViewModel: ViewModelType { public struct Input { let fetchLyric: PublishSubject = PublishSubject() + let didTapActivateButton: BehaviorRelay = BehaviorRelay(value: false) let didTapHighlighting: BehaviorRelay = BehaviorRelay(value: .init(row: -1, section: 0)) let didTapSaveButton: PublishSubject = PublishSubject() } @@ -40,6 +41,7 @@ public final class LyricHighlightingViewModel: ViewModelType { artist: "" )) let updateProvider: PublishSubject = .init() + let showToast: PublishSubject = .init() } public func transform(from input: Input) -> Output { @@ -60,11 +62,22 @@ public final class LyricHighlightingViewModel: ViewModelType { .disposed(by: disposeBag) input.didTapHighlighting - .map { $0.item } + .withLatestFrom(input.didTapActivateButton) { ($0, $1) } + .filter { $0.1 } + .map { $0.0.item } .filter { $0 >= 0 } .withLatestFrom(output.dataSource) { ($0, $1) } .filter { index, entities in - return entities[index].isHighlighting || entities.filter { $0.isHighlighting }.count < 4 + let currentTotalLineCount: Int = entities.filter { $0.isHighlighting } + .map { $0.text.components(separatedBy: "\n").count } + .reduce(0, +) + let nowSelectItemLineCount: Int = entities[index].text + .components(separatedBy: "\n").count + guard entities[index].isHighlighting || (currentTotalLineCount + nowSelectItemLineCount <= 4) else { + output.showToast.onNext("๊ฐ€์‚ฌ๋Š” ์ตœ๋Œ€ 4์ค„๊นŒ์ง€ ์„ ํƒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") + return false + } + return true } .map { index, entities in var newEntities = entities diff --git a/Projects/Features/MainTabFeature/Resources/Main.storyboard b/Projects/Features/MainTabFeature/Resources/Main.storyboard index 0d0e82cd8..c3abe8188 100644 --- a/Projects/Features/MainTabFeature/Resources/Main.storyboard +++ b/Projects/Features/MainTabFeature/Resources/Main.storyboard @@ -143,44 +143,25 @@ - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Projects/Features/MyInfoFeature/Sources/Analytics/FAQAnalyticsLog.swift b/Projects/Features/MyInfoFeature/Sources/Analytics/FAQAnalyticsLog.swift new file mode 100644 index 000000000..7b9897658 --- /dev/null +++ b/Projects/Features/MyInfoFeature/Sources/Analytics/FAQAnalyticsLog.swift @@ -0,0 +1,6 @@ +import LogManager + +enum FAQAnalyticsLog: AnalyticsLogType { + case selectFaqCategory(category: String) + case clickFaqItem(title: String) +} diff --git a/Projects/Features/MyInfoFeature/Sources/Analytics/InquiryAnalyticsLog.swift b/Projects/Features/MyInfoFeature/Sources/Analytics/InquiryAnalyticsLog.swift new file mode 100644 index 000000000..b36030a65 --- /dev/null +++ b/Projects/Features/MyInfoFeature/Sources/Analytics/InquiryAnalyticsLog.swift @@ -0,0 +1,33 @@ +import LogManager + +enum InquiryAnalyticsLog: AnalyticsLogType { + case clickInquirySubmitButton(type: LogInquiryType) + + enum LogInquiryType: String, AnalyticsLogEnumParametable { + case bug = "๋ฒ„๊ทธ ์ œ๋ณด" + case feature = "๊ธฐ๋Šฅ ์ œ์•ˆ" + case addSong = "๋…ธ๋ž˜ ์ถ”๊ฐ€" + case modifySong = "๋…ธ๋ž˜ ์ˆ˜์ •" + case weeklyChart = "์ฃผ๊ฐ„์ฐจํŠธ ์˜์ƒ" + case credit = "์ฐธ์—ฌ ์ •๋ณด" + + init(mailSource: InquiryType) { + switch mailSource { + case .reportBug: + self = .bug + case .suggestFunction: + self = .feature + case .addSong: + self = .addSong + case .modifySong: + self = .modifySong + case .weeklyChart: + self = .weeklyChart + case .credit: + self = .credit + case .unknown: + self = .bug + } + } + } +} diff --git a/Projects/Features/MyInfoFeature/Sources/Analytics/MyInfoAnalyticsLog.swift b/Projects/Features/MyInfoFeature/Sources/Analytics/MyInfoAnalyticsLog.swift new file mode 100644 index 000000000..25b39e469 --- /dev/null +++ b/Projects/Features/MyInfoFeature/Sources/Analytics/MyInfoAnalyticsLog.swift @@ -0,0 +1,24 @@ +import LogManager + +enum MyInfoAnalyticsLog: AnalyticsLogType { + case clickProfileImage + case clickProfileChangeButton + case completeProfileChange + case clickNicknameChangeButton + case completeNicknameChange + case clickFruitDrawEntryButton(location: FruitDrawEntryLocation) + case clickFruitStorageButton + case clickFaqButton + case clickNoticeButton + case clickInquiryButton + case clickTeamButton + case clickSettingButton +} + +enum FruitDrawEntryLocation: String, AnalyticsLogEnumParametable { + case myPage = "my_page" + + var description: String { + self.rawValue + } +} diff --git a/Projects/Features/MyInfoFeature/Sources/Analytics/NoticeAnalyticsLog.swift b/Projects/Features/MyInfoFeature/Sources/Analytics/NoticeAnalyticsLog.swift new file mode 100644 index 000000000..2b06b27c1 --- /dev/null +++ b/Projects/Features/MyInfoFeature/Sources/Analytics/NoticeAnalyticsLog.swift @@ -0,0 +1,5 @@ +import LogManager + +enum NoticeAnalyticsLog: AnalyticsLogType { + case clickNoticeItem(id: String, location: String = "notice") +} diff --git a/Projects/Features/MyInfoFeature/Sources/Analytics/SettingAnalyticsLog.swift b/Projects/Features/MyInfoFeature/Sources/Analytics/SettingAnalyticsLog.swift new file mode 100644 index 000000000..5cb1a9553 --- /dev/null +++ b/Projects/Features/MyInfoFeature/Sources/Analytics/SettingAnalyticsLog.swift @@ -0,0 +1,17 @@ +import LogManager + +enum SettingAnalyticsLog: AnalyticsLogType { + case clickNotificationButton + case clickTermsOfServiceButton + case clickPrivacyPolicyButton + case clickSongPlayPlatform + case completeSelectSongPlayPlatform(platform: String) + case clickOpensourceButton + case clickRemoveCacheButton + case completeRemoveCache(size: String) + case clickLogoutButton + case completeLogout + case clickVersionButton + case clickWithdrawButton + case completeWithdraw +} diff --git a/Projects/Features/MyInfoFeature/Sources/Components/MyInfoComponent.swift b/Projects/Features/MyInfoFeature/Sources/Components/MyInfoComponent.swift index 08ac73f1d..b8b957c6c 100644 --- a/Projects/Features/MyInfoFeature/Sources/Components/MyInfoComponent.swift +++ b/Projects/Features/MyInfoFeature/Sources/Components/MyInfoComponent.swift @@ -10,8 +10,8 @@ import UserDomainInterface public protocol MyInfoDependency: Dependency { var signInFactory: any SignInFactory { get } - var textPopUpFactory: any TextPopUpFactory { get } - var multiPurposePopUpFactory: any MultiPurposePopupFactory { get } + var textPopupFactory: any TextPopupFactory { get } + var multiPurposePopupFactory: any MultiPurposePopupFactory { get } var faqFactory: any FaqFactory { get } var noticeFactory: any NoticeFactory { get } var questionFactory: any QuestionFactory { get } @@ -34,8 +34,8 @@ public final class MyInfoComponent: Component, MyInfoFactory { fetchUserInfoUseCase: dependency.fetchUserInfoUseCase ), profilePopupFactory: dependency.profilePopupFactory, - textPopUpFactory: dependency.textPopUpFactory, - multiPurposePopUpFactory: dependency.multiPurposePopUpFactory, + textPopupFactory: dependency.textPopupFactory, + multiPurposePopupFactory: dependency.multiPurposePopupFactory, signInFactory: dependency.signInFactory, faqFactory: dependency.faqFactory, noticeFactory: dependency.noticeFactory, diff --git a/Projects/Features/MyInfoFeature/Sources/Components/PlayTypeTogglePopupComponent.swift b/Projects/Features/MyInfoFeature/Sources/Components/PlayTypeTogglePopupComponent.swift new file mode 100644 index 000000000..5866f6004 --- /dev/null +++ b/Projects/Features/MyInfoFeature/Sources/Components/PlayTypeTogglePopupComponent.swift @@ -0,0 +1,15 @@ +import MyInfoFeatureInterface +import NeedleFoundation +import UIKit + +public final class PlayTypeTogglePopupComponent: Component, PlayTypeTogglePopupFactory { + public func makeView( + completion: ((_ selectedItemString: String) -> Void)? = nil, + cancelCompletion: (() -> Void)? = nil + ) -> UIViewController { + return PlayTypeTogglePopupViewController( + completion: completion, + cancelCompletion: cancelCompletion + ) + } +} diff --git a/Projects/Features/MyInfoFeature/Sources/Components/QuestionComponent.swift b/Projects/Features/MyInfoFeature/Sources/Components/QuestionComponent.swift index c2e647a24..24bec9ab3 100644 --- a/Projects/Features/MyInfoFeature/Sources/Components/QuestionComponent.swift +++ b/Projects/Features/MyInfoFeature/Sources/Components/QuestionComponent.swift @@ -5,14 +5,14 @@ import NeedleFoundation import UIKit public protocol QuestionDependency: Dependency { - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } } public final class QuestionComponent: Component, QuestionFactory { public func makeView() -> UIViewController { return QuestionViewController.viewController( viewModel: .init(), - textPopUpFactory: dependency.textPopUpFactory + textPopupFactory: dependency.textPopupFactory ) } } diff --git a/Projects/Features/MyInfoFeature/Sources/Components/ServiceInfoComponent.swift b/Projects/Features/MyInfoFeature/Sources/Components/ServiceInfoComponent.swift index fdbf7da72..6e968a0eb 100644 --- a/Projects/Features/MyInfoFeature/Sources/Components/ServiceInfoComponent.swift +++ b/Projects/Features/MyInfoFeature/Sources/Components/ServiceInfoComponent.swift @@ -5,7 +5,7 @@ import UIKit public protocol ServiceInfoDependency: Dependency { var openSourceLicenseFactory: any OpenSourceLicenseFactory { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } } public final class ServiceInfoComponent: Component, ServiceInfoFactory { @@ -13,7 +13,7 @@ public final class ServiceInfoComponent: Component, Servi return ServiceInfoViewController.viewController( viewModel: ServiceInfoViewModel(), openSourceLicenseFactory: dependency.openSourceLicenseFactory, - textPopUpFactory: dependency.textPopUpFactory + textPopupFactory: dependency.textPopupFactory ) } } diff --git a/Projects/Features/MyInfoFeature/Sources/Components/SettingComponent.swift b/Projects/Features/MyInfoFeature/Sources/Components/SettingComponent.swift index fcf9ca353..a805b41c7 100644 --- a/Projects/Features/MyInfoFeature/Sources/Components/SettingComponent.swift +++ b/Projects/Features/MyInfoFeature/Sources/Components/SettingComponent.swift @@ -12,11 +12,12 @@ public protocol SettingDependency: Dependency { var withdrawUserInfoUseCase: any WithdrawUserInfoUseCase { get } var logoutUseCase: any LogoutUseCase { get } var updateNotificationTokenUseCase: any UpdateNotificationTokenUseCase { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } var signInFactory: any SignInFactory { get } var serviceTermsFactory: any ServiceTermFactory { get } var privacyFactory: any PrivacyFactory { get } var openSourceLicenseFactory: any OpenSourceLicenseFactory { get } + var playTypeTogglePopupFactory: any PlayTypeTogglePopupFactory { get } } public final class SettingComponent: Component, SettingFactory { @@ -27,11 +28,12 @@ public final class SettingComponent: Component, SettingFactor logoutUseCase: dependency.logoutUseCase, updateNotificationTokenUseCase: dependency.updateNotificationTokenUseCase ), - textPopUpFactory: dependency.textPopUpFactory, + textPopupFactory: dependency.textPopupFactory, signInFactory: dependency.signInFactory, serviceTermsFactory: dependency.serviceTermsFactory, privacyFactory: dependency.privacyFactory, - openSourceLicenseFactory: dependency.openSourceLicenseFactory + openSourceLicenseFactory: dependency.openSourceLicenseFactory, + playTypeTogglePopupFactory: dependency.playTypeTogglePopupFactory ) } } diff --git a/Projects/Features/MyInfoFeature/Sources/DataSource/SettingItemDataSource.swift b/Projects/Features/MyInfoFeature/Sources/DataSource/SettingItemDataSource.swift index 447df2450..7c80780df 100644 --- a/Projects/Features/MyInfoFeature/Sources/DataSource/SettingItemDataSource.swift +++ b/Projects/Features/MyInfoFeature/Sources/DataSource/SettingItemDataSource.swift @@ -4,6 +4,7 @@ import UIKit class SettingItemDataSource: NSObject, UITableViewDataSource { private let nonLoginSettingItems: [SettingItemType] = [ .navigate(.appPush), + .navigate(.playType), .navigate(.serviceTerms), .navigate(.privacy), .navigate(.openSource), @@ -13,6 +14,7 @@ class SettingItemDataSource: NSObject, UITableViewDataSource { private let loginSettingItems: [SettingItemType] = [ .navigate(.appPush), + .navigate(.playType), .navigate(.serviceTerms), .navigate(.privacy), .navigate(.openSource), diff --git a/Projects/Features/MyInfoFeature/Sources/Reactors/MyInfoReactor.swift b/Projects/Features/MyInfoFeature/Sources/Reactors/MyInfoReactor.swift index 9dc7ad2c3..f7ffbbc91 100644 --- a/Projects/Features/MyInfoFeature/Sources/Reactors/MyInfoReactor.swift +++ b/Projects/Features/MyInfoFeature/Sources/Reactors/MyInfoReactor.swift @@ -22,7 +22,7 @@ final class MyInfoReactor: Reactor { case completedFruitDraw case completedSetProfile case changeNicknameButtonDidTap(String) - case requiredLogin + case requiredLogin(CommonAnalyticsLog.LoginButtonEntry) } enum Mutation { @@ -47,7 +47,7 @@ final class MyInfoReactor: Reactor { case mail case team case setting - case login + case login(entry: CommonAnalyticsLog.LoginButtonEntry) } struct State { @@ -113,8 +113,8 @@ final class MyInfoReactor: Reactor { return teamNavigationDidTap() case .settingNavigationDidTap: return settingNavigationDidTap() - case .requiredLogin: - return navigateLogin() + case let .requiredLogin(entry): + return navigateLogin(entry: entry) case .completedFruitDraw: return mutateFetchUserInfo() case .completedSetProfile: @@ -265,8 +265,8 @@ private extension MyInfoReactor { return .just(.navigate(.setting)) } - func navigateLogin() -> Observable { - return .just(.navigate(.login)) + func navigateLogin(entry: CommonAnalyticsLog.LoginButtonEntry) -> Observable { + return .just(.navigate(.login(entry: entry))) } } diff --git a/Projects/Features/MyInfoFeature/Sources/Reactors/SettingReactor.swift b/Projects/Features/MyInfoFeature/Sources/Reactors/SettingReactor.swift index db37ff0a0..785ecf21b 100644 --- a/Projects/Features/MyInfoFeature/Sources/Reactors/SettingReactor.swift +++ b/Projects/Features/MyInfoFeature/Sources/Reactors/SettingReactor.swift @@ -37,6 +37,7 @@ final class SettingReactor: Reactor { case updateIsHiddenWithDrawButton(Bool) case changedNotificationAuthorizationStatus(Bool) case updateIsShowActivityIndicator(Bool) + case reloadTableView } enum NavigateType { @@ -59,6 +60,7 @@ final class SettingReactor: Reactor { @Pulse var withDrawButtonDidTap: Bool? @Pulse var confirmRemoveCacheButtonDidTap: Bool? @Pulse var withDrawResult: BaseEntity? + @Pulse var reloadTableView: Void? } var initialState: State @@ -137,6 +139,8 @@ final class SettingReactor: Reactor { newState.notificationAuthorizationStatus = granted case let .updateIsShowActivityIndicator(isShow): newState.isShowActivityIndicator = isShow + case .reloadTableView: + newState.reloadTableView = () } return newState } @@ -161,10 +165,18 @@ final class SettingReactor: Reactor { return .just(.changedNotificationAuthorizationStatus(granted)) } + let updatePlayTypeMutation = PreferenceManager.$songPlayPlatformType + .distinctUntilChanged() + .map { $0 ?? .youtube } + .flatMap { playType -> Observable in + return .just(.reloadTableView) + } + return Observable.merge( mutation, updateIsLoggedInMutation, - updatepushNotificationAuthorizationStatusMutation + updatepushNotificationAuthorizationStatusMutation, + updatePlayTypeMutation ) } } diff --git a/Projects/Features/MyInfoFeature/Sources/ViewControllers/FAQ/FaqContentViewController.swift b/Projects/Features/MyInfoFeature/Sources/ViewControllers/FAQ/FaqContentViewController.swift index 08346e108..7b4c0c542 100644 --- a/Projects/Features/MyInfoFeature/Sources/ViewControllers/FAQ/FaqContentViewController.swift +++ b/Projects/Features/MyInfoFeature/Sources/ViewControllers/FAQ/FaqContentViewController.swift @@ -1,4 +1,5 @@ import BaseFeature +import LogManager import RxCocoa import RxRelay import RxSwift @@ -111,6 +112,8 @@ extension FaqContentViewController: UITableViewDelegate { if data[indexPath.section].isOpen { self.scrollToBottom(indexPath: next) + let title = data[indexPath.section].question + LogManager.analytics(FAQAnalyticsLog.clickFaqItem(title: title)) } } diff --git a/Projects/Features/MyInfoFeature/Sources/ViewControllers/FAQ/FaqViewController.swift b/Projects/Features/MyInfoFeature/Sources/ViewControllers/FAQ/FaqViewController.swift index c3d2ad10e..6c395ef6a 100644 --- a/Projects/Features/MyInfoFeature/Sources/ViewControllers/FAQ/FaqViewController.swift +++ b/Projects/Features/MyInfoFeature/Sources/ViewControllers/FAQ/FaqViewController.swift @@ -1,4 +1,5 @@ import DesignSystem +import LogManager import MyInfoFeatureInterface import NVActivityIndicatorView import Pageboy @@ -45,6 +46,25 @@ public final class FaqViewController: TabmanViewController, ViewControllerFromSt viewController.faqContentFactory = faqContentFactory return viewController } + + override public func pageboyViewController( + _ pageboyViewController: PageboyViewController, + didScrollToPageAt index: TabmanViewController.PageIndex, + direction: PageboyViewController.NavigationDirection, + animated: Bool + ) { + super.pageboyViewController( + pageboyViewController, + didScrollToPageAt: index, + direction: direction, + animated: animated + ) + + let titles = output.dataSource.value.0 + if let selectedTitle = titles[safe: index]?.trimmingCharacters(in: .whitespaces) { + LogManager.analytics(FAQAnalyticsLog.selectFaqCategory(category: selectedTitle)) + } + } } extension FaqViewController { @@ -120,7 +140,7 @@ extension FaqViewController { extension FaqViewController: PageboyViewControllerDataSource, TMBarDataSource { public func numberOfViewControllers(in pageboyViewController: Pageboy.PageboyViewController) -> Int { - DEBUG_LOG(self.viewControllers.count) + LogManager.printDebug(self.viewControllers.count) return self.viewControllers.count } diff --git a/Projects/Features/MyInfoFeature/Sources/ViewControllers/MyInfo/MyInfoViewController.swift b/Projects/Features/MyInfoFeature/Sources/ViewControllers/MyInfo/MyInfoViewController.swift index cf3b391fc..60cb00400 100644 --- a/Projects/Features/MyInfoFeature/Sources/ViewControllers/MyInfo/MyInfoViewController.swift +++ b/Projects/Features/MyInfoFeature/Sources/ViewControllers/MyInfo/MyInfoViewController.swift @@ -17,8 +17,8 @@ import Utility final class MyInfoViewController: BaseReactorViewController, EditSheetViewType { let myInfoView = MyInfoView() private var profilePopupFactory: ProfilePopupFactory! - private var textPopUpFactory: TextPopUpFactory! - private var multiPurposePopUpFactory: MultiPurposePopupFactory! + private var textPopupFactory: TextPopupFactory! + private var multiPurposePopupFactory: MultiPurposePopupFactory! private var signInFactory: SignInFactory! private var faqFactory: FaqFactory! // ์ž์ฃผ ๋ฌป๋Š” ์งˆ๋ฌธ private var noticeFactory: NoticeFactory! // ๊ณต์ง€์‚ฌํ•ญ @@ -39,16 +39,17 @@ final class MyInfoViewController: BaseReactorViewController, Edit view = myInfoView } - override public func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - navigationController?.interactivePopGestureRecognizer?.delegate = self - } - override func viewDidLoad() { super.viewDidLoad() reactor?.action.onNext(.viewDidLoad) } + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + navigationController?.interactivePopGestureRecognizer?.delegate = self + LogManager.analytics(CommonAnalyticsLog.viewPage(pageName: .myPage)) + } + override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) navigationController?.interactivePopGestureRecognizer?.delegate = nil @@ -58,8 +59,8 @@ final class MyInfoViewController: BaseReactorViewController, Edit public static func viewController( reactor: MyInfoReactor, profilePopupFactory: ProfilePopupFactory, - textPopUpFactory: TextPopUpFactory, - multiPurposePopUpFactory: MultiPurposePopupFactory, + textPopupFactory: TextPopupFactory, + multiPurposePopupFactory: MultiPurposePopupFactory, signInFactory: SignInFactory, faqFactory: FaqFactory, noticeFactory: NoticeFactory, @@ -71,8 +72,8 @@ final class MyInfoViewController: BaseReactorViewController, Edit ) -> MyInfoViewController { let viewController = MyInfoViewController(reactor: reactor) viewController.profilePopupFactory = profilePopupFactory - viewController.textPopUpFactory = textPopUpFactory - viewController.multiPurposePopUpFactory = multiPurposePopUpFactory + viewController.textPopupFactory = textPopupFactory + viewController.multiPurposePopupFactory = multiPurposePopupFactory viewController.signInFactory = signInFactory viewController.faqFactory = faqFactory viewController.noticeFactory = noticeFactory @@ -138,6 +139,9 @@ final class MyInfoViewController: BaseReactorViewController, Edit reactor.pulse(\.$loginButtonDidTap) .compactMap { $0 } .bind(with: self) { owner, _ in + let log = CommonAnalyticsLog.clickLoginButton(entry: .mypage) + LogManager.analytics(log) + let vc = owner.signInFactory.makeView() vc.modalPresentationStyle = .fullScreen owner.present(vc, animated: true) @@ -169,14 +173,14 @@ final class MyInfoViewController: BaseReactorViewController, Edit viewController.modalPresentationStyle = .fullScreen owner.present(viewController, animated: true) } else { - reactor.action.onNext(.requiredLogin) + reactor.action.onNext(.requiredLogin(.fruitDraw)) } case .fruit: if reactor.currentState.isLoggedIn { let viewController = owner.fruitStorageFactory.makeView() owner.navigationController?.pushViewController(viewController, animated: true) } else { - reactor.action.onNext(.requiredLogin) + reactor.action.onNext(.requiredLogin(.fruitStorage)) } case .faq: let vc = owner.faqFactory.makeView() @@ -194,13 +198,25 @@ final class MyInfoViewController: BaseReactorViewController, Edit case .setting: let vc = owner.settingFactory.makeView() owner.navigationController?.pushViewController(vc, animated: true) - case .login: - let vc = owner.textPopUpFactory.makeView( + case let .login(entry): + let vc = owner.textPopupFactory.makeView( text: LocalizationStrings.needLoginWarning, cancelButtonIsHidden: false, confirmButtonText: nil, cancelButtonText: nil, completion: { + switch entry { + case .fruitDraw: + let log = CommonAnalyticsLog.clickLoginButton(entry: .fruitDraw) + LogManager.analytics(log) + case .fruitStorage: + let log = CommonAnalyticsLog.clickLoginButton(entry: .fruitStorage) + LogManager.analytics(log) + default: + assertionFailure("์˜ˆ์ƒ์น˜ ๋ชปํ•œ entry๊ฐ€ ๋“ค์–ด์˜ด") + LogManager.printDebug("์˜ˆ์ƒ์น˜ ๋ชปํ•œ entry๊ฐ€ ๋“ค์–ด์˜ด") + } + let loginVC = owner.signInFactory.makeView() loginVC.modalPresentationStyle = .fullScreen owner.present(loginVC, animated: true) @@ -221,41 +237,57 @@ final class MyInfoViewController: BaseReactorViewController, Edit .disposed(by: disposeBag) myInfoView.rx.profileImageDidTap + .do(onNext: { LogManager.analytics(MyInfoAnalyticsLog.clickProfileImage) }) .map { MyInfoReactor.Action.profileImageDidTap } .bind(to: reactor.action) .disposed(by: disposeBag) + myInfoView.rx.fruitStorageButtonDidTap + .do(onNext: { LogManager.analytics(MyInfoAnalyticsLog.clickFruitStorageButton) }) + .map { MyInfoReactor.Action.fruitNavigationDidTap } + .bind(to: reactor.action) + .disposed(by: disposeBag) + myInfoView.rx.drawButtonDidTap + .do(onNext: { LogManager.analytics(MyInfoAnalyticsLog.clickFruitDrawEntryButton(location: .myPage)) }) .map { MyInfoReactor.Action.drawButtonDidTap } .bind(to: reactor.action) .disposed(by: disposeBag) myInfoView.rx.fruitNavigationButtonDidTap + .do(onNext: { LogManager.analytics(MyInfoAnalyticsLog.clickFruitStorageButton) }) .map { MyInfoReactor.Action.fruitNavigationDidTap } .bind(to: reactor.action) .disposed(by: disposeBag) + #warning("์ž์ฃผ๋ฌป๋Š”์งˆ๋ฌธ qna -> faq ๋กœ ๋ณ€๊ฒฝํ•˜๊ธฐ") myInfoView.rx.qnaNavigationButtonDidTap + .do(onNext: { LogManager.analytics(MyInfoAnalyticsLog.clickFaqButton) }) .map { MyInfoReactor.Action.faqNavigationDidTap } .bind(to: reactor.action) .disposed(by: disposeBag) myInfoView.rx.notiNavigationButtonDidTap + .do(onNext: { LogManager.analytics(MyInfoAnalyticsLog.clickNoticeButton) }) .map { MyInfoReactor.Action.notiNavigationDidTap } .bind(to: reactor.action) .disposed(by: disposeBag) + #warning("๋ฌธ์˜ํ•˜๊ธฐ mail -> qna ๋กœ ๋ณ€๊ฒฝํ•˜๊ธฐ") myInfoView.rx.mailNavigationButtonDidTap + .do(onNext: { LogManager.analytics(MyInfoAnalyticsLog.clickInquiryButton) }) .map { MyInfoReactor.Action.mailNavigationDidTap } .bind(to: reactor.action) .disposed(by: disposeBag) myInfoView.rx.teamNavigationButtonDidTap + .do(onNext: { LogManager.analytics(MyInfoAnalyticsLog.clickTeamButton) }) .map { MyInfoReactor.Action.teamNavigationDidTap } .bind(to: reactor.action) .disposed(by: disposeBag) myInfoView.rx.settingNavigationButtonDidTap + .do(onNext: { LogManager.analytics(MyInfoAnalyticsLog.clickSettingButton) }) .map { MyInfoReactor.Action.settingNavigationDidTap } .bind(to: reactor.action) .disposed(by: disposeBag) @@ -281,18 +313,22 @@ extension MyInfoViewController: EditSheetViewDelegate { case .share: break case .profile: + LogManager.analytics(MyInfoAnalyticsLog.clickProfileChangeButton) let vc = profilePopupFactory.makeView( completion: { [reactor] () in + LogManager.analytics(MyInfoAnalyticsLog.completeProfileChange) reactor?.action.onNext(.completedSetProfile) } ) let height: CGFloat = (ProfilePopupViewController.rowHeight * 2) + 10 showBottomSheet(content: vc, size: .fixed(190 + height + SAFEAREA_BOTTOM_HEIGHT())) case .nickname: - let vc = multiPurposePopUpFactory.makeView( + LogManager.analytics(MyInfoAnalyticsLog.clickNicknameChangeButton) + let vc = multiPurposePopupFactory.makeView( type: .nickname, key: "", completion: { [reactor] text in + LogManager.analytics(MyInfoAnalyticsLog.completeNicknameChange) reactor?.action.onNext(.changeNicknameButtonDidTap(text)) } ) diff --git a/Projects/Features/MyInfoFeature/Sources/ViewControllers/Notice/NoticeViewController.swift b/Projects/Features/MyInfoFeature/Sources/ViewControllers/Notice/NoticeViewController.swift index 637ce397e..6fc716562 100644 --- a/Projects/Features/MyInfoFeature/Sources/ViewControllers/Notice/NoticeViewController.swift +++ b/Projects/Features/MyInfoFeature/Sources/ViewControllers/Notice/NoticeViewController.swift @@ -1,5 +1,6 @@ import BaseFeature import DesignSystem +import LogManager import MyInfoFeatureInterface import NVActivityIndicatorView import RxCocoa @@ -85,6 +86,9 @@ private extension NoticeViewController { output.goNoticeDetailScene .bind(with: self) { owner, model in + let log = NoticeAnalyticsLog.clickNoticeItem(id: "\(model.id)", location: "notice") + LogManager.analytics(log) + let viewController = owner.noticeDetailFactory.makeView(model: model) viewController.modalPresentationStyle = .fullScreen owner.present(viewController, animated: true) diff --git a/Projects/Features/MyInfoFeature/Sources/ViewControllers/PlayTypeTogglePopup/PlayTypeTogglePopupViewController.swift b/Projects/Features/MyInfoFeature/Sources/ViewControllers/PlayTypeTogglePopup/PlayTypeTogglePopupViewController.swift new file mode 100644 index 000000000..865c21107 --- /dev/null +++ b/Projects/Features/MyInfoFeature/Sources/ViewControllers/PlayTypeTogglePopup/PlayTypeTogglePopupViewController.swift @@ -0,0 +1,281 @@ +import DesignSystem +import RxCocoa +import RxSwift +import SnapKit +import Then +import UIKit +import Utility + +public final class PlayTypeTogglePopupViewController: UIViewController { + private let dimmView = UIView().then { + $0.backgroundColor = .black.withAlphaComponent(0.4) + } + + private let contentView = UIView().then { + $0.layer.cornerRadius = 24 + $0.backgroundColor = .white + } + + private let titleLabel = WMLabel( + text: "์–ด๋–ป๊ฒŒ ์žฌ์ƒํ• ๊นŒ์š”?", + textColor: DesignSystemAsset.BlueGrayColor.gray900.color, + font: .t2(weight: .bold), + alignment: .center, + lineHeight: UIFont.WMFontSystem.t2().lineHeight, + kernValue: -0.5 + ) + + private let vStackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 8 + $0.distribution = .fillEqually + } + + private let firstItemButton = PlayTypeTogglePopupItemButtonView() + + private let secondItemButton = PlayTypeTogglePopupItemButtonView() + + private let firstDotImageView = UIImageView().then { + $0.image = DesignSystemAsset.MyInfo.dot.image + } + + private let secondDotImageView = UIImageView().then { + $0.image = DesignSystemAsset.MyInfo.dot.image + } + + private let firstDescriptionLabel = WMLabel( + text: "ํ•ด๋‹น ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์•ฑ์ด ์„ค์น˜๋˜์–ด ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", + textColor: DesignSystemAsset.BlueGrayColor.gray500.color, + font: .t7(weight: .light), + alignment: .left + ) + + private let secondDescriptionLabel = WMLabel( + text: "์ผ๋ถ€ ๋…ธ๋ž˜๋‚˜ ์‡ผ์ธ ๋Š” YouTube Music์—์„œ ์ง€์›๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + textColor: DesignSystemAsset.BlueGrayColor.gray500.color, + font: .t7(weight: .light), + alignment: .left + ) + + private let hStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 0 + $0.distribution = .fillEqually + } + + private let cancelButton = UIButton().then { + let cancleButtonBackgroundColor = DesignSystemAsset.BlueGrayColor.blueGray400.color + $0.setBackgroundColor(cancleButtonBackgroundColor, for: .normal) + $0.setTitle("์ทจ์†Œ", for: .normal) + $0.setTitleColor(DesignSystemAsset.BlueGrayColor.blueGray25.color, for: .normal) + $0.titleLabel?.font = .setFont(.t4(weight: .medium)) + $0.titleLabel?.setTextWithAttributes(alignment: .center) + } + + private let confirmButton = UIButton().then { + let confirmButtonBackgroundColor = DesignSystemAsset.PrimaryColorV2.point.color + $0.setBackgroundColor(confirmButtonBackgroundColor, for: .normal) + $0.setTitle("ํ™•์ธ", for: .normal) + $0.setTitleColor(DesignSystemAsset.BlueGrayColor.blueGray25.color, for: .normal) + $0.titleLabel?.font = .setFont(.t4(weight: .medium)) + $0.titleLabel?.setTextWithAttributes(alignment: .center) + } + + private let disposeBag = DisposeBag() + private var selectedItemString: String = "" + private var completion: ((_ selectedItemString: String) -> Void)? + private var cancelCompletion: (() -> Void)? + + init( + completion: ((_ selectedItemString: String) -> Void)? = nil, + cancelCompletion: (() -> Void)? = nil + ) { + self.completion = completion + self.cancelCompletion = cancelCompletion + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + addViews() + setLayout() + configureUI() + setActions() + firstItemButton.setDelegate(self) + secondItemButton.setDelegate(self) + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + contentView.transform = CGAffineTransform(translationX: 0, y: 80) + contentView.alpha = 0 + dimmView.alpha = 0 + let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) + animator.addAnimations { + self.contentView.transform = CGAffineTransform.identity + } + animator.addAnimations { + self.contentView.alpha = 1 + self.dimmView.alpha = 1 + } + animator.startAnimation() + } + + func setActions() { + NotificationCenter.default.rx.notification(UIApplication.didBecomeActiveNotification) + .subscribe(onNext: { [weak self] _ in + self?.secondItemButton.checkAppIsInstalled() + }) + .disposed(by: disposeBag) + + let cancelAction = UIAction { [weak self] _ in + self?.dismiss() + } + + let confirmAction = UIAction { [weak self] _ in + guard let self else { return } + self.completion?(self.selectedItemString) + self.dismiss() + } + cancelButton.addAction(cancelAction, for: .touchUpInside) + confirmButton.addAction(confirmAction, for: .touchUpInside) + } +} + +private extension PlayTypeTogglePopupViewController { + func addViews() { + self.view.addSubviews( + dimmView, + contentView + ) + contentView.addSubviews( + titleLabel, + firstItemButton, + secondItemButton, + firstDotImageView, + firstDescriptionLabel, + secondDotImageView, + secondDescriptionLabel, + hStackView + ) + hStackView.addArrangedSubviews(cancelButton, confirmButton) + } + + func setLayout() { + let is320 = APP_WIDTH() <= 320 + + dimmView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + contentView.snp.makeConstraints { + $0.height.equalTo(is320 ? 364 : 344) + $0.center.equalToSuperview() + $0.horizontalEdges.equalToSuperview().inset(20) + } + + titleLabel.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview().inset(20) + $0.top.equalToSuperview().offset(30) + } + + firstItemButton.snp.makeConstraints { + $0.height.equalTo(60) + $0.top.equalTo(titleLabel.snp.bottom).offset(20) + $0.horizontalEdges.equalToSuperview().inset(20) + } + + secondItemButton.snp.makeConstraints { + $0.height.equalTo(60) + $0.top.equalTo(firstItemButton.snp.bottom).offset(8) + $0.horizontalEdges.equalToSuperview().inset(20) + } + + firstDotImageView.snp.makeConstraints { + $0.size.equalTo(16) + $0.top.equalTo(secondItemButton.snp.bottom).offset(12) + $0.left.equalToSuperview().offset(20) + } + + firstDescriptionLabel.snp.makeConstraints { + $0.top.equalTo(firstDotImageView) + $0.left.equalTo(firstDotImageView.snp.right) + $0.right.equalToSuperview().inset(20) + } + + secondDotImageView.snp.makeConstraints { + $0.size.equalTo(16) + $0.top.equalTo(firstDescriptionLabel.snp.bottom).offset(4) + $0.left.equalToSuperview().offset(20) + } + + secondDescriptionLabel.snp.makeConstraints { + $0.top.equalTo(secondDotImageView) + $0.left.equalTo(secondDotImageView.snp.right) + $0.right.equalToSuperview().inset(20) + } + + hStackView.snp.makeConstraints { + $0.height.equalTo(56) + $0.horizontalEdges.equalToSuperview() + $0.bottom.equalToSuperview() + } + } + + func configureUI() { + self.view.backgroundColor = .clear + contentView.clipsToBounds = true + + let playType = PreferenceManager.songPlayPlatformType ?? .youtube + self.selectedItemString = playType.display + + firstItemButton.setTitleWithOption(title: YoutubePlayType.youtube.display) + secondItemButton.setTitleWithOption( + title: YoutubePlayType.youtubeMusic.display, + shouldCheckAppIsInstalled: true + ) + + firstItemButton.isSelected = playType == .youtube + secondItemButton.isSelected = playType == .youtubeMusic + + if APP_WIDTH() <= 320 { // ๋‘์ค„๋กœ ํ‘œ๊ธฐํ•˜๊ธฐ ์œ„ํ•จ + firstDescriptionLabel.numberOfLines = 0 + secondDescriptionLabel.numberOfLines = 0 + } + + let gesture = UITapGestureRecognizer(target: self, action: #selector(tappedAround(_:))) + dimmView.addGestureRecognizer(gesture) + dimmView.isUserInteractionEnabled = true + } + + @objc func tappedAround(_ sender: UITapGestureRecognizer) { + dismiss() + } + + func dismiss() { + self.dismiss(animated: false) + } +} + +extension PlayTypeTogglePopupViewController: PlayTypeTogglePopupItemButtonViewDelegate { + func tappedButtonAction(title: String) { + switch title { + case "YouTube": + self.selectedItemString = title + firstItemButton.isSelected = true + secondItemButton.isSelected = false + case "YouTube Music": + self.selectedItemString = title + firstItemButton.isSelected = false + secondItemButton.isSelected = true + default: + break + } + } +} diff --git a/Projects/Features/MyInfoFeature/Sources/ViewControllers/Question/QuestionViewController.swift b/Projects/Features/MyInfoFeature/Sources/ViewControllers/Question/QuestionViewController.swift index 5037ab99d..b8a02d189 100644 --- a/Projects/Features/MyInfoFeature/Sources/ViewControllers/Question/QuestionViewController.swift +++ b/Projects/Features/MyInfoFeature/Sources/ViewControllers/Question/QuestionViewController.swift @@ -1,6 +1,7 @@ import BaseFeature import BaseFeatureInterface import DesignSystem +import LogManager import MessageUI import RxSwift import SafariServices @@ -41,7 +42,7 @@ public final class QuestionViewController: BaseViewController, ViewControllerFro let unSelectedColor: UIColor = DesignSystemAsset.BlueGrayColor.blueGray200.color let disposeBag = DisposeBag() var viewModel: QuestionViewModel! - var textPopUpFactory: TextPopUpFactory! + var textPopupFactory: TextPopupFactory! lazy var input = QuestionViewModel.Input() lazy var output = viewModel.transform(from: input) @@ -57,11 +58,11 @@ public final class QuestionViewController: BaseViewController, ViewControllerFro public static func viewController( viewModel: QuestionViewModel, - textPopUpFactory: TextPopUpFactory + textPopupFactory: TextPopupFactory ) -> QuestionViewController { let viewController = QuestionViewController.viewController(storyBoardName: "Question", bundle: Bundle.module) viewController.viewModel = viewModel - viewController.textPopUpFactory = textPopUpFactory + viewController.textPopupFactory = textPopupFactory return viewController } } @@ -230,6 +231,10 @@ extension QuestionViewController { .withLatestFrom(output.mailSource) .filter { $0 != .unknown } .subscribe(onNext: { [weak self] source in + let logInquiryType: InquiryAnalyticsLog.LogInquiryType = .init(mailSource: source) + let log = InquiryAnalyticsLog.clickInquirySubmitButton(type: logInquiryType) + LogManager.analytics(log) + guard let self = self else { return } self.goToMail(source: source) }) @@ -261,7 +266,7 @@ extension QuestionViewController { self.present(compseVC, animated: true, completion: nil) } else { - guard let textPopupViewController = self.textPopUpFactory.makeView( + guard let textPopupViewController = self.textPopupFactory.makeView( text: "๋ฉ”์ผ ๊ณ„์ •์ด ์„ค์ •๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\n์„ค์ • > Mail ์•ฑ > ๊ณ„์ •์„ ์„ค์ •ํ•ด์ฃผ์„ธ์š”.", cancelButtonIsHidden: true, confirmButtonText: "ํ™•์ธ", diff --git a/Projects/Features/MyInfoFeature/Sources/ViewControllers/Request/RequestViewController.swift b/Projects/Features/MyInfoFeature/Sources/ViewControllers/Request/RequestViewController.swift deleted file mode 100644 index 9f2e95c84..000000000 --- a/Projects/Features/MyInfoFeature/Sources/ViewControllers/Request/RequestViewController.swift +++ /dev/null @@ -1,278 +0,0 @@ -// import BaseFeature -// import BaseFeatureInterface -// import DesignSystem -// import MyInfoFeatureInterface -// import RxSwift -// import UIKit -// import Utility -// -// public final class RequestViewController: UIViewController, ViewControllerFromStoryBoard { -// @IBOutlet weak var backButton: UIButton! -// @IBOutlet weak var titleLabel: UILabel! -// -// @IBOutlet weak var questionImageview: UIImageView! -// @IBOutlet weak var questionButton: UIButton! -// @IBOutlet weak var questionSuperView: UIView! -// -// @IBOutlet weak var qnaSuperView: UIView! -// @IBOutlet weak var qnaSuperImageview: UIImageView! -// @IBOutlet weak var qnaButton: UIButton! -// -// @IBOutlet weak var noticeSuperView: UIView! -// @IBOutlet weak var noticeButton: UIButton! -// @IBOutlet weak var noticeImageView: UIImageView! -// -// @IBOutlet weak var serviceSuperView: UIView! -// @IBOutlet weak var serviceButton: UIButton! -// @IBOutlet weak var serviceImageView: UIImageView! -// -// /// ํญํƒ„ -// @IBOutlet weak var bombSuperView: UIView! -// @IBOutlet weak var bombButton: UIButton! -// @IBOutlet weak var bombImageView: UIImageView! -// -// @IBOutlet weak var dotLabel: UILabel! -// @IBOutlet weak var descriptionLabel: UILabel! -// -// @IBOutlet weak var fakeViewHeight: NSLayoutConstraint! -// @IBOutlet weak var withdrawButton: UIButton! -// -// var textPopUpFactory: TextPopUpFactory! -// -// @IBAction func pressBackAction(_ sender: UIButton) { -// self.navigationController?.popViewController(animated: true) -// } -// -// @IBAction func moveQnaAction(_ sender: UIButton) { -// let vc = faqFactory.makeView() -// self.navigationController?.pushViewController(vc, animated: true) -// } -// -// @IBAction func moveQuestionAction(_ sender: Any) { -// let vc = questionFactory.makeView().wrapNavigationController -// vc.modalPresentationStyle = .fullScreen -// self.present(vc, animated: true) -// } -// -// @IBAction func movenoticeAction(_ sender: Any) { -// let viewController = noticeFactory.makeView() -// self.navigationController?.pushViewController(viewController, animated: true) -// } -// -// @IBAction func presswithDrawAction(_ sender: UIButton) { -// guard let secondConfirmVc = textPopUpFactory.makeView( -// text: "์ •๋ง ํƒˆํ‡ดํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", -// cancelButtonIsHidden: false, -// confirmButtonText: nil, -// cancelButtonText: nil, -// completion: { [weak self] in -// -// guard let self else { return } -// -// self.input.pressWithdraw.onNext(()) -// }, -// cancelCompletion: nil -// ) as? TextPopupViewController else { -// return -// } -// -// guard let firstConfirmVc = textPopUpFactory.makeView( -// text: "ํšŒ์›ํƒˆํ‡ด ์‹ ์ฒญ์„ ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", -// cancelButtonIsHidden: false, -// confirmButtonText: nil, -// cancelButtonText: nil, -// completion: { [weak self] in -// -// guard let self else { return } -// -// self.showBottomSheet(content: secondConfirmVc) -// }, -// cancelCompletion: nil -// ) as? TextPopupViewController else { -// return -// } -// -// self.showBottomSheet(content: firstConfirmVc) -// } -// -// var viewModel: RequestViewModel! -// lazy var input = RequestViewModel.Input() -// lazy var output = viewModel.transform(from: input) -// -// var faqFactory: FaqFactory! -// var questionFactory: QuestionFactory! -// var noticeFactory: NoticeFactory! -// var serviceInfoFactory: ServiceInfoFactory! -// -// var disposeBag = DisposeBag() -// deinit { DEBUG_LOG("โŒ \(Self.self) Deinit") } -// -// override public func viewDidLoad() { -// super.viewDidLoad() -// configureUI() -// bindRx() -// } -// -// override public func viewDidAppear(_ animated: Bool) { -// super.viewDidAppear(animated) -// navigationController?.interactivePopGestureRecognizer?.delegate = nil // ์Šค์™€์ดํ”„๋กœ ๋’ค๋กœ๊ฐ€๊ธฐ -// } -// -// public static func viewController( -// viewModel: RequestViewModel, -// faqFactory: FaqFactory, -// questionFactory: QuestionFactory, -// noticeFactory: NoticeFactory, -// serviceInfoFactory: ServiceInfoFactory, -// textPopUpFactory: TextPopUpFactory -// ) -> RequestViewController { -// let viewController = RequestViewController.viewController(storyBoardName: "Request", bundle: Bundle.module) -// viewController.viewModel = viewModel -// viewController.faqFactory = faqFactory -// viewController.questionFactory = questionFactory -// viewController.noticeFactory = noticeFactory -// viewController.serviceInfoFactory = serviceInfoFactory -// viewController.textPopUpFactory = textPopUpFactory -// return viewController -// } -// } -// -// extension RequestViewController { -// private func configureUI() { -// self.backButton.setImage(DesignSystemAsset.Navigation.back.image, for: .normal) -// self.titleLabel.text = "๊ฑด์˜์‚ฌํ•ญ" -// self.titleLabel.font = DesignSystemFontFamily.Pretendard.medium.font(size: 16) -// self.titleLabel.setTextWithAttributes(kernValue: -0.5) -// -// let buttons: [UIButton] = [ -// self.questionButton, -// self.qnaButton, -// self.noticeButton, -// self.serviceButton, -// self.bombButton -// ] -// let superViews: [UIView] = [ -// self.questionSuperView, -// self.qnaSuperView, -// self.noticeSuperView, -// self.serviceSuperView, -// self.bombSuperView -// ] -// let imageViews: [UIImageView] = [ -// self.questionImageview, -// self.qnaSuperImageview, -// self.noticeImageView, -// self.serviceImageView, -// self.bombImageView -// ] -// -// for i in 0 ..< buttons.count { -// var title = "" -// switch i { -// case 0: -// title = "๋ฌธ์˜ํ•˜๊ธฐ" -// imageViews[i].image = DesignSystemAsset.Storage.question.image -// case 1: -// title = "์ž์ฃผ ๋ฌป๋Š” ์งˆ๋ฌธ" -// imageViews[i].image = DesignSystemAsset.Storage.qna.image -// case 2: -// title = "๊ณต์ง€์‚ฌํ•ญ" -// imageViews[i].image = DesignSystemAsset.Storage.notice.image -// case 3: -// title = "์„œ๋น„์Šค ์ •๋ณด" -// imageViews[i].image = DesignSystemAsset.Storage.document.image -// case 4: -// title = "๐Ÿ’ฃ ์•ฑ ํ„ฐํŠธ๋ฆฌ๊ธฐ" -// imageViews[i].image = DesignSystemAsset.Storage.question.image -// default: -// return -// } -// -// let attr: NSAttributedString = NSAttributedString( -// string: title, -// attributes: [ -// .font: DesignSystemFontFamily.Pretendard.medium.font(size: 16), -// .foregroundColor: DesignSystemAsset.BlueGrayColor.gray900.color, -// .kern: -0.5 -// ] -// ) -// buttons[i].setAttributedTitle(attr, for: .normal) -// superViews[i].backgroundColor = .white.withAlphaComponent(0.4) -// superViews[i].layer.borderWidth = 1 -// superViews[i].layer.cornerRadius = 12 -// superViews[i].layer.borderColor = DesignSystemAsset.BlueGrayColor.gray200.color.cgColor -// } -// -// dotLabel.layer.cornerRadius = 2 -// dotLabel.clipsToBounds = true -// -// descriptionLabel.text = "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง ํŒ€์— ์†ํ•œ ๋ชจ๋“  ํŒ€์›๋“ค์€ ๋ถ€์•„๋‚ด๋น„ (๋ถ€๋ ค๋จน๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ ๋‚ด๊ฐ€ ๋น„๋นˆ๊ฑฐ๋‹ค)๋ผ๋Š” ๋ชจํ† ๋ฅผ ๊ฐ€์Šด์— ์ƒˆ๊ธฐ๊ณ  ์ผํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค." -// descriptionLabel.font = DesignSystemFontFamily.Pretendard.light.font(size: 12) -// descriptionLabel.setTextWithAttributes(kernValue: -0.5, lineHeightMultiple: 1.26) -// -// let withDrawAttributedString = NSMutableAttributedString.init(string: "ํšŒ์›ํƒˆํ‡ด") -// withDrawAttributedString.addAttributes( -// [ -// .font: DesignSystemFontFamily.Pretendard.bold.font(size: 12), -// .foregroundColor: DesignSystemAsset.BlueGrayColor.gray400.color, -// .kern: -0.5 -// ], -// range: NSRange(location: 0, length: withDrawAttributedString.string.count) -// ) -// withdrawButton.layer.borderWidth = 1 -// withdrawButton.layer.cornerRadius = 4 -// withdrawButton.layer.borderColor = DesignSystemAsset.BlueGrayColor.gray300.color.cgColor -// withdrawButton.setAttributedTitle(withDrawAttributedString, for: .normal) -// -// #if DEBUG -// self.bombSuperView.isHidden = false -// #else -// self.bombSuperView.isHidden = true -// #endif -// } -// -// private func bindRx() { -// output.withDrawResult.subscribe(onNext: { [weak self] in -// guard let self = self else { -// return -// } -// -// let status: Int = $0.status -// -// guard let textPopUpViewController = textPopUpFactory.makeView( -// text: (status == 200) ? "ํšŒ์›ํƒˆํ‡ด๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n์ด์šฉํ•ด์ฃผ์…”์„œ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค." : $0.description, -// cancelButtonIsHidden: true, -// confirmButtonText: nil, -// cancelButtonText: nil, -// completion: { [weak self] in -// -// guard let self else { return } -// -// if status == 200 { -// self.navigationController?.popViewController(animated: true) -// } -// }, -// cancelCompletion: nil -// ) as? TextPopupViewController else { -// return -// } -// -// self.showBottomSheet(content: textPopUpViewController) -// }) -// .disposed(by: disposeBag) -// -// serviceButton.rx.tap -// .withUnretained(self) -// .subscribe(onNext: { owner, _ in -// let viewController = owner.serviceInfoFactory.makeView() -// owner.navigationController?.pushViewController(viewController, animated: true) -// }).disposed(by: disposeBag) -// -// bombButton.rx.tap -// .withUnretained(self) -// .subscribe(onNext: { _, _ in -// let array: [Int] = [0] -// let _ = array[1] -// }).disposed(by: disposeBag) -// } -// } diff --git a/Projects/Features/MyInfoFeature/Sources/ViewControllers/ServiceInfo/ServiceInfoViewController.swift b/Projects/Features/MyInfoFeature/Sources/ViewControllers/ServiceInfo/ServiceInfoViewController.swift index 488e4f028..f3ac46b77 100644 --- a/Projects/Features/MyInfoFeature/Sources/ViewControllers/ServiceInfo/ServiceInfoViewController.swift +++ b/Projects/Features/MyInfoFeature/Sources/ViewControllers/ServiceInfo/ServiceInfoViewController.swift @@ -14,7 +14,7 @@ public class ServiceInfoViewController: UIViewController, ViewControllerFromStor @IBOutlet weak var tableView: UITableView! var openSourceLicenseFactory: OpenSourceLicenseFactory! - var textPopUpFactory: TextPopUpFactory! + var textPopupFactory: TextPopupFactory! var viewModel: ServiceInfoViewModel! var disposeBag: DisposeBag = DisposeBag() @@ -31,7 +31,7 @@ public class ServiceInfoViewController: UIViewController, ViewControllerFromStor public static func viewController( viewModel: ServiceInfoViewModel, openSourceLicenseFactory: OpenSourceLicenseFactory, - textPopUpFactory: TextPopUpFactory + textPopupFactory: TextPopupFactory ) -> ServiceInfoViewController { let viewController = ServiceInfoViewController.viewController( storyBoardName: "OpenSourceAndServiceInfo", @@ -39,7 +39,7 @@ public class ServiceInfoViewController: UIViewController, ViewControllerFromStor ) viewController.viewModel = viewModel viewController.openSourceLicenseFactory = openSourceLicenseFactory - viewController.textPopUpFactory = textPopUpFactory + viewController.textPopupFactory = textPopupFactory return viewController } } @@ -95,7 +95,7 @@ extension ServiceInfoViewController { .withUnretained(self) .subscribe(onNext: { owner, sizeString in - guard let textPopupVC = owner.textPopUpFactory.makeView( + guard let textPopupVC = owner.textPopupFactory.makeView( text: "์บ์‹œ ๋ฐ์ดํ„ฐ(\(sizeString))๋ฅผ ์ง€์šฐ์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", cancelButtonIsHidden: false, confirmButtonText: nil, diff --git a/Projects/Features/MyInfoFeature/Sources/ViewControllers/Setting/SettingViewController.swift b/Projects/Features/MyInfoFeature/Sources/ViewControllers/Setting/SettingViewController.swift index e77c86efa..8b1583d9a 100644 --- a/Projects/Features/MyInfoFeature/Sources/ViewControllers/Setting/SettingViewController.swift +++ b/Projects/Features/MyInfoFeature/Sources/ViewControllers/Setting/SettingViewController.swift @@ -13,11 +13,12 @@ import UIKit import Utility final class SettingViewController: BaseReactorViewController { - private var textPopUpFactory: TextPopUpFactory! + private var textPopupFactory: TextPopupFactory! private var signInFactory: SignInFactory! private var serviceTermsFactory: ServiceTermFactory! private var privacyFactory: PrivacyFactory! private var openSourceLicenseFactory: OpenSourceLicenseFactory! + private var playTypeTogglePopupFactory: PlayTypeTogglePopupFactory! let settingView = SettingView() let settingItemDataSource = SettingItemDataSource() @@ -32,24 +33,38 @@ final class SettingViewController: BaseReactorViewController { view.backgroundColor = DesignSystemAsset.BlueGrayColor.blueGray100.color } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + LogManager.analytics(CommonAnalyticsLog.viewPage(pageName: .setting)) + } + public static func viewController( reactor: SettingReactor, - textPopUpFactory: TextPopUpFactory, + textPopupFactory: TextPopupFactory, signInFactory: SignInFactory, serviceTermsFactory: ServiceTermFactory, privacyFactory: PrivacyFactory, - openSourceLicenseFactory: OpenSourceLicenseFactory + openSourceLicenseFactory: OpenSourceLicenseFactory, + playTypeTogglePopupFactory: PlayTypeTogglePopupFactory ) -> SettingViewController { let viewController = SettingViewController(reactor: reactor) - viewController.textPopUpFactory = textPopUpFactory + viewController.textPopupFactory = textPopupFactory viewController.signInFactory = signInFactory viewController.serviceTermsFactory = serviceTermsFactory viewController.privacyFactory = privacyFactory viewController.openSourceLicenseFactory = openSourceLicenseFactory + viewController.playTypeTogglePopupFactory = playTypeTogglePopupFactory return viewController } override func bindState(reactor: SettingReactor) { + reactor.pulse(\.$reloadTableView) + .compactMap { $0 } + .bind(with: self) { owner, _ in + owner.settingView.settingItemTableView.reloadData() + } + .disposed(by: disposeBag) + reactor.state.map(\.isShowActivityIndicator) .distinctUntilChanged() .bind(with: self) { owner, isShow in @@ -106,12 +121,15 @@ final class SettingViewController: BaseReactorViewController { reactor.pulse(\.$cacheSize) .compactMap { $0 } .bind(with: self, onNext: { owner, cachSize in - guard let textPopupVC = owner.textPopUpFactory.makeView( + guard let textPopupVC = owner.textPopupFactory.makeView( text: "์บ์‹œ ๋ฐ์ดํ„ฐ(\(cachSize))๋ฅผ ์ง€์šฐ์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", cancelButtonIsHidden: false, confirmButtonText: nil, cancelButtonText: nil, completion: { + let log = SettingAnalyticsLog.completeRemoveCache(size: cachSize) + LogManager.analytics(log) + owner.reactor?.action.onNext(.confirmRemoveCacheButtonDidTap) }, cancelCompletion: nil @@ -132,30 +150,26 @@ final class SettingViewController: BaseReactorViewController { reactor.pulse(\.$withDrawButtonDidTap) .compactMap { $0 } .bind(with: self, onNext: { owner, _ in - guard let secondConfirmVC = owner.textPopUpFactory.makeView( - text: "์ •๋ง ํƒˆํ‡ดํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", - cancelButtonIsHidden: false, - confirmButtonText: nil, - cancelButtonText: nil, - completion: { - owner.reactor?.action.onNext(.confirmWithDrawButtonDidTap) - }, - cancelCompletion: nil - ) as? TextPopupViewController else { - return + let makeSecondConfirmVC: () -> UIViewController = { + owner.textPopupFactory.makeView( + text: "์ •๋ง ํƒˆํ‡ดํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + cancelButtonIsHidden: false, + completion: { + let log = SettingAnalyticsLog.completeWithdraw + LogManager.analytics(log) + + owner.reactor?.action.onNext(.confirmWithDrawButtonDidTap) + } + ) } - guard let firstConfirmVC = owner.textPopUpFactory.makeView( + let firstConfirmVC = owner.textPopupFactory.makeView( text: "ํšŒ์›ํƒˆํ‡ด ์‹ ์ฒญ์„ ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", cancelButtonIsHidden: false, - confirmButtonText: nil, - cancelButtonText: nil, completion: { + let secondConfirmVC = makeSecondConfirmVC() owner.showBottomSheet(content: secondConfirmVC) - }, - cancelCompletion: nil - ) as? TextPopupViewController else { - return - } + } + ) owner.showBottomSheet(content: firstConfirmVC) }) .disposed(by: disposeBag) @@ -165,7 +179,7 @@ final class SettingViewController: BaseReactorViewController { .bind(with: self) { owner, withDrawResult in let status = withDrawResult.status let description = withDrawResult.description - guard let textPopUpVC = owner.textPopUpFactory.makeView( + guard let textPopupVC = owner.textPopupFactory.makeView( text: (status == 200) ? "ํšŒ์›ํƒˆํ‡ด๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n์ด์šฉํ•ด์ฃผ์…”์„œ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค." : description, cancelButtonIsHidden: true, confirmButtonText: nil, @@ -179,7 +193,7 @@ final class SettingViewController: BaseReactorViewController { ) as? TextPopupViewController else { return } - owner.showBottomSheet(content: textPopUpVC) + owner.showBottomSheet(content: textPopupVC) } .disposed(by: disposeBag) } @@ -191,6 +205,9 @@ final class SettingViewController: BaseReactorViewController { .disposed(by: disposeBag) settingView.rx.withDrawButtonDidTap + .do(onNext: { + LogManager.analytics(SettingAnalyticsLog.clickWithdrawButton) + }) .map { Reactor.Action.withDrawButtonDidTap } .bind(to: reactor.action) .disposed(by: disposeBag) @@ -211,33 +228,82 @@ extension SettingViewController: UITableViewDelegate { tableView.deselectRow(at: indexPath, animated: true) guard let cell = tableView.cellForRow(at: indexPath) as? SettingItemTableViewCell else { return } guard let category = cell.category else { return } + switch category { case .appPush: + LogManager.analytics(SettingAnalyticsLog.clickNotificationButton) reactor?.action.onNext(.appPushSettingNavigationDidTap) - case .serviceTerms: + case .playType: + LogManager.analytics(SettingAnalyticsLog.clickSongPlayPlatform) + showPlayTypeTogglePopup() + case .serviceTerms: LogManager.analytics(SettingAnalyticsLog.clickTermsOfServiceButton) reactor?.action.onNext(.serviceTermsNavigationDidTap) case .privacy: + LogManager.analytics(SettingAnalyticsLog.clickPrivacyPolicyButton) reactor?.action.onNext(.privacyNavigationDidTap) case .openSource: + LogManager.analytics(SettingAnalyticsLog.clickOpensourceButton) reactor?.action.onNext(.openSourceNavigationDidTap) case .removeCache: + LogManager.analytics(SettingAnalyticsLog.clickRemoveCacheButton) reactor?.action.onNext(.removeCacheButtonDidTap) case .logout: - let text = "๋กœ๊ทธ์•„์›ƒ ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?" - let vc = textPopUpFactory.makeView( - text: text, - cancelButtonIsHidden: false, - confirmButtonText: "ํ™•์ธ", - cancelButtonText: "์ทจ์†Œ", - completion: { [weak self] in - guard let self else { return } - self.reactor?.action.onNext(.confirmLogoutButtonDidTap) - }, - cancelCompletion: {} - ) - showBottomSheet(content: vc, size: .fixed(234)) + LogManager.analytics(SettingAnalyticsLog.clickLogoutButton) + showLogoutTextPopup() case .versionInfo: + LogManager.analytics(SettingAnalyticsLog.clickVersionButton) reactor?.action.onNext(.versionInfoButtonDidTap) } } + + private func showPlayTypeTogglePopup() { + let togglePopupVC = playTypeTogglePopupFactory.makeView( + completion: { selectedItemString in + switch selectedItemString { + case YoutubePlayType.youtube.display: + PreferenceManager.songPlayPlatformType = .youtube + LogManager.analytics( + SettingAnalyticsLog.completeSelectSongPlayPlatform(platform: YoutubePlayType.youtube.display) + ) + LogManager.setUserProperty( + property: .songPlayPlatform(platform: YoutubePlayType.youtube.display) + ) + case YoutubePlayType.youtubeMusic.display: + PreferenceManager.songPlayPlatformType = .youtubeMusic + LogManager.analytics( + SettingAnalyticsLog.completeSelectSongPlayPlatform( + platform: YoutubePlayType.youtubeMusic.display + ) + ) + LogManager.setUserProperty( + property: .songPlayPlatform(platform: YoutubePlayType.youtubeMusic.display) + ) + default: + break + } + }, + cancelCompletion: {} + ) + togglePopupVC.modalPresentationStyle = .overFullScreen + togglePopupVC.modalTransitionStyle = .crossDissolve + self.present(togglePopupVC, animated: false) + } + + private func showLogoutTextPopup() { + let textPopUpVC = textPopupFactory.makeView( + text: "๋กœ๊ทธ์•„์›ƒ ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + cancelButtonIsHidden: false, + confirmButtonText: "ํ™•์ธ", + cancelButtonText: "์ทจ์†Œ", + completion: { [weak self] in + guard let self else { return } + let log = SettingAnalyticsLog.completeLogout + LogManager.analytics(log) + + self.reactor?.action.onNext(.confirmLogoutButtonDidTap) + }, + cancelCompletion: {} + ) + showBottomSheet(content: textPopUpVC, size: .fixed(234)) + } } diff --git a/Projects/Features/MyInfoFeature/Sources/ViewModels/FaqViewModel.swift b/Projects/Features/MyInfoFeature/Sources/ViewModels/FaqViewModel.swift index 2b656c350..84e330487 100644 --- a/Projects/Features/MyInfoFeature/Sources/ViewModels/FaqViewModel.swift +++ b/Projects/Features/MyInfoFeature/Sources/ViewModels/FaqViewModel.swift @@ -32,6 +32,9 @@ public final class FaqViewModel: ViewModelType { let zip1 = fetchFaqCategoriesUseCase.execute() .catchAndReturn(FaqCategoryEntity(categories: [])) .map { + /* + ํƒญ๋งจ์˜ ๋ฌด์–ธ๊ฐ€ ์ด์ƒํ•จ์œผ๋กœ ์ธํ•ด ์ž„์‹œ๋ฐฉํŽธ์œผ๋กœ ๊ณต๋ฐฑ์„ ์ง์ ‘ ๋„ฃ์–ด์คŒ + */ var result: [String] = [String("์ „์ฒด ")] result += $0.categories.map { $0.count < 6 ? $0 + String(repeating: " ", count: 6 - $0.count) : $0 } diff --git a/Projects/Features/MyInfoFeature/Sources/ViewModels/OpenSourceLicenseViewModel.swift b/Projects/Features/MyInfoFeature/Sources/ViewModels/OpenSourceLicenseViewModel.swift index d017dfd4e..a70d96583 100644 --- a/Projects/Features/MyInfoFeature/Sources/ViewModels/OpenSourceLicenseViewModel.swift +++ b/Projects/Features/MyInfoFeature/Sources/ViewModels/OpenSourceLicenseViewModel.swift @@ -43,6 +43,11 @@ public final class OpenSourceLicenseViewModel { description: "The MIT License\nCopyright ยฉ 2015 Krunoslav Zaher All rights reserved.", link: "https://github.com/ReactiveX/RxSwift.git" ), + OpenSourceLicense( + title: "RxGesture", + description: "The MIT License\nCopyright ยฉ 2016 RxSwiftCommunity.", + link: "https://github.com/RxSwiftCommunity/RxGesture.git" + ), OpenSourceLicense( title: "RxDataSources", description: "The MIT License\nCopyright (c) 2017 RxSwift Community", diff --git a/Projects/Features/MyInfoFeature/Sources/ViewModels/QuestionViewModel.swift b/Projects/Features/MyInfoFeature/Sources/ViewModels/QuestionViewModel.swift index 85ad6443c..8ed3fff35 100644 --- a/Projects/Features/MyInfoFeature/Sources/ViewModels/QuestionViewModel.swift +++ b/Projects/Features/MyInfoFeature/Sources/ViewModels/QuestionViewModel.swift @@ -93,7 +93,7 @@ extension InquiryType { case .weeklyChart: return "์ฃผ๊ฐ„์ฐจํŠธ ์˜์ƒ" case .credit: - return "์ฐธ์—ฌ์ •๋ณด" + return "์ฐธ์—ฌ ์ •๋ณด" case .unknown: return "" } diff --git a/Projects/Features/MyInfoFeature/Sources/ViewModels/RequestViewModel.swift b/Projects/Features/MyInfoFeature/Sources/ViewModels/RequestViewModel.swift deleted file mode 100644 index aedf616d6..000000000 --- a/Projects/Features/MyInfoFeature/Sources/ViewModels/RequestViewModel.swift +++ /dev/null @@ -1,107 +0,0 @@ -//// -//// AfterLoginStorageViewModel.swift -//// StorageFeature -//// -//// Created by yongbeomkwak on 2023/01/26. -//// Copyright ยฉ 2023 yongbeomkwak. All rights reserved. -//// -// -// import AuthDomainInterface -// import BaseDomainInterface -// import BaseFeature -// import Foundation -// import NaverThirdPartyLogin -// import RxRelay -// import RxSwift -// import UserDomainInterface -// import Utility -// -// public final class RequestViewModel: ViewModelType { -// var disposeBag = DisposeBag() -// var withDrawUserInfoUseCase: WithdrawUserInfoUseCase -// private let logoutUseCase: any LogoutUseCase -// let naverLoginInstance = NaverThirdPartyLoginConnection.getSharedInstance() -// -// public struct Input { -// let pressWithdraw: PublishSubject = PublishSubject() -// } -// -// public struct Output { -// let withDrawResult: PublishSubject = PublishSubject() -// } -// -// public init( -// withDrawUserInfoUseCase: WithdrawUserInfoUseCase, -// logoutUseCase: any LogoutUseCase -// ) { -// self.withDrawUserInfoUseCase = withDrawUserInfoUseCase -// self.logoutUseCase = logoutUseCase -// DEBUG_LOG("โœ… \(Self.self) ์ƒ์„ฑ") -// } -// -// public func transform(from input: Input) -> Output { -// let output = Output() -// -// input.pressWithdraw -// .debug("pressWithdraw") -// .flatMap { [weak self] _ -> Observable in -// guard let self else { return Observable.empty() } -// return withDrawUserInfoUseCase.execute() -// .andThen( -// .concat( -// handleThirdPartyWithDraw(), -// logout() -// ) -// ) -// .catch { error in -// Observable.create { observable in -// observable.onNext(BaseEntity( -// status: 0, -// description: error.asWMError.errorDescription ?? "" -// )) -// observable.onCompleted() -// return Disposables.create{} -// } -// } -// } -// .bind(to: output.withDrawResult) -// .disposed(by: disposeBag) -// -// return output -// } -// } -// -// private extension RequestViewModel { -// func handleThirdPartyWithDraw() -> Observable { -// let platform = Utility.PreferenceManager.userInfo?.platform -// if platform == "naver" { -// naverLoginInstance?.requestDeleteToken() -// } -// return .empty() -// } -// -// func logout() -> Observable { -// logoutUseCase.execute() -// .andThen( -// Observable.create { observable in -// observable.onNext(BaseEntity( -// status: 200, -// description: "ํšŒ์›ํƒˆํ‡ด๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n์ด์šฉํ•ด์ฃผ์…”์„œ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค." -// )) -// observable.onCompleted() -// return Disposables.create{} -// } -// ) -// .catch { error in -// Observable.create { observable in -// observable.onNext(BaseEntity( -// status: 0, -// description: error.asWMError.errorDescription ?? "" -// )) -// observable.onCompleted() -// return Disposables.create{} -// } -// } -// } -// -// } diff --git a/Projects/Features/MyInfoFeature/Sources/Views/FruitDrawButtonView.swift b/Projects/Features/MyInfoFeature/Sources/Views/FruitDrawButtonView.swift index 107be8174..83002867f 100644 --- a/Projects/Features/MyInfoFeature/Sources/Views/FruitDrawButtonView.swift +++ b/Projects/Features/MyInfoFeature/Sources/Views/FruitDrawButtonView.swift @@ -10,14 +10,15 @@ private protocol FruitDrawStateProtocol { } private protocol FruitDrawActionProtocol { + var fruitStorageButtonDidTap: Observable { get } var drawButtonDidTap: Observable { get } } final class FruitDrawButtonView: UIView { let backgroundView = UIView().then { - $0.backgroundColor = .white + $0.backgroundColor = .white.withAlphaComponent(0.4) $0.layer.borderWidth = 1 - $0.layer.borderColor = DesignSystemAsset.BlueGrayColor.blueGray200.color.cgColor + $0.layer.borderColor = UIColor(hex: "ECEFF3").cgColor $0.layer.cornerRadius = 8 $0.clipsToBounds = true } @@ -40,6 +41,8 @@ final class FruitDrawButtonView: UIView { kernValue: -0.5 ) + let fruitStorageButton = UIButton() + let drawButton = UIButton().then { $0.titleLabel?.font = DesignSystemFontFamily.Pretendard.medium.font(size: 14) $0.setTitle("๋ฝ‘๊ธฐ", for: .normal) @@ -66,6 +69,7 @@ extension FruitDrawButtonView { backgroundView, titleLabel, countLabel, + fruitStorageButton, drawButton ) } @@ -85,6 +89,12 @@ extension FruitDrawButtonView { $0.left.equalTo(titleLabel.snp.right).offset(8) } + fruitStorageButton.snp.makeConstraints { + $0.verticalEdges.equalTo(backgroundView.snp.verticalEdges) + $0.leading.equalTo(backgroundView.snp.leading) + $0.trailing.equalTo(drawButton.snp.leading) + } + drawButton.snp.makeConstraints { $0.verticalEdges.equalTo(backgroundView.snp.verticalEdges) $0.trailing.equalTo(backgroundView.snp.trailing) @@ -100,5 +110,6 @@ extension FruitDrawButtonView: FruitDrawStateProtocol { } extension Reactive: FruitDrawActionProtocol where Base: FruitDrawButtonView { + var fruitStorageButtonDidTap: Observable { base.fruitStorageButton.rx.tap.asObservable() } var drawButtonDidTap: Observable { base.drawButton.rx.tap.asObservable() } } diff --git a/Projects/Features/MyInfoFeature/Sources/Views/MyInfoView.swift b/Projects/Features/MyInfoFeature/Sources/Views/MyInfoView.swift index beba24a79..894c8874d 100644 --- a/Projects/Features/MyInfoFeature/Sources/Views/MyInfoView.swift +++ b/Projects/Features/MyInfoFeature/Sources/Views/MyInfoView.swift @@ -17,6 +17,7 @@ private protocol MyInfoActionProtocol { var scrollViewDidTap: Observable { get } var loginButtonDidTap: Observable { get } var profileImageDidTap: Observable { get } + var fruitStorageButtonDidTap: Observable { get } var drawButtonDidTap: Observable { get } var fruitNavigationButtonDidTap: Observable { get } var qnaNavigationButtonDidTap: Observable { get } @@ -204,6 +205,7 @@ extension Reactive: MyInfoActionProtocol where Base: MyInfoView { var loginButtonDidTap: Observable { base.loginWarningView.rx.loginButtonDidTap } var profileImageDidTap: Observable { base.profileView.rx.profileImageDidTap } + var fruitStorageButtonDidTap: Observable { base.fruitDrawButtonView.rx.fruitStorageButtonDidTap } var drawButtonDidTap: Observable { base.fruitDrawButtonView.rx.drawButtonDidTap } var fruitNavigationButtonDidTap: Observable { base.fruitNavigationButton.rx.tap.asObservable() } var qnaNavigationButtonDidTap: Observable { base.qnaNavigationButton.rx.tap.asObservable() } diff --git a/Projects/Features/MyInfoFeature/Sources/Views/PlayTypeTogglePopupItemButton.swift b/Projects/Features/MyInfoFeature/Sources/Views/PlayTypeTogglePopupItemButton.swift new file mode 100644 index 000000000..02423ee13 --- /dev/null +++ b/Projects/Features/MyInfoFeature/Sources/Views/PlayTypeTogglePopupItemButton.swift @@ -0,0 +1,210 @@ +import DesignSystem +import SnapKit +import Then +import UIKit + +protocol PlayTypeTogglePopupItemButtonViewDelegate: AnyObject { + func tappedButtonAction(title: String) +} + +final class PlayTypeTogglePopupItemButtonView: UIView { + private let baseView = UIView().then { + $0.layer.cornerRadius = 12 + $0.layer.borderColor = DesignSystemAsset.BlueGrayColor.gray200.color.cgColor + $0.layer.borderWidth = 1 + $0.backgroundColor = .white + $0.layer.addShadow( + color: UIColor(hex: "#080F34"), + alpha: 0.08, + x: 0, + y: 2, + blur: 4, + spread: 0 + ) + } + + private let titleLabel = WMLabel( + text: "", + textColor: DesignSystemAsset.BlueGrayColor.blueGray900.color, + font: .t5(weight: .light), + alignment: .left + ) + + private let imageView = UIImageView().then { + $0.image = DesignSystemAsset.Storage.checkBox.image + $0.contentMode = .scaleAspectFit + } + + private let installButton = InstallButton().then { + $0.isHidden = true + } + + private let button = UIButton() + + private weak var delegate: PlayTypeTogglePopupItemButtonViewDelegate? + + private var shouldCheckAppIsInstalled: Bool = false + + var isSelected: Bool = false { + didSet { + didChangedSelection(isSelected) + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + addViews() + setLayout() + setActions() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setDelegate(_ delegate: PlayTypeTogglePopupItemButtonViewDelegate) { + self.delegate = delegate + } + + func setTitleWithOption( + title: String, + shouldCheckAppIsInstalled: Bool = false + ) { + self.titleLabel.text = title + self.shouldCheckAppIsInstalled = shouldCheckAppIsInstalled + if shouldCheckAppIsInstalled { + checkAppIsInstalled() + } + } + + @discardableResult + func checkAppIsInstalled() -> Bool { + let isInstalled: Bool + if let url = URL(string: "youtubemusic://"), UIApplication.shared.canOpenURL(url) { + isInstalled = true + } else { + isInstalled = false + } + installButton.isHidden = isInstalled + button.isEnabled = isInstalled + + return isInstalled + } +} + +private extension PlayTypeTogglePopupItemButtonView { + func didChangedSelection(_ isSelected: Bool) { + UIView.animate(withDuration: 0.2) { [weak self] in + guard let self else { return } + + self.baseView.layer.borderColor = isSelected ? + DesignSystemAsset.PrimaryColorV2.decrease.color.cgColor : + DesignSystemAsset.BlueGrayColor.blueGray200.color.withAlphaComponent(0.4).cgColor + + self.baseView.layer.shadowOpacity = isSelected ? 0.08 : 0 + } + + self.imageView.image = isSelected ? + DesignSystemAsset.Storage.checkBox.image : nil + + let font = isSelected ? + UIFont.WMFontSystem.t5(weight: .medium) : + UIFont.WMFontSystem.t5(weight: .light) + self.titleLabel.setFont(font) + self.titleLabel.textColor = isSelected ? + DesignSystemAsset.PrimaryColorV2.decrease.color : + DesignSystemAsset.BlueGrayColor.blueGray900.color + } + + func setActions() { + let buttonAction = UIAction { [weak self] _ in + guard let self = self else { return } + self.delegate?.tappedButtonAction(title: titleLabel.text ?? "") + } + button.addAction(buttonAction, for: .touchUpInside) + + installButton.addAction { + let youtubeMusicAppStoreURL = "itms-apps://apps.apple.com/app/id1017492454" + if let url = URL(string: youtubeMusicAppStoreURL) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + } + + func addViews() { + addSubview(baseView) + addSubview(titleLabel) + addSubview(imageView) + addSubview(button) + addSubview(installButton) + } + + func setLayout() { + baseView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().inset(16) + $0.trailing.equalTo(imageView.snp.leading).offset(-20) + } + + imageView.snp.makeConstraints { + $0.size.equalTo(24) + $0.trailing.equalToSuperview().inset(20) + $0.centerY.equalToSuperview() + } + + button.snp.makeConstraints { + $0.edges.equalTo(baseView) + } + + installButton.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } +} + +private extension PlayTypeTogglePopupItemButtonView { + class InstallButton: UIButton { + private let messageLabel = WMLabel( + text: "๋ฏธ์„ค์น˜", + textColor: DesignSystemAsset.BlueGrayColor.gray400.color, + font: .t7(weight: .bold), + alignment: .center + ).then { + $0.layer.cornerRadius = 4 + $0.layer.borderWidth = 1 + $0.layer.borderColor = DesignSystemAsset.BlueGrayColor.gray300.color.cgColor + $0.backgroundColor = .white + $0.clipsToBounds = true + } + + init() { + super.init(frame: .zero) + addViews() + setLayout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func addViews() { + addSubviews(messageLabel) + } + + func setLayout() { + messageLabel.snp.makeConstraints { + $0.width.equalTo(55) + $0.height.equalTo(24) + $0.trailing.equalToSuperview().inset(20) + $0.centerY.equalToSuperview() + } + } + } +} diff --git a/Projects/Features/MyInfoFeature/Sources/Views/ProfileView.swift b/Projects/Features/MyInfoFeature/Sources/Views/ProfileView.swift index a72b675cd..8a27a11af 100644 --- a/Projects/Features/MyInfoFeature/Sources/Views/ProfileView.swift +++ b/Projects/Features/MyInfoFeature/Sources/Views/ProfileView.swift @@ -1,6 +1,7 @@ import DesignSystem import Kingfisher import RxCocoa +import RxGesture import RxSwift import SnapKit import Then @@ -22,6 +23,7 @@ final class ProfileView: UIView { $0.contentMode = .scaleAspectFill $0.layer.cornerRadius = 92 / 2.0 $0.clipsToBounds = true + $0.isUserInteractionEnabled = true } private let nameLabel = WMLabel( @@ -48,10 +50,15 @@ final class ProfileView: UIView { $0.numberOfLines = .zero } + fileprivate let didTapProfileImageSubject = PublishSubject() + private let scaleDownTransform = CGAffineTransform(scaleX: 0.9, y: 0.9) + private let disposeBag = DisposeBag() + init() { super.init(frame: .zero) addView() setLayout() + registerGesture() } @available(*, unavailable) @@ -77,7 +84,7 @@ final class ProfileView: UIView { } } -extension ProfileView { +private extension ProfileView { func addView() { self.addSubview(imageView) self.addSubview(nameLabel) @@ -103,6 +110,31 @@ extension ProfileView { $0.centerX.equalToSuperview() } } + + func registerGesture() { + imageView.rx.longPressGesture(configuration: { gestureRecognizer, delegate in + gestureRecognizer.minimumPressDuration = 0.0 + delegate.selfFailureRequirementPolicy = .always + }) + .bind(with: self) { owner, gesture in + switch gesture.state { + case .began: + UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) { + owner.imageView.transform = owner.scaleDownTransform + } + .startAnimation() + case .ended, .cancelled: + let animator = UIViewPropertyAnimator(duration: 0.3, dampingRatio: 0.5, animations: { + owner.imageView.transform = .identity + }) + animator.startAnimation() + owner.didTapProfileImageSubject.onNext(()) + default: + break + } + } + .disposed(by: disposeBag) + } } extension ProfileView: ProfileStateProtocol { @@ -140,9 +172,7 @@ extension ProfileView: ProfileStateProtocol { extension Reactive: ProfileActionProtocol where Base: ProfileView { var profileImageDidTap: Observable { - let tapGestureRecognizer = UITapGestureRecognizer() - base.imageView.addGestureRecognizer(tapGestureRecognizer) - base.imageView.isUserInteractionEnabled = true - return tapGestureRecognizer.rx.event.map { _ in }.asObservable() + return base.didTapProfileImageSubject.asObserver() + .throttle(.milliseconds(500), latest: false, scheduler: MainScheduler.asyncInstance) } } diff --git a/Projects/Features/MyInfoFeature/Sources/Views/SettingItemTableViewCell.swift b/Projects/Features/MyInfoFeature/Sources/Views/SettingItemTableViewCell.swift index 43904df01..d35b6a63b 100644 --- a/Projects/Features/MyInfoFeature/Sources/Views/SettingItemTableViewCell.swift +++ b/Projects/Features/MyInfoFeature/Sources/Views/SettingItemTableViewCell.swift @@ -72,8 +72,15 @@ private extension SettingItemTableViewCell { switch type { case let .navigate(category): let pushNotificationAuthorizationStatus = PreferenceManager.pushNotificationAuthorizationStatus ?? false - self.subTitleLabel.text = (category == .appPush) ? - pushNotificationAuthorizationStatus ? "์ผœ์ง" : "๊บผ์ง" : "" + let playType = PreferenceManager.songPlayPlatformType ?? .youtube + switch category { + case .appPush: + self.subTitleLabel.text = pushNotificationAuthorizationStatus ? "์ผœ์ง" : "๊บผ์ง" + case .playType: + self.subTitleLabel.text = playType.display + default: + self.subTitleLabel.text = "" + } case let .description(category): self.subTitleLabel.text = "" } @@ -84,7 +91,7 @@ private extension SettingItemTableViewCell { case let .navigate(category): rightImageView.isHidden = false rightLabel.isHidden = true - subTitleLabel.isHidden = category != .appPush + subTitleLabel.isHidden = !(category == .appPush || category == .playType) case .description: rightImageView.isHidden = true rightLabel.isHidden = false diff --git a/Projects/Features/MyInfoFeature/Sources/Views/WithDrawLabel.swift b/Projects/Features/MyInfoFeature/Sources/Views/WithDrawLabel.swift index eccf1e0c6..1f07a648b 100644 --- a/Projects/Features/MyInfoFeature/Sources/Views/WithDrawLabel.swift +++ b/Projects/Features/MyInfoFeature/Sources/Views/WithDrawLabel.swift @@ -1,5 +1,6 @@ import DesignSystem import RxCocoa +import RxGesture import RxSwift import UIKit @@ -8,23 +9,19 @@ private protocol WithDrawLabelActionProtocol { } final class WithDrawLabel: UILabel { - private let target = "์—ฌ๊ธฐ" + private let targetText: String + fileprivate let didTapSubject = PublishSubject() + private let disposeBag = DisposeBag() - init(_ text: String = "ํšŒ์› ํƒˆํ‡ด๋ฅผ ์›ํ•˜์‹ ๋‹ค๋ฉด ์—ฌ๊ธฐ๋ฅผ ๋ˆŒ๋Ÿฌ์ฃผ์„ธ์š”.") { + init(text: String = "ํšŒ์› ํƒˆํ‡ด๋ฅผ ์›ํ•˜์‹ ๋‹ค๋ฉด ์—ฌ๊ธฐ๋ฅผ ๋ˆŒ๋Ÿฌ์ฃผ์„ธ์š”.", targetText: String = "์—ฌ๊ธฐ") { + self.targetText = targetText super.init(frame: .zero) self.text = text - configure() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - } - func configure() { guard let text = self.text else { return } let attrStr = NSMutableAttributedString(string: text) let fullRange = NSRange(location: 0, length: attrStr.length) - let targetRange = (text as NSString).range(of: target) + let targetRange = (text as NSString).range(of: targetText) let color = DesignSystemAsset.BlueGrayColor.blueGray500.color let font = UIFont.setFont(.t7(weight: .light)) attrStr.addAttribute(.foregroundColor, value: color, range: fullRange) @@ -33,14 +30,51 @@ final class WithDrawLabel: UILabel { attrStr.addAttribute(.underlineStyle, value: 1, range: targetRange) self.attributedText = attrStr + registerGesture() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func registerGesture() { + self.isUserInteractionEnabled = true + self.rx.tapGesture() + .when(.recognized) + .filter { [weak self] gesture in + guard let self = self else { return false } + let location = gesture.location(in: self) + return self.isTargetTapped(at: location) + } + .map { _ in () } + .bind(to: didTapSubject) + .disposed(by: disposeBag) + } + + private func isTargetTapped(at point: CGPoint) -> Bool { + guard let attributedText = attributedText else { return false } + + let textStorage = NSTextStorage(attributedString: attributedText) + let layoutManager = NSLayoutManager() + textStorage.addLayoutManager(layoutManager) + + let textContainer = NSTextContainer(size: bounds.size) + textContainer.lineFragmentPadding = 0 + layoutManager.addTextContainer(textContainer) + + let index = layoutManager.characterIndex( + for: point, + in: textContainer, + fractionOfDistanceBetweenInsertionPoints: nil + ) + let targetRange = (text as NSString?)?.range(of: targetText) ?? NSRange(location: 0, length: 0) + return NSLocationInRange(index, targetRange) } } -extension Reactive: WithDrawLabelActionProtocol where Base: WithDrawLabel { +extension Reactive where Base: WithDrawLabel { var didTap: Observable { - let tapGestureRecognizer = UITapGestureRecognizer() - base.addGestureRecognizer(tapGestureRecognizer) - base.isUserInteractionEnabled = true - return tapGestureRecognizer.rx.event.map { _ in }.asObservable() + return base.didTapSubject.asObservable() } } diff --git a/Projects/Features/MyInfoFeature/Testing/QuestionComponentStub.swift b/Projects/Features/MyInfoFeature/Testing/QuestionComponentStub.swift index 164681bb3..808b849d2 100644 --- a/Projects/Features/MyInfoFeature/Testing/QuestionComponentStub.swift +++ b/Projects/Features/MyInfoFeature/Testing/QuestionComponentStub.swift @@ -8,7 +8,7 @@ public final class QuestionComponentStub: QuestionFactory { public func makeView() -> UIViewController { return QuestionViewController.viewController( viewModel: .init(), - textPopUpFactory: TextPopUpComponentStub() + textPopupFactory: TextPopupComponentStub() ) } } diff --git a/Projects/Features/MyInfoFeature/Testing/SettingComponentStub.swift b/Projects/Features/MyInfoFeature/Testing/SettingComponentStub.swift index 6d5ab1233..ea6cd31d8 100644 --- a/Projects/Features/MyInfoFeature/Testing/SettingComponentStub.swift +++ b/Projects/Features/MyInfoFeature/Testing/SettingComponentStub.swift @@ -19,11 +19,24 @@ public final class SettingComponentStub: SettingFactory { logoutUseCase: LogoutUseCaseSpy(), updateNotificationTokenUseCase: UpdateNotificationTokenUseCaseSpy() ), - textPopUpFactory: TextPopUpComponentStub(), + textPopupFactory: TextPopupComponentStub(), signInFactory: SignInComponentStub(), serviceTermsFactory: ServiceTermComponentStub(), privacyFactory: PrivacyComponentStub(), - openSourceLicenseFactory: OpenSourceLicenseComponentStub() + openSourceLicenseFactory: OpenSourceLicenseComponentStub(), + playTypeTogglePopupFactory: PlayTypeTogglePopupComponentStub() + ) + } +} + +final class PlayTypeTogglePopupComponentStub: PlayTypeTogglePopupFactory { + public func makeView( + completion: ((_ selectedItemString: String) -> Void)? = nil, + cancelCompletion: (() -> Void)? = nil + ) -> UIViewController { + return PlayTypeTogglePopupViewController( + completion: completion, + cancelCompletion: cancelCompletion ) } } diff --git a/Projects/Features/PlaylistFeature/Interface/PlaylistDetailNavigator.swift b/Projects/Features/PlaylistFeature/Interface/PlaylistDetailNavigator.swift index 098e9506d..6e50ad01e 100644 --- a/Projects/Features/PlaylistFeature/Interface/PlaylistDetailNavigator.swift +++ b/Projects/Features/PlaylistFeature/Interface/PlaylistDetailNavigator.swift @@ -4,13 +4,13 @@ import UIKit public protocol PlaylistDetailNavigator { var playlistDetailFactory: any PlaylistDetailFactory { get } - func navigateWmPlaylistDetail(key: String) + func navigateWMPlaylistDetail(key: String) func navigatePlaylistDetail(key: String) } public extension PlaylistDetailNavigator where Self: UIViewController { - func navigateWmPlaylistDetail(key: String) { + func navigateWMPlaylistDetail(key: String) { let dest = playlistDetailFactory.makeWmView(key: key) self.navigationController?.pushViewController(dest, animated: true) diff --git a/Projects/Features/PlaylistFeature/Interface/PlaylistFactory.swift b/Projects/Features/PlaylistFeature/Interface/PlaylistFactory.swift index 54735d577..d6f220472 100644 --- a/Projects/Features/PlaylistFeature/Interface/PlaylistFactory.swift +++ b/Projects/Features/PlaylistFeature/Interface/PlaylistFactory.swift @@ -2,4 +2,5 @@ import UIKit public protocol PlaylistFactory { func makeViewController() -> UIViewController + func makeViewController(currentSongID: String) -> UIViewController } diff --git a/Projects/Features/PlaylistFeature/Sources/Components/CheckPlaylistCoverComponent.swift b/Projects/Features/PlaylistFeature/Sources/Components/CheckPlaylistCoverComponent.swift index 2088fe87b..36d49be6f 100644 --- a/Projects/Features/PlaylistFeature/Sources/Components/CheckPlaylistCoverComponent.swift +++ b/Projects/Features/PlaylistFeature/Sources/Components/CheckPlaylistCoverComponent.swift @@ -5,7 +5,7 @@ import PlaylistFeatureInterface import UIKit public protocol CheckPlaylistCoverDependency: Dependency { - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } } public final class CheckPlaylistCoverComponent: Component, CheckPlaylistCoverFactory { @@ -14,7 +14,7 @@ public final class CheckPlaylistCoverComponent: Component, MyPlaylistDetailFactory { @@ -36,9 +36,9 @@ public final class MyPlaylistDetailComponent: Component, PlaylistFac public func makeViewController() -> UIViewController { let viewModel = PlaylistViewModel() let viewController = PlaylistViewController( + currentSongID: nil, viewModel: viewModel, containSongsFactory: dependency.containSongsFactory, songDetailPresenter: dependency.songDetailPresenter, - textPopUpFactory: dependency.textPopUpFactory, + textPopupFactory: dependency.textPopupFactory, + signInFactory: dependency.signInFactory + ) + return viewController + } + + public func makeViewController(currentSongID: String) -> UIViewController { + let viewModel = PlaylistViewModel() + let viewController = PlaylistViewController( + currentSongID: currentSongID, + viewModel: viewModel, + containSongsFactory: dependency.containSongsFactory, + songDetailPresenter: dependency.songDetailPresenter, + textPopupFactory: dependency.textPopupFactory, signInFactory: dependency.signInFactory ) return viewController diff --git a/Projects/Features/PlaylistFeature/Sources/Components/PlaylistDetailComponent.swift b/Projects/Features/PlaylistFeature/Sources/Components/PlaylistDetailComponent.swift index 21c5da394..6d9503ad4 100644 --- a/Projects/Features/PlaylistFeature/Sources/Components/PlaylistDetailComponent.swift +++ b/Projects/Features/PlaylistFeature/Sources/Components/PlaylistDetailComponent.swift @@ -1,3 +1,4 @@ +import BaseFeatureInterface import NeedleFoundation import PlaylistDomainInterface import PlaylistFeatureInterface @@ -8,6 +9,7 @@ public protocol PlaylistDetailFactoryDependency: Dependency { var unknownPlaylistDetailFactory: any UnknownPlaylistDetailFactory { get } var wakmusicPlaylistDetailFactory: any WakmusicPlaylistDetailFactory { get } var requestPlaylistOwnerIDUsecase: any RequestPlaylistOwnerIDUsecase { get } + var textPopupFactory: any TextPopupFactory { get } } public final class PlaylistDetailComponent: Component, PlaylistDetailFactory { @@ -21,7 +23,8 @@ public final class PlaylistDetailComponent: Component] = [] + var mutations: [Observable] = [ + updateComplectionButtonVisible(flag: false), + updateIsSecondaryLoading(flag: true) + ] if let imageData = state.imageData { switch imageData { case let .default(imageName): mutations.append( uploadDefaultPlaylistImageUseCase.execute(key: self.key, model: imageName) - .andThen(.empty()) + .andThen(.concat([ + postNotification(notiName: .shouldRefreshPlaylist) + ])) // ํ”Œ๋ฆฌ ์ด๋ฏธ์ง€ ๊ฐฑ์‹  .catch { error in let wmErorr = error.asWMError return Observable.just( @@ -292,6 +315,7 @@ private extension MyPlaylistDetailReactor { ) } + mutations.append(updateIsSecondaryLoading(flag: false)) return .concat(mutations) } @@ -323,7 +347,8 @@ private extension MyPlaylistDetailReactor { return updateTitleAndPrivateUseCase.execute(key: key, title: nil, isPrivate: prev.private) .andThen(.concat([ .just(.updateHeader(prev)), - .just(.showToast(message)) + .just(.showToast(message)), + .just(.postNotification(.shouldRefreshPlaylist)) ])) .catch { error in @@ -356,7 +381,9 @@ private extension MyPlaylistDetailReactor { return .concat([ .just(.updateEditingState(true)), - .just(.updateBackUpPlaylist(currentPlaylists)) + .just(.updateBackUpPlaylist(currentPlaylists)), + updateComplectionButtonVisible(flag: true), + updateShowEditSheet(flag: false), ]) } @@ -398,6 +425,7 @@ private extension MyPlaylistDetailReactor { let backUpPlaylist = state.backupPlaylistModels return .concat([ + updateComplectionButtonVisible(flag: false), .just(Mutation.updateEditingState(false)), .just(Mutation.updatePlaylist(backUpPlaylist)), .just(.updateSelectedCount(0)) @@ -468,4 +496,23 @@ private extension MyPlaylistDetailReactor { func postNotification(notiName: Notification.Name) -> Observable { .just(.postNotification(notiName)) } + + func updateShareLink() -> Observable { + return .concat([ + .just(.showShareLink(deepLinkGenerator.generatePlaylistDeepLink(key: key))), + updateShowEditSheet(flag: false) + ]) + } + + func updateShowEditSheet(flag: Bool) -> Observable { + return .just(.updateShowEditSheet(flag)) + } + + func updateComplectionButtonVisible(flag: Bool) -> Observable { + return .just(.updateCompletionButtonVisible(flag)) + } + + func updateIsSecondaryLoading(flag: Bool) -> Observable { + return .just(.updateIsSecondaryLoading(flag)) + } } diff --git a/Projects/Features/PlaylistFeature/Sources/Reactors/PlaylistDetailContainerReactor.swift b/Projects/Features/PlaylistFeature/Sources/Reactors/PlaylistDetailContainerReactor.swift index d79708ee8..b4da71fa0 100644 --- a/Projects/Features/PlaylistFeature/Sources/Reactors/PlaylistDetailContainerReactor.swift +++ b/Projects/Features/PlaylistFeature/Sources/Reactors/PlaylistDetailContainerReactor.swift @@ -13,12 +13,14 @@ final class PlaylistDetailContainerReactor: Reactor { case updateOwnerID(String?) case updateLoadingState(Bool) case showToastMessagae(String) + case updateDetectedNotFound } struct State { var isLoading: Bool var ownerID: String? @Pulse var toastMessgae: String? + @Pulse var detectedNotFound: Void? } private let requestPlaylistOwnerIDUsecase: any RequestPlaylistOwnerIDUsecase @@ -50,6 +52,8 @@ final class PlaylistDetailContainerReactor: Reactor { newState.toastMessgae = message case let .updateLoadingState(flag): newState.isLoading = flag + case .updateDetectedNotFound: + newState.detectedNotFound = () } return newState @@ -74,6 +78,11 @@ extension PlaylistDetailContainerReactor { } .catch { error in let wmError = error.asWMError + + if wmError == .notFound { + return self.updateDetectedNotFound() + } + return .just(Mutation.showToastMessagae(wmError.localizedDescription)) } } @@ -88,4 +97,8 @@ extension PlaylistDetailContainerReactor { Observable.just(.updateLoadingState(false)) ]) } + + func updateDetectedNotFound() -> Observable { + .just(.updateDetectedNotFound) + } } diff --git a/Projects/Features/PlaylistFeature/Sources/Reactors/UnknownPlaylistDetailReactor.swift b/Projects/Features/PlaylistFeature/Sources/Reactors/UnknownPlaylistDetailReactor.swift index ad5c492d3..9a071ee81 100644 --- a/Projects/Features/PlaylistFeature/Sources/Reactors/UnknownPlaylistDetailReactor.swift +++ b/Projects/Features/PlaylistFeature/Sources/Reactors/UnknownPlaylistDetailReactor.swift @@ -1,6 +1,7 @@ import AuthDomainInterface import Foundation import Localization +import LogManager import PlaylistDomainInterface import ReactorKit import RxSwift @@ -16,7 +17,11 @@ final class UnknownPlaylistDetailReactor: Reactor { case deselectAll case itemDidTap(Int) case subscriptionButtonDidTap - case requestLoginRequiredAction + case requestLoginRequiredAction(source: LoginRequiredActionSource) + + enum LoginRequiredActionSource { + case addMusics + } } enum Mutation { @@ -27,9 +32,8 @@ final class UnknownPlaylistDetailReactor: Reactor { case updateSelectingStateByIndex([SongEntity]) case updateSubscribeState(Bool) case showToast(String) - case updateLoginPopupState(Bool) + case updateLoginPopupState((Bool, CommonAnalyticsLog.LoginButtonEntry?)) case updateRefresh - case updateDetectedNotFound } struct State { @@ -39,7 +43,7 @@ final class UnknownPlaylistDetailReactor: Reactor { var selectedCount: Int var isSubscribing: Bool @Pulse var toastMessage: String? - @Pulse var showLoginPopup: Bool + @Pulse var showLoginPopup: (Bool, CommonAnalyticsLog.LoginButtonEntry?) @Pulse var refresh: Void? @Pulse var detectedNotFound: Void? } @@ -79,7 +83,7 @@ final class UnknownPlaylistDetailReactor: Reactor { isLoading: true, selectedCount: 0, isSubscribing: false, - showLoginPopup: false + showLoginPopup: (false, nil) ) } @@ -100,8 +104,12 @@ final class UnknownPlaylistDetailReactor: Reactor { case let .itemDidTap(index): return updateItemSelected(index) - case .requestLoginRequiredAction: - return .just(.updateLoginPopupState(true)) + case let .requestLoginRequiredAction(source): + switch source { + case .addMusics: + return .just(.updateLoginPopupState((true, .addMusics))) + } + return .just(.updateLoginPopupState((true, nil))) } } @@ -134,9 +142,6 @@ final class UnknownPlaylistDetailReactor: Reactor { case .updateRefresh: newState.refresh = () - - case .updateDetectedNotFound: - newState.detectedNotFound = () } return newState @@ -185,13 +190,10 @@ private extension UnknownPlaylistDetailReactor { guard let self else { return .empty() } - let wmErorr = error.asWMError + let wmError = error.asWMError - if wmErorr == .notFound { - return self.updateDetectedNotFound() - } return Observable.just( - Mutation.showToast(wmErorr.errorDescription ?? LocalizationStrings.unknownErrorWarning) + Mutation.showToast(wmError.errorDescription ?? LocalizationStrings.unknownErrorWarning) ) }, .just(.updateLoadingState(false)) @@ -267,7 +269,7 @@ private extension UnknownPlaylistDetailReactor { let prev = currentState.isSubscribing if PreferenceManager.userInfo == nil { - return .just(.updateLoginPopupState(true)) + return .just(.updateLoginPopupState((true, .playlistSubscribe))) } else { return subscribePlaylistUseCase.execute(key: key, isSubscribing: prev) .andThen( @@ -291,8 +293,4 @@ private extension UnknownPlaylistDetailReactor { func updateSendRefreshNoti() -> Observable { .just(.updateRefresh) } - - func updateDetectedNotFound() -> Observable { - .just(.updateDetectedNotFound) - } } diff --git a/Projects/Features/PlaylistFeature/Sources/Reactors/WakmusicPlaylistDetailReactor.swift b/Projects/Features/PlaylistFeature/Sources/Reactors/WakmusicPlaylistDetailReactor.swift index 1443b7420..77742e99d 100644 --- a/Projects/Features/PlaylistFeature/Sources/Reactors/WakmusicPlaylistDetailReactor.swift +++ b/Projects/Features/PlaylistFeature/Sources/Reactors/WakmusicPlaylistDetailReactor.swift @@ -28,6 +28,7 @@ final class WakmusicPlaylistDetailReactor: Reactor { case updateSelectingStateByIndex([SongEntity]) case showToast(String) case updateLoginPopupState(Bool) + case updatePlaylistURL(String) } struct State { @@ -35,20 +36,21 @@ final class WakmusicPlaylistDetailReactor: Reactor { var dataSource: [SongEntity] var isLoading: Bool var selectedCount: Int + var playlistURL: String? @Pulse var toastMessage: String? @Pulse var showLoginPopup: Bool } var initialState: State - private let fetchPlaylistDetailUseCase: any FetchPlaylistDetailUseCase + private let fetchWMPlaylistDetailUseCase: any FetchWMPlaylistDetailUseCase init( key: String, - fetchPlaylistDetailUseCase: any FetchPlaylistDetailUseCase + fetchWMPlaylistDetailUseCase: any FetchWMPlaylistDetailUseCase ) { self.key = key - self.fetchPlaylistDetailUseCase = fetchPlaylistDetailUseCase + self.fetchWMPlaylistDetailUseCase = fetchWMPlaylistDetailUseCase self.initialState = State( header: PlaylistDetailHeaderModel( @@ -106,6 +108,9 @@ final class WakmusicPlaylistDetailReactor: Reactor { newState.dataSource = dataSource case let .updateLoginPopupState(flag): newState.showLoginPopup = flag + + case let .updatePlaylistURL(URL): + newState.playlistURL = URL } return newState @@ -116,7 +121,7 @@ private extension WakmusicPlaylistDetailReactor { func updateDataSource() -> Observable { return .concat([ .just(.updateLoadingState(true)), - fetchPlaylistDetailUseCase.execute(id: key, type: .wmRecommend) + fetchWMPlaylistDetailUseCase.execute(id: key) .asObservable() .flatMap { data -> Observable in return .concat([ @@ -125,11 +130,12 @@ private extension WakmusicPlaylistDetailReactor { key: data.key, title: data.title, image: data.image, - userName: data.userName, - private: data.private, + userName: "Wakmu", + private: false, songCount: data.songs.count ) )), + Observable.just(.updatePlaylistURL(data.playlistURL)), Observable.just(Mutation.updateDataSource(data.songs)) ]) } diff --git a/Projects/Features/PlaylistFeature/Sources/ViewControllers/CheckPlaylistCoverViewController.swift b/Projects/Features/PlaylistFeature/Sources/ViewControllers/CheckPlaylistCoverViewController.swift index 95bc8a66d..65e58094c 100644 --- a/Projects/Features/PlaylistFeature/Sources/ViewControllers/CheckPlaylistCoverViewController.swift +++ b/Projects/Features/PlaylistFeature/Sources/ViewControllers/CheckPlaylistCoverViewController.swift @@ -13,7 +13,7 @@ import Utility final class CheckPlaylistCoverViewController: BaseReactorViewController { weak var delegate: CheckPlaylistCoverDelegate? - private let textPopUpFactory: any TextPopUpFactory + private let textPopupFactory: any TextPopupFactory private var wmNavigationbarView: WMNavigationBarView = WMNavigationBarView().then { $0.setTitle("์•จ๋ฒ”์—์„œ ๊ณ ๋ฅด๊ธฐ") @@ -68,11 +68,11 @@ final class CheckPlaylistCoverViewController: BaseReactorViewController Limit.imageSizeLimitPerMB { - let textPopupVC = self.textPopUpFactory.makeView( + let textPopupVC = self.textPopupFactory.makeView( text: "์‚ฌ์ง„์˜ ์šฉ๋Ÿ‰์€ \(Int(Limit.imageSizeLimitPerMB))MB๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.\n๋‹ค๋ฅธ ์‚ฌ์ง„์„ ์„ ํƒํ•ด ์ฃผ์„ธ์š”.", cancelButtonIsHidden: true, confirmButtonText: nil, @@ -487,28 +544,46 @@ extension MyPlaylistDetailViewController: PlayButtonGroupViewDelegate { let currentState = reactor.currentState var songs = currentState.playlistModels + let playlistName = reactor.currentState.header.title + let title: String + switch event { case .allPlay: + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .playlistDetail, type: .all) + ) LogManager.analytics(PlaylistAnalyticsLog.clickPlaylistPlayButton(type: "all", key: reactor.key)) + title = "\(playlistName) (์ „์ฒด)" case .shufflePlay: + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .playlistDetail, type: .random) + ) LogManager.analytics(PlaylistAnalyticsLog.clickPlaylistPlayButton(type: "random", key: reactor.key)) songs.shuffle() + title = "\(playlistName) (๋žœ๋ค)" } PlayState.shared.append(contentsOf: songs.map { PlaylistItem(id: $0.id, title: $0.title, artist: $0.artist) }) - WakmusicYoutubePlayer(ids: songs.map { $0.id }).play() + + if songs.allSatisfy({ $0.title.isContainShortsTagTitle }) { + WakmusicYoutubePlayer(ids: songs.map { $0.id }, title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง", playPlatform: .youtube).play() + } else { + WakmusicYoutubePlayer(ids: songs.map { $0.id }, title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง").play() + } } } /// ํŽธ์ง‘๋ชจ๋“œ ์‹œ ์…€ ์„ ํƒ ์ด๋ฒคํŠธ extension MyPlaylistDetailViewController: PlaylistTableViewCellDelegate { - func thumbnailDidTap(key: String) { - songDetailPresenter.present(id: key) - } - - func playButtonDidTap(key: String) { - WakmusicYoutubePlayer(id: key).play() + func playButtonDidTap(model: PlaylistItemModel) { + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .playlist, type: .single) + ) + WakmusicYoutubePlayer( + id: model.id, + playPlatform: model.title.isContainShortsTagTitle ? .youtube : .automatic + ).play() } func superButtonTapped(index: Int) { @@ -543,6 +618,9 @@ extension MyPlaylistDetailViewController: SongCartViewDelegate { reactor.action.onNext(.deselectAll) } case .addSong: + let log = CommonAnalyticsLog.clickAddMusicsButton(location: .playlistDetail) + LogManager.analytics(log) + let vc = containSongsFactory .makeView(songs: songs.map(\.id)) vc.modalPresentationStyle = .overFullScreen @@ -564,7 +642,7 @@ extension MyPlaylistDetailViewController: SongCartViewDelegate { break case .remove: - let vc: UIViewController = textPopUpFactory.makeView( + let vc: UIViewController = textPopupFactory.makeView( text: "\(currentState.selectedCount)๊ณก์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", cancelButtonIsHidden: false, confirmButtonText: "ํ™•์ธ", cancelButtonText: "์ทจ์†Œ", @@ -586,14 +664,12 @@ extension MyPlaylistDetailViewController: PlaylistEditSheetDelegate { func didTap(_ type: PlaylistEditType) { switch type { case .edit: - LogManager.analytics(PlaylistAnalyticsLog.clickPlaylistEditButton) + LogManager.analytics(CommonAnalyticsLog.clickEditButton(location: .playlistDetail)) reactor?.action.onNext(.editButtonDidTap) case .share: LogManager.analytics(PlaylistAnalyticsLog.clickPlaylistShareButton) reactor?.action.onNext(.shareButtonDidTap) } - - self.hideplaylistEditSheet() } } diff --git a/Projects/Features/PlaylistFeature/Sources/ViewControllers/PlaylistDetailContainerViewController.swift b/Projects/Features/PlaylistFeature/Sources/ViewControllers/PlaylistDetailContainerViewController.swift index 8ec928367..adb200e4e 100644 --- a/Projects/Features/PlaylistFeature/Sources/ViewControllers/PlaylistDetailContainerViewController.swift +++ b/Projects/Features/PlaylistFeature/Sources/ViewControllers/PlaylistDetailContainerViewController.swift @@ -1,4 +1,5 @@ import BaseFeature +import BaseFeatureInterface import DesignSystem import PlaylistFeatureInterface import RxSwift @@ -22,6 +23,7 @@ final class PlaylistDetailContainerViewController: BaseReactorViewController UIView? { - let playbuttonGroupView = PlayButtonGroupView() - playbuttonGroupView.delegate = self - return playbuttonGroupView + let randomPlayButton = RandomPlayButtonHeaderView(frame: .zero) + randomPlayButton.setPlayButtonHandler { [weak self] in + guard let self else { return } + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .playlist, type: .random) + ) + let songIDs = output.playlists.value + .map(\.id) + .shuffled() + .prefix(50) + if output.playlists.value.allSatisfy({ $0.title.isContainShortsTagTitle }) { + WakmusicYoutubePlayer(ids: Array(songIDs), title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง ์žฌ์ƒ๋ชฉ๋ก (๋žœ๋ค)", playPlatform: .youtube).play() + } else { + WakmusicYoutubePlayer(ids: Array(songIDs), title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง ์žฌ์ƒ๋ชฉ๋ก (๋žœ๋ค)").play() + } + } + return randomPlayButton } public func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { @@ -101,41 +122,15 @@ extension PlaylistViewController: UITableViewDelegate { } } -extension PlaylistViewController: PlayButtonGroupViewDelegate { - public func play(_ event: PlayEvent) { - switch event { - case .allPlay: - let songIDs = output.playlists.value - .map(\.id) - .prefix(50) - WakmusicYoutubePlayer(ids: Array(songIDs)).play() - - case .shufflePlay: - let songIDs = output.playlists.value - .map(\.id) - .shuffled() - .prefix(50) - WakmusicYoutubePlayer(ids: Array(songIDs)).play() - } - } -} - extension PlaylistViewController: PlaylistTableViewCellDelegate { - func thumbnailDidTap(key: String) { - let currentSongs = output.playlists.value - .map(\.id) - .prefix(50) - - self.dismiss(animated: true) { [songDetailPresenter] in - songDetailPresenter.present( - ids: Array(currentSongs), - selectedID: key - ) - } - } - - func playButtonDidTap(key: String) { - WakmusicYoutubePlayer(id: key).play() + func playButtonDidTap(model: PlaylistItemModel) { + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .playlist, type: .single) + ) + WakmusicYoutubePlayer( + id: model.id, + playPlatform: model.title.isContainShortsTagTitle ? .youtube : .automatic + ).play() } func superButtonTapped(index: Int) { diff --git a/Projects/Features/PlaylistFeature/Sources/ViewControllers/PlaylistViewController.swift b/Projects/Features/PlaylistFeature/Sources/ViewControllers/PlaylistViewController.swift index 4211cd0de..2ac0bd8f7 100644 --- a/Projects/Features/PlaylistFeature/Sources/ViewControllers/PlaylistViewController.swift +++ b/Projects/Features/PlaylistFeature/Sources/ViewControllers/PlaylistViewController.swift @@ -4,6 +4,7 @@ import Combine import DesignSystem import Foundation import Kingfisher +import LogManager import RxDataSources import RxRelay import RxSwift @@ -26,7 +27,7 @@ public final class PlaylistViewController: UIViewController, SongCartViewType { private(set) var containSongsFactory: any ContainSongsFactory private(set) var songDetailPresenter: any SongDetailPresentable - private(set) var textPopUpFactory: any TextPopUpFactory + private(set) var textPopupFactory: any TextPopupFactory private(set) var signInFactory: any SignInFactory public var songCartView: BaseFeature.SongCartView! @@ -51,23 +52,27 @@ public final class PlaylistViewController: UIViewController, SongCartViewType { ) lazy var output = self.viewModel.transform(from: input) + private let currentSongID: String? + init( + currentSongID: String?, viewModel: PlaylistViewModel, containSongsFactory: ContainSongsFactory, songDetailPresenter: any SongDetailPresentable, - textPopUpFactory: any TextPopUpFactory, + textPopupFactory: any TextPopupFactory, signInFactory: any SignInFactory ) { + self.currentSongID = currentSongID self.containSongsFactory = containSongsFactory self.songDetailPresenter = songDetailPresenter self.signInFactory = signInFactory - self.textPopUpFactory = textPopUpFactory + self.textPopupFactory = textPopupFactory self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } deinit { - DEBUG_LOG("โŒ PlaylistVC deinit") + LogManager.printDebug("โŒ PlaylistVC deinit") } @available(*, unavailable) @@ -89,6 +94,12 @@ public final class PlaylistViewController: UIViewController, SongCartViewType { bindActions() } + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + let log = CommonAnalyticsLog.viewPage(pageName: .playlist) + LogManager.analytics(log) + } + override public func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // Comment: ์žฌ์ƒ๋ชฉ๋ก ํ™”๋ฉด์ด ์‚ฌ๋ผ์ง€๋Š” ์‹œ์ ์—์„œ DB์— ์ €์žฅ๋œ ๋ฆฌ์ŠคํŠธ๋กœ ์—…๋ฐ์ดํŠธ @@ -170,7 +181,21 @@ private extension PlaylistViewController { }.store(in: &subscription) output.playlists - .map { [PlayListSectionModel.init(model: 0, items: $0)] } + .map { [PlaylistSectionModel.init(model: 0, items: $0)] } + .do(afterNext: { [currentSongID, tableView = playlistView.playlistTableView] playListSectionModel in + guard let currentSongID else { return } + guard + let sectionIndex = playListSectionModel.firstIndex(where: { model in + model.items.contains(where: { $0.id == currentSongID }) + }), + let itemIndex = playListSectionModel[safe: sectionIndex]?.items + .firstIndex(where: { $0.id == currentSongID }) + else { return } + let index = IndexPath(row: itemIndex, section: sectionIndex) + DispatchQueue.main.async { + tableView.scrollToRow(at: index, at: .middle, animated: false) + } + }) .bind(to: playlistView.playlistTableView.rx.items(dataSource: createDatasources(output: output))) .disposed(by: disposeBag) @@ -192,6 +217,22 @@ private extension PlaylistViewController { .map { $0.isEmpty } .bind(to: playlistView.editButton.rx.isHidden) .disposed(by: disposeBag) + + playlistView.playlistTableView.rx.itemSelected + .withLatestFrom(output.playlists) { ($0, $1) } + .map { $0.1[$0.0.row] } + .bind(with: self, onNext: { [songDetailPresenter] owner, item in + let currentSongs = output.playlists.value + .map(\.id) + + owner.dismiss(animated: true) { + songDetailPresenter.present( + ids: Array(currentSongs), + selectedID: item.id + ) + } + }) + .disposed(by: disposeBag) } private func bindSongCart(output: PlaylistViewModel.Output) { @@ -236,8 +277,8 @@ extension PlaylistViewController { private func createDatasources( output: PlaylistViewModel .Output - ) -> RxTableViewSectionedReloadDataSource { - let datasource = RxTableViewSectionedReloadDataSource( + ) -> RxTableViewSectionedReloadDataSource { + let datasource = RxTableViewSectionedReloadDataSource( configureCell: { [weak self] _, tableView, indexPath, model -> UITableViewCell in guard let self else { return UITableViewCell() } guard let cell = tableView.dequeueReusableCell( diff --git a/Projects/Features/PlaylistFeature/Sources/ViewControllers/UnknownPlaylistDetailViewController.swift b/Projects/Features/PlaylistFeature/Sources/ViewControllers/UnknownPlaylistDetailViewController.swift index bf791cc6d..bc71b8c9d 100644 --- a/Projects/Features/PlaylistFeature/Sources/ViewControllers/UnknownPlaylistDetailViewController.swift +++ b/Projects/Features/PlaylistFeature/Sources/ViewControllers/UnknownPlaylistDetailViewController.swift @@ -20,8 +20,6 @@ final class UnknownPlaylistDetailViewController: BaseReactorViewController UIView? { - let playbuttonGroupView = PlayButtonGroupView() - playbuttonGroupView.delegate = self + let view = SingleActionButtonView(frame: CGRect(x: 0, y: 0, width: APP_WIDTH(), height: 78)) + view.delegate = self + view.setTitleAndImage(text: "์ „์ฒด์žฌ์ƒ", image: DesignSystemAsset.Chart.allPlay.image) guard let reactor = reactor else { return nil @@ -284,7 +285,7 @@ extension WakmusicPlaylistDetailViewController: UITableViewDelegate { if reactor.currentState.dataSource.isEmpty { return nil } else { - return playbuttonGroupView + return view } } @@ -305,33 +306,34 @@ extension WakmusicPlaylistDetailViewController: UITableViewDelegate { } } -extension WakmusicPlaylistDetailViewController: PlaylistDateTableViewCellDelegate { - func thumbnailDidTap(key: String) { - songDetailPresenter.present(id: key) - } -} - -/// ์ „์ฒด์žฌ์ƒ , ๋žœ๋ค ์žฌ์ƒ ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ -extension WakmusicPlaylistDetailViewController: PlayButtonGroupViewDelegate { - func play(_ event: PlayEvent) { - guard let reactor = reactor else { +extension WakmusicPlaylistDetailViewController: SingleActionButtonViewDelegate { + func tappedButtonAction() { + guard let currentState = self.reactor?.currentState, let playlistURL = currentState.playlistURL, + let url = URL(string: playlistURL) else { + showToast(text: "ํ•ด๋‹น ๊ธฐ๋Šฅ์€ ์ค€๋น„ ์ค‘์ž…๋‹ˆ๋‹ค.", options: [.tabBar]) return } - let currentState = reactor.currentState - var songs = currentState.dataSource + let songs = currentState.dataSource - switch event { - case .allPlay: - LogManager.analytics(PlaylistAnalyticsLog.clickPlaylistPlayButton(type: "all", key: reactor.key)) - - case .shufflePlay: - LogManager.analytics(PlaylistAnalyticsLog.clickPlaylistPlayButton(type: "random", key: reactor.key)) - songs.shuffle() + LogManager.analytics(PlaylistAnalyticsLog.clickPlaylistPlayButton(type: "all", key: reactor?.key ?? "")) + PlayState.shared.append(contentsOf: songs.map { PlaylistItem(item: $0) }) + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let listID = components.queryItems?.first(where: { $0.name == "list" })?.value { + WakmusicYoutubePlayer(listID: listID).play() } + } +} - PlayState.shared.append(contentsOf: songs.map { PlaylistItem(item: $0) }) - WakmusicYoutubePlayer(ids: songs.map { $0.id }).play() +extension WakmusicPlaylistDetailViewController: PlaylistDateTableViewCellDelegate { + func thumbnailDidTap(key: String) { + guard let tappedSong = reactor?.currentState.dataSource + .first(where: { $0.id == key }) + else { return } + PlayState.shared.append(item: .init(id: tappedSong.id, title: tappedSong.title, artist: tappedSong.artist)) + let playlistIDs = PlayState.shared.currentPlaylist + .map(\.id) + songDetailPresenter.present(ids: playlistIDs, selectedID: key) } } @@ -344,6 +346,7 @@ extension WakmusicPlaylistDetailViewController: SongCartViewDelegate { let currentState = reactor.currentState let songs = currentState.dataSource.filter { $0.isSelected } + let limit = 50 switch type { case let .allSelect(flag: flag): @@ -353,6 +356,16 @@ extension WakmusicPlaylistDetailViewController: SongCartViewDelegate { reactor.action.onNext(.deselectAll) } case .addSong: + let log = CommonAnalyticsLog.clickAddMusicsButton(location: .playlistDetail) + LogManager.analytics(log) + + guard songs.count <= limit else { + showToast( + text: LocalizationStrings.overFlowContainWarning(songs.count - limit), + options: [.tabBar, .songCart] + ) + return + } if PreferenceManager.userInfo == nil { reactor.action.onNext(.requestLoginRequiredAction) @@ -365,6 +378,7 @@ extension WakmusicPlaylistDetailViewController: SongCartViewDelegate { reactor.action.onNext(.deselectAll) case .addPlayList: + PlayState.shared.append(contentsOf: songs.map { PlaylistItem(item: $0) }) reactor.action.onNext(.deselectAll) showToast( @@ -373,8 +387,23 @@ extension WakmusicPlaylistDetailViewController: SongCartViewDelegate { ) case .play: + + guard songs.count <= limit else { + showToast( + text: LocalizationStrings.overFlowPlayWarning(songs.count - limit), + options: [.tabBar, .songCart] + ) + return + } + LogManager.analytics( + CommonAnalyticsLog.clickPlayButton(location: .playlistDetail, type: .multiple) + ) PlayState.shared.append(contentsOf: songs.map { PlaylistItem(item: $0) }) - WakmusicYoutubePlayer(ids: songs.map { $0.id }).play() + if songs.allSatisfy({ $0.title.isContainShortsTagTitle }) { + WakmusicYoutubePlayer(ids: songs.map { $0.id }, title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง", playPlatform: .youtube).play() + } else { + WakmusicYoutubePlayer(ids: songs.map { $0.id }, title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง").play() + } reactor.action.onNext(.deselectAll) case .remove: diff --git a/Projects/Features/PlaylistFeature/Sources/ViewModels/PlayListViewModel.swift b/Projects/Features/PlaylistFeature/Sources/ViewModels/PlaylistViewModel.swift similarity index 94% rename from Projects/Features/PlaylistFeature/Sources/ViewModels/PlayListViewModel.swift rename to Projects/Features/PlaylistFeature/Sources/ViewModels/PlaylistViewModel.swift index 78a320bdb..f51be9027 100644 --- a/Projects/Features/PlaylistFeature/Sources/ViewModels/PlayListViewModel.swift +++ b/Projects/Features/PlaylistFeature/Sources/ViewModels/PlaylistViewModel.swift @@ -1,12 +1,13 @@ import BaseFeature import Combine import Foundation +import LogManager import RxDataSources import RxRelay import RxSwift import Utility -internal typealias PlayListSectionModel = SectionModel +internal typealias PlaylistSectionModel = SectionModel final class PlaylistViewModel: ViewModelType { struct Input { @@ -37,11 +38,11 @@ final class PlaylistViewModel: ViewModelType { private var disposeBag = DisposeBag() init() { - DEBUG_LOG("โœ… PlaylistViewModel ์ƒ์„ฑ") + LogManager.printDebug("โœ… PlaylistViewModel ์ƒ์„ฑ") } deinit { - DEBUG_LOG("โŒ PlaylistViewModel deinit") + LogManager.printDebug("โŒ PlaylistViewModel deinit") } func transform(from input: Input) -> Output { @@ -72,6 +73,13 @@ final class PlaylistViewModel: ViewModelType { input.editButtonDidTapEvent.sink { [weak self] _ in guard let self else { return } + let log = if !self.isEditing { + CommonAnalyticsLog.clickEditButton(location: .playlist) + } else { + CommonAnalyticsLog.clickEditCompleteButton(location: .playlist) + } + LogManager.analytics(log) + self.isEditing.toggle() output.editState.send(self.isEditing) }.store(in: &subscription) diff --git a/Projects/Features/PlaylistFeature/Sources/Views/MyPlaylistHeaderView.swift b/Projects/Features/PlaylistFeature/Sources/Views/MyPlaylistHeaderView.swift index ec78c6cb2..8b3a73cf5 100644 --- a/Projects/Features/PlaylistFeature/Sources/Views/MyPlaylistHeaderView.swift +++ b/Projects/Features/PlaylistFeature/Sources/Views/MyPlaylistHeaderView.swift @@ -57,7 +57,7 @@ final class MyPlaylistHeaderView: UIView { let countLabel: WMLabel = WMLabel( text: "", - textColor: DesignSystemAsset.BlueGrayColor.blueGray900.color.withAlphaComponent(0.6), + textColor: DesignSystemAsset.BlueGrayColor.blueGray600.color, font: .t6_1(weight: .light), lineHeight: UIFont.WMFontSystem.t6_1(weight: .light).lineHeight ) diff --git a/Projects/Features/PlaylistFeature/Sources/Views/PlaylistTableViewCell.swift b/Projects/Features/PlaylistFeature/Sources/Views/PlaylistTableViewCell.swift index 3654baaa1..58c4db485 100644 --- a/Projects/Features/PlaylistFeature/Sources/Views/PlaylistTableViewCell.swift +++ b/Projects/Features/PlaylistFeature/Sources/Views/PlaylistTableViewCell.swift @@ -11,8 +11,7 @@ import Utility internal protocol PlaylistTableViewCellDelegate: AnyObject { func superButtonTapped(index: Int) - func thumbnailDidTap(key: String) - func playButtonDidTap(key: String) + func playButtonDidTap(model: PlaylistItemModel) } internal class PlaylistTableViewCell: UITableViewCell { @@ -25,8 +24,6 @@ internal class PlaylistTableViewCell: UITableViewCell { $0.clipsToBounds = true } - internal lazy var thumbnailButton = UIButton() - internal lazy var titleArtistStackView = UIStackView(arrangedSubviews: [titleLabel, artistLabel]).then { $0.axis = .vertical $0.distribution = .fill @@ -90,7 +87,6 @@ internal class PlaylistTableViewCell: UITableViewCell { self.contentView.addSubview(self.thumbnailImageView) self.contentView.addSubview(self.titleArtistStackView) self.contentView.addSubview(self.playImageView) - self.contentView.addSubview(self.thumbnailButton) self.contentView.addSubview(self.superButton) let height = 40 @@ -102,10 +98,6 @@ internal class PlaylistTableViewCell: UITableViewCell { $0.height.equalTo(height) } - thumbnailButton.snp.makeConstraints { - $0.edges.equalTo(thumbnailImageView) - } - titleArtistStackView.snp.makeConstraints { $0.top.equalTo(thumbnailImageView.snp.top) $0.left.equalTo(thumbnailImageView.snp.right).offset(8) @@ -149,15 +141,6 @@ extension PlaylistTableViewCell { } func bindAction() { - thumbnailButton.addAction { [weak self] in - - guard let song = self?.model.model else { - return - } - - self?.delegate?.thumbnailDidTap(key: song.id) - } - playImageView.rx.tapGesture() .when(.recognized) .bind(with: self) { owner, _ in @@ -165,7 +148,7 @@ extension PlaylistTableViewCell { return } - owner.delegate?.playButtonDidTap(key: song.id) + owner.delegate?.playButtonDidTap(model: song) } .disposed(by: disposeBag) diff --git a/Projects/Features/PlaylistFeature/Sources/Views/RandomPlayButtonGroupView.swift b/Projects/Features/PlaylistFeature/Sources/Views/RandomPlayButtonGroupView.swift new file mode 100644 index 000000000..e65f7236b --- /dev/null +++ b/Projects/Features/PlaylistFeature/Sources/Views/RandomPlayButtonGroupView.swift @@ -0,0 +1,79 @@ +import DesignSystem +import Localization +import SnapKit +import Then +import UIKit +import Utility + +final class RandomPlayButtonGroupView: UIView { + private let randomPlayButton = RandomPlayButton() + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + self.backgroundColor = DesignSystemAsset.BlueGrayColor.gray100.color + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func addAction(didTap: @escaping () -> Void) { + randomPlayButton.addAction { + didTap() + } + } + + private func setupView() { + addSubviews(randomPlayButton) + randomPlayButton.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview().inset(20) + $0.verticalEdges.equalToSuperview().inset(12) + } + } +} + +private final class RandomPlayButton: UIButton { + private let randomImageView = UIImageView().then { + $0.image = DesignSystemAsset.Chart.shufflePlay.image + $0.tintColor = DesignSystemAsset.BlueGrayColor.blueGray900.color + $0.contentMode = .scaleAspectFit + } + + private let playLabel = UILabel().then { + $0.text = LocalizationStrings.titleRandomPlay + $0.textColor = DesignSystemAsset.BlueGrayColor.blueGray900.color + $0.font = .setFont(.t6(weight: .medium)) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + addSubviews(randomImageView, playLabel) + + randomImageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().inset(32) + $0.size.equalTo(32) + } + + playLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + + layer.borderWidth = 1 + layer.borderColor = DesignSystemAsset.BlueGrayColor.blueGray200.color + .withAlphaComponent(0.4).cgColor + layer.cornerRadius = 8 + backgroundColor = DesignSystemAsset.PrimaryColorV2.white.color + } +} diff --git a/Projects/Features/PlaylistFeature/Sources/Views/RandomPlayButtonHeaderView.swift b/Projects/Features/PlaylistFeature/Sources/Views/RandomPlayButtonHeaderView.swift new file mode 100644 index 000000000..1f1f86cfd --- /dev/null +++ b/Projects/Features/PlaylistFeature/Sources/Views/RandomPlayButtonHeaderView.swift @@ -0,0 +1,107 @@ +import DesignSystem +import Localization +import SnapKit +import Then +import UIKit +import Utility + +final class RandomPlayButtonHeaderView: UICollectionReusableView { + private let randomPlayButton = RandomPlayButton() + private let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)).then { + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + + private var playButtonHandler: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + bind() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { + super.apply(layoutAttributes) + layer.zPosition = 1 + } + + func setPlayButtonHandler(handler: @escaping () -> Void) { + self.playButtonHandler = handler + } + + private func setupViews() { + addSubviews(blurEffectView, randomPlayButton) + blurEffectView.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview().inset(20) + $0.verticalEdges.equalToSuperview().inset(8) + } + + randomPlayButton.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview().inset(20) + $0.verticalEdges.equalToSuperview().inset(8) + } + } + + private func bind() { + randomPlayButton.addAction { [weak self] in + self?.playButtonHandler?() + } + } +} + +private final class RandomPlayButton: UIButton { + private let randomImageView = UIImageView().then { + $0.image = DesignSystemAsset.Chart.shufflePlay.image + $0.tintColor = DesignSystemAsset.BlueGrayColor.blueGray900.color + $0.contentMode = .scaleAspectFit + } + + private let playLabel = UILabel().then { + $0.text = LocalizationStrings.titleRandomPlay + $0.textColor = DesignSystemAsset.BlueGrayColor.blueGray900.color + $0.font = .setFont(.t6(weight: .medium)) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateTitle(isOverMaximumNumber: Bool) { + playLabel.text = if isOverMaximumNumber { + LocalizationStrings.title50RandomPlay + } else { + LocalizationStrings.titleRandomPlay + } + } + + private func setupView() { + addSubviews(randomImageView, playLabel) + + randomImageView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.leading.equalToSuperview().inset(32) + $0.size.equalTo(32) + } + + playLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + + layer.borderWidth = 1 + layer.borderColor = DesignSystemAsset.BlueGrayColor.blueGray200.color + .withAlphaComponent(0.4).cgColor + layer.cornerRadius = 8 + backgroundColor = DesignSystemAsset.PrimaryColorV2.white.color.withAlphaComponent(0.4) + } +} diff --git a/Projects/Features/PlaylistFeature/Sources/Views/UnknownPlaylistHeaderView.swift b/Projects/Features/PlaylistFeature/Sources/Views/UnknownPlaylistHeaderView.swift index e5d2d4f61..d1d1eb8db 100644 --- a/Projects/Features/PlaylistFeature/Sources/Views/UnknownPlaylistHeaderView.swift +++ b/Projects/Features/PlaylistFeature/Sources/Views/UnknownPlaylistHeaderView.swift @@ -17,30 +17,46 @@ final class UnknownPlaylistHeaderView: UIView { $0.contentMode = .scaleAspectFill } - private let containerView: UIView = UIView().then { + private let scrollView: UIScrollView = UIScrollView().then { $0.backgroundColor = DesignSystemAsset.BlueGrayColor.blueGray25.color.withAlphaComponent(0.4) $0.layer.borderWidth = 1 $0.layer.cornerRadius = 8 $0.layer.borderColor = UIColor.white.cgColor $0.clipsToBounds = true + $0.verticalScrollIndicatorInsets = .init(top: 20, left: .zero, bottom: 12, right: 6) } + private let stackView: UIStackView = UIStackView().then { + $0.axis = .vertical + } + + private let containerView: UIView = UIView() + private let titleLabel: WMLabel = WMLabel( text: "", textColor: DesignSystemAsset.BlueGrayColor.blueGray900.color, font: .t3(weight: .bold), lineHeight: UIFont.WMFontSystem.t3(weight: .bold).lineHeight ).then { - $0.numberOfLines = 0 + $0.numberOfLines = .zero } - let subtitleLabel: WMLabel = WMLabel( + let countLabel: WMLabel = WMLabel( text: "", - textColor: DesignSystemAsset.BlueGrayColor.blueGray900.color.withAlphaComponent(0.6), + textColor: DesignSystemAsset.BlueGrayColor.blueGray600.color, font: .t6_1(weight: .light), lineHeight: UIFont.WMFontSystem.t6_1(weight: .light).lineHeight ) + let nickNameLabel: WMLabel = WMLabel( + text: "", + textColor: DesignSystemAsset.BlueGrayColor.blueGray600.color, + font: .t6_1(weight: .medium), + lineHeight: UIFont.WMFontSystem.t6(weight: .medium).lineHeight + ).then { + $0.numberOfLines = .zero + } + override init(frame: CGRect) { super.init(frame: frame) @@ -56,8 +72,10 @@ final class UnknownPlaylistHeaderView: UIView { extension UnknownPlaylistHeaderView { private func addView() { - self.addSubviews(thumbnailImageView, containerView) - containerView.addSubviews(titleLabel, subtitleLabel) + self.addSubviews(thumbnailImageView, scrollView) + scrollView.addSubview(stackView) + stackView.addArrangedSubview(containerView) + containerView.addSubviews(titleLabel, countLabel, nickNameLabel) } private func setLayout() { @@ -67,20 +85,35 @@ extension UnknownPlaylistHeaderView { $0.leading.equalToSuperview().inset(20) } - containerView.snp.makeConstraints { + scrollView.snp.makeConstraints { $0.leading.equalTo(thumbnailImageView.snp.trailing).offset(8) - $0.top.bottom.equalToSuperview() + $0.verticalEdges.equalToSuperview() $0.trailing.equalToSuperview().inset(20) } + stackView.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.width.equalTo(scrollView.snp.width) + } + + containerView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + titleLabel.snp.makeConstraints { $0.top.equalToSuperview().inset(12) $0.horizontalEdges.equalToSuperview().inset(16) } - subtitleLabel.snp.makeConstraints { + countLabel.snp.makeConstraints { $0.top.equalTo(titleLabel.snp.bottom).offset(8) - $0.leading.equalTo(titleLabel.snp.leading) + $0.horizontalEdges.equalTo(titleLabel) + } + + nickNameLabel.snp.makeConstraints { + $0.top.lessThanOrEqualTo(countLabel.snp.bottom).offset(2) + $0.horizontalEdges.equalTo(titleLabel) + $0.bottom.greaterThanOrEqualToSuperview().inset(12) } } } @@ -89,16 +122,7 @@ extension UnknownPlaylistHeaderView: UnknownPlaylistHeaderStateProtocol { func updateData(_ model: PlaylistDetailHeaderModel) { titleLabel.text = model.title thumbnailImageView.kf.setImage(with: URL(string: model.image)) - - let attributedString = NSMutableAttributedString(string: "\(model.songCount)๊ณก") - - let padding = NSTextAttachment() - let imageAttachment = NSTextAttachment() - imageAttachment.image = DesignSystemAsset.Playlist.smallGrayDot.image - imageAttachment.bounds = CGRect(x: 0, y: 0, width: 12, height: 12) - attributedString.append(NSAttributedString(attachment: imageAttachment)) - attributedString.append(NSAttributedString(string: model.userName)) - - subtitleLabel.attributedText = attributedString + countLabel.text = "\(model.songCount)๊ณก" + nickNameLabel.text = model.userName } } diff --git a/Projects/Features/PlaylistFeature/Sources/Views/WakmusicPlaylistHeaderView.swift b/Projects/Features/PlaylistFeature/Sources/Views/WakmusicPlaylistHeaderView.swift index 4d556171d..1b0ac6feb 100644 --- a/Projects/Features/PlaylistFeature/Sources/Views/WakmusicPlaylistHeaderView.swift +++ b/Projects/Features/PlaylistFeature/Sources/Views/WakmusicPlaylistHeaderView.swift @@ -36,7 +36,7 @@ final class WakmusicPlaylistHeaderView: UIView { let subtitleLabel: WMLabel = WMLabel( text: "", - textColor: DesignSystemAsset.BlueGrayColor.blueGray900.color.withAlphaComponent(0.6), + textColor: DesignSystemAsset.BlueGrayColor.blueGray600.color, font: .t6_1(weight: .light), lineHeight: UIFont.WMFontSystem.t6_1(weight: .light).lineHeight ) diff --git a/Projects/Features/RootFeature/Sources/Components/RootComponent.swift b/Projects/Features/RootFeature/Sources/Components/RootComponent.swift index b9e2ce0b3..f53e81416 100644 --- a/Projects/Features/RootFeature/Sources/Components/RootComponent.swift +++ b/Projects/Features/RootFeature/Sources/Components/RootComponent.swift @@ -13,7 +13,7 @@ public protocol RootDependency: Dependency { var fetchAppCheckUseCase: any FetchAppCheckUseCase { get } var logoutUseCase: any LogoutUseCase { get } var checkIsExistAccessTokenUseCase: any CheckIsExistAccessTokenUseCase { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } } public final class RootComponent: Component { @@ -21,7 +21,7 @@ public final class RootComponent: Component { return IntroViewController.viewController( mainContainerComponent: dependency.mainContainerComponent, permissionComponent: dependency.permissionComponent, - textPopUpFactory: dependency.textPopUpFactory, + textPopupFactory: dependency.textPopupFactory, viewModel: IntroViewModel( fetchUserInfoUseCase: dependency.fetchUserInfoUseCase, fetchAppCheckUseCase: dependency.fetchAppCheckUseCase, diff --git a/Projects/Features/RootFeature/Sources/ViewControllers/IntroViewController.swift b/Projects/Features/RootFeature/Sources/ViewControllers/IntroViewController.swift index ac05ae8b1..8bf9c8eab 100644 --- a/Projects/Features/RootFeature/Sources/ViewControllers/IntroViewController.swift +++ b/Projects/Features/RootFeature/Sources/ViewControllers/IntroViewController.swift @@ -17,7 +17,7 @@ open class IntroViewController: UIViewController, ViewControllerFromStoryBoard { private var mainContainerComponent: MainContainerComponent! private var permissionComponent: PermissionComponent! - private var textPopUpFactory: TextPopUpFactory! + private var textPopupFactory: TextPopupFactory! private var viewModel: IntroViewModel! lazy var input = IntroViewModel.Input() @@ -34,13 +34,13 @@ open class IntroViewController: UIViewController, ViewControllerFromStoryBoard { public static func viewController( mainContainerComponent: MainContainerComponent, permissionComponent: PermissionComponent, - textPopUpFactory: TextPopUpFactory, + textPopupFactory: TextPopupFactory, viewModel: IntroViewModel ) -> IntroViewController { let viewController = IntroViewController.viewController(storyBoardName: "Intro", bundle: Bundle.module) viewController.mainContainerComponent = mainContainerComponent viewController.permissionComponent = permissionComponent - viewController.textPopUpFactory = textPopUpFactory + viewController.textPopupFactory = textPopupFactory viewController.viewModel = viewModel return viewController } @@ -95,7 +95,7 @@ private extension IntroViewController { case .offline: owner.showBottomSheet( - content: owner.textPopUpFactory.makeView( + content: owner.textPopupFactory.makeView( text: entity.description, cancelButtonIsHidden: true, confirmButtonText: "์žฌ์‹œ๋„", @@ -110,7 +110,7 @@ private extension IntroViewController { return case .event: - textPopupVc = owner.textPopUpFactory.makeView( + textPopupVc = owner.textPopupFactory.makeView( text: "\(entity.title)\(entity.description.isEmpty ? "" : "\n")\(entity.description)", cancelButtonIsHidden: true, confirmButtonText: nil, @@ -122,7 +122,7 @@ private extension IntroViewController { ) case .update: - textPopupVc = owner.textPopUpFactory.makeView( + textPopupVc = owner.textPopupFactory.makeView( text: "\(updateTitle)\n\(updateMessage)", cancelButtonIsHidden: false, confirmButtonText: "์—…๋ฐ์ดํŠธ", @@ -136,7 +136,7 @@ private extension IntroViewController { ) case .forceUpdate: - textPopupVc = owner.textPopUpFactory.makeView( + textPopupVc = owner.textPopupFactory.makeView( text: "\(updateTitle)\n\(updateMessage)", cancelButtonIsHidden: true, confirmButtonText: "์—…๋ฐ์ดํŠธ", @@ -156,7 +156,7 @@ private extension IntroViewController { case let .failure(error): owner.lottiePlay(specialLogo: false) owner.showBottomSheet( - content: owner.textPopUpFactory.makeView( + content: owner.textPopupFactory.makeView( text: error.asWMError.errorDescription ?? "", cancelButtonIsHidden: true, confirmButtonText: "์žฌ์‹œ๋„", @@ -189,7 +189,7 @@ private extension IntroViewController { case let .failure(error): owner.showBottomSheet( - content: owner.textPopUpFactory.makeView( + content: owner.textPopupFactory.makeView( text: error.asWMError.errorDescription ?? error.localizedDescription, cancelButtonIsHidden: true, confirmButtonText: nil, diff --git a/Projects/Features/SearchFeature/Sources/After/Components/SongSearchResultComponent.swift b/Projects/Features/SearchFeature/Sources/After/Components/SongSearchResultComponent.swift index b41190a61..7c836ed38 100644 --- a/Projects/Features/SearchFeature/Sources/After/Components/SongSearchResultComponent.swift +++ b/Projects/Features/SearchFeature/Sources/After/Components/SongSearchResultComponent.swift @@ -13,7 +13,7 @@ public protocol SongSearchResultDependency: Dependency { var searchGlobalScrollState: any SearchGlobalScrollProtocol { get } var songDetailPresenter: any SongDetailPresentable { get } var signInFactory: any SignInFactory { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } } public final class SongSearchResultComponent: Component, SongSearchResultFactory { @@ -27,7 +27,7 @@ public final class SongSearchResultComponent: Component Observable { return .concat([ .just(.updateSortType(type)), - updateDataSource(order: type, text: self.text, scrollPage: 1, byOption: true) + updateDataSource(order: type, text: self.text, scrollPage: 1) ]) } private func updateDataSource( order: SortType, text: String, - scrollPage: Int, - byOption: Bool = false // ํ•„ํ„ฐ๋˜๋Š” ์˜ต์…˜์œผ๋กœ ๋ฆฌํ”„๋ž˜์‰ฌ ํ•˜๋‚˜ , ์•„๋‹ˆ๋ฉด ์Šคํฌ๋กค์ด๋ƒ + scrollPage: Int ) -> Observable { - let prev: [SearchPlaylistEntity] = byOption ? [] : self.currentState.dataSource + let prev: [SearchPlaylistEntity] = scrollPage == 1 ? [] : self.currentState.dataSource return .concat([ .just(Mutation.updateLoadingState(true)), diff --git a/Projects/Features/SearchFeature/Sources/After/Reactors/SongSearchResultReactor.swift b/Projects/Features/SearchFeature/Sources/After/Reactors/SongSearchResultReactor.swift index f74b15cae..2f0b280a2 100644 --- a/Projects/Features/SearchFeature/Sources/After/Reactors/SongSearchResultReactor.swift +++ b/Projects/Features/SearchFeature/Sources/After/Reactors/SongSearchResultReactor.swift @@ -1,6 +1,8 @@ import Localization import LogManager import ReactorKit +import RxCocoa +import RxSwift import SearchDomainInterface import SongsDomainInterface @@ -41,11 +43,13 @@ final class SongSearchResultReactor: Reactor { private let fetchSearchSongsUseCase: any FetchSearchSongsUseCase private let text: String private let limit: Int = 20 + private var requestDisposeBag = DisposeBag() + private let subject = PublishSubject() init(text: String, fetchSearchSongsUseCase: any FetchSearchSongsUseCase) { self.initialState = State( isLoading: true, - sortType: .latest, + sortType: .relevance, filterType: .all, selectedCount: 0, scrollPage: 1, @@ -112,6 +116,20 @@ final class SongSearchResultReactor: Reactor { return newState } + + func transform(mutation: Observable) -> Observable { + let flatMapMutation = subject + .withUnretained(self) + .flatMap { owner, subjectMutation -> Observable in + return .concat([ + .just(subjectMutation), + .just(Mutation.updateScrollPage(owner.currentState.scrollPage + 1)), + .just(Mutation.updateLoadingState(false)) + ]) + } + + return Observable.merge(mutation, flatMapMutation) + } } extension SongSearchResultReactor { @@ -121,7 +139,7 @@ extension SongSearchResultReactor { return .concat([ .just(.updateSelectedCount(0)), .just(.updateSortType(type)), - updateDataSource(order: type, filter: state.filterType, text: self.text, scrollPage: 1, byOption: true) + updateDataSource(order: type, filter: state.filterType, text: self.text, scrollPage: 1) ]) } @@ -131,7 +149,7 @@ extension SongSearchResultReactor { return .concat([ .just(.updateSelectedCount(0)), .just(.updateFilterType(type)), - updateDataSource(order: state.sortType, filter: type, text: self.text, scrollPage: 1, byOption: true) + updateDataSource(order: state.sortType, filter: type, text: self.text, scrollPage: 1) ]) } @@ -139,39 +157,40 @@ extension SongSearchResultReactor { order: SortType, filter: FilterType, text: String, - scrollPage: Int, - byOption: Bool = false // ํ•„ํ„ฐ๋˜๋Š” ์˜ต์…˜์œผ๋กœ ๋ฆฌํ”„๋ž˜์‰ฌ ํ•˜๋‚˜ , ์•„๋‹ˆ๋ฉด ์Šคํฌ๋กค์ด๋ƒ + scrollPage: Int ) -> Observable { - return .concat([ - .just(.updateLoadingState(true)), - fetchSearchSongsUseCase - .execute(order: order, filter: filter, text: text, page: scrollPage, limit: limit) - .asObservable() - .map { [weak self] dataSource -> Mutation in - - guard let self else { return .updateDataSource(dataSource: [], canLoad: false) } - - let prev: [SongEntity] = byOption ? [] : self.currentState.dataSource - - if scrollPage == 1 { - LogManager.analytics(SearchAnalyticsLog.viewSearchResult( - keyword: self.text, - category: "song", - count: dataSource.count - )) - } - - return Mutation.updateDataSource(dataSource: prev + dataSource, canLoad: dataSource.count == limit) + requestDisposeBag = DisposeBag() // ๊ธฐ์กด ์ž‘์—… ์บ”์Šฌ + + fetchSearchSongsUseCase + .execute(order: order, filter: filter, text: text, page: scrollPage, limit: limit) + .asObservable() + .map { [weak self] dataSource -> Mutation in + + guard let self else { return .updateDataSource(dataSource: [], canLoad: false) } + + let prev: [SongEntity] = scrollPage == 1 ? [] : self.currentState.dataSource + if scrollPage == 1 { + LogManager.analytics(SearchAnalyticsLog.viewSearchResult( + keyword: self.text, + category: "song", + count: dataSource.count + )) } - .catch { error in - let wmErorr = error.asWMError - return Observable.just( - Mutation.showToast(wmErorr.errorDescription ?? LocalizationStrings.unknownErrorWarning) - ) - }, - .just(Mutation.updateScrollPage(scrollPage + 1)), // ์Šคํฌ๋กค ํŽ˜์ด์ง€ ์ฆ๊ฐ€ - .just(.updateLoadingState(false)) - ]) + + return Mutation.updateDataSource(dataSource: prev + dataSource, canLoad: dataSource.count == limit) + } + .catch { error in + let wmErorr = error.asWMError + return Observable.just( + Mutation.showToast(wmErorr.errorDescription ?? LocalizationStrings.unknownErrorWarning) + ) + } + .bind(with: subject, onNext: { subject, mutation in + subject.onNext(mutation) + }) + .disposed(by: requestDisposeBag) + + return Observable.just(.updateLoadingState(true)) } func updateItemSelected(_ index: Int) -> Observable { diff --git a/Projects/Features/SearchFeature/Sources/After/View/ListResultCell.swift b/Projects/Features/SearchFeature/Sources/After/View/ListResultCell.swift index 9c162416e..6099fdf84 100644 --- a/Projects/Features/SearchFeature/Sources/After/View/ListResultCell.swift +++ b/Projects/Features/SearchFeature/Sources/After/View/ListResultCell.swift @@ -92,7 +92,7 @@ extension ListResultCell { public func update(_ model: SearchPlaylistEntity) { titleLabel.text = model.title creatorLabel.text = model.userName - sharedCountLabel.text = "\(model.subscribeCount)ํšŒ" + sharedCountLabel.text = "\(model.subscribeCount.formattedWithComma())๊ตฌ๋…" thumbnailView.kf.setImage(with: URL(string: model.image), placeholder: nil, options: [.transition(.fade(0.2))]) } } diff --git a/Projects/Features/SearchFeature/Sources/After/ViewControllers/AfterSearchViewController.swift b/Projects/Features/SearchFeature/Sources/After/ViewControllers/AfterSearchViewController.swift index beb296f7d..e7ea91d74 100644 --- a/Projects/Features/SearchFeature/Sources/After/ViewControllers/AfterSearchViewController.swift +++ b/Projects/Features/SearchFeature/Sources/After/ViewControllers/AfterSearchViewController.swift @@ -56,7 +56,7 @@ public final class AfterSearchViewController: TabmanViewController, ViewControll } deinit { - DEBUG_LOG("โŒ \(Self.self)") + LogManager.printDebug("โŒ \(Self.self)") } public func bind(reactor: AfterSearchReactor) { @@ -99,6 +99,7 @@ extension AfterSearchViewController { bar.layout.contentInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) bar.layout.contentMode = .fit bar.layout.transitionStyle = .progressive + bar.layout.interButtonSpacing = 0 // ๋ฒ„ํŠผ ๊ธ€์”จ ์ปค์Šคํ…€ bar.buttons.customize { button in diff --git a/Projects/Features/SearchFeature/Sources/After/ViewControllers/ListSearchResultViewController.swift b/Projects/Features/SearchFeature/Sources/After/ViewControllers/ListSearchResultViewController.swift index 88dfcbdbb..f88933871 100644 --- a/Projects/Features/SearchFeature/Sources/After/ViewControllers/ListSearchResultViewController.swift +++ b/Projects/Features/SearchFeature/Sources/After/ViewControllers/ListSearchResultViewController.swift @@ -24,7 +24,7 @@ final class ListSearchResultViewController: BaseReactorViewController() + sharedState.map(\.dataSource) + .bind(with: self) { owner, dataSource in - snapshot.appendSections([.list]) + var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendItems(dataSource, toSection: .list) - owner.dataSource.apply(snapshot, animatingDifferences: false) + snapshot.appendSections([.list]) - let warningView = WMWarningView( - text: "๊ฒ€์ƒ‰๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค." - ) + snapshot.appendItems(dataSource, toSection: .list) + owner.dataSource.apply(snapshot, animatingDifferences: false) - if dataSource.isEmpty { - owner.collectionView.setBackgroundView(warningView, 100) - } else { - owner.collectionView.restore() - } + let warningView = WMWarningView( + text: "๊ฒ€์ƒ‰๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค." + ) + + if dataSource.isEmpty { + owner.collectionView.setBackgroundView(warningView, 100) + } else { + owner.collectionView.restore() } } .disposed(by: disposeBag) @@ -173,12 +177,8 @@ final class ListSearchResultViewController: BaseReactorViewController() snapshot.appendSections([.song]) @@ -247,7 +247,7 @@ final class SongSearchResultViewController: BaseReactorViewController { public func makeView() -> UIViewController { return BeforeSearchContentViewController( wakmusicRecommendComponent: dependency.wakmusicRecommendComponent, - textPopUpFactory: dependency.textPopUpFactory, + textPopupFactory: dependency.textPopupFactory, playlistDetailFactory: dependency.playlistDetailFactory, reactor: BeforeSearchReactor( fetchCurrentVideoUseCase: dependency.fetchCurrentVideoUseCase, diff --git a/Projects/Features/SearchFeature/Sources/Before/ViewControllers/BeforeSearchContentViewController.swift b/Projects/Features/SearchFeature/Sources/Before/ViewControllers/BeforeSearchContentViewController.swift index 838ac1f83..c2ed84d16 100644 --- a/Projects/Features/SearchFeature/Sources/Before/ViewControllers/BeforeSearchContentViewController.swift +++ b/Projects/Features/SearchFeature/Sources/Before/ViewControllers/BeforeSearchContentViewController.swift @@ -15,7 +15,7 @@ import Utility final class BeforeSearchContentViewController: BaseReactorViewController, PlaylistDetailNavigator { private let wakmusicRecommendComponent: WakmusicRecommendComponent - private let textPopUpFactory: TextPopUpFactory + private let textPopupFactory: TextPopupFactory private (set) var playlistDetailFactory: any PlaylistDetailFactory private let tableView: UITableView = UITableView().then { @@ -33,18 +33,18 @@ final class BeforeSearchContentViewController: BaseReactorViewController, SearchFactory { reactor: SearchReactor(), beforeSearchComponent: self.dependency.beforeSearchComponent, afterSearchComponent: self.dependency.afterSearchComponent, - textPopUpFactory: dependency.textPopUpFactory, + textPopupFactory: dependency.textPopupFactory, searchGlobalScrollState: dependency.searchGlobalScrollState ) } diff --git a/Projects/Features/SearchFeature/Sources/Root/ViewControllers/SearchViewController.swift b/Projects/Features/SearchFeature/Sources/Root/ViewControllers/SearchViewController.swift index ca150b9fe..42f73c301 100644 --- a/Projects/Features/SearchFeature/Sources/Root/ViewControllers/SearchViewController.swift +++ b/Projects/Features/SearchFeature/Sources/Root/ViewControllers/SearchViewController.swift @@ -1,6 +1,7 @@ import BaseFeature import BaseFeatureInterface import DesignSystem +import LogManager import NeedleFoundation import ReactorKit import RxCocoa @@ -33,7 +34,7 @@ final class SearchViewController: BaseStoryboardReactorViewController SearchViewController { let viewController = SearchViewController.viewController(storyBoardName: "Search", bundle: Bundle.module) @@ -64,7 +66,7 @@ final class SearchViewController: BaseStoryboardReactorViewController Observable { return fetchSongCreditsUseCase.execute(id: songID) .map { credits in - credits.map { CreditModel(creditEntity: $0) } + let filteredCredits = credits.map { entity in + SongCreditsEntity( + type: entity.type, + names: entity.names.filter { !$0.name.isEmpty } + ) + } + return filteredCredits.map { CreditModel(creditEntity: $0) } } .map { Mutation.updateCredits($0) } .asObservable() diff --git a/Projects/Features/SongCreditFeature/Sources/SongCreditViewController.swift b/Projects/Features/SongCreditFeature/Sources/SongCreditViewController.swift index ea7f39ec0..eec087034 100644 --- a/Projects/Features/SongCreditFeature/Sources/SongCreditViewController.swift +++ b/Projects/Features/SongCreditFeature/Sources/SongCreditViewController.swift @@ -4,6 +4,7 @@ import CreditSongListFeatureInterface import DesignSystem import Kingfisher import Localization +import LogManager import RxCocoa import RxSwift import SnapKit @@ -70,6 +71,26 @@ final class SongCreditViewController: BaseReactorViewController() snapshot.appendSections(credits.map(\.position)) credits.forEach { snapshot.appendItems($0.names, toSection: $0.position) } diff --git a/Projects/Features/SongCreditFeature/Sources/View/CreditCollectionView/CreditCollectionViewCell.swift b/Projects/Features/SongCreditFeature/Sources/View/CreditCollectionView/CreditCollectionViewCell.swift index 45d0431cc..8dbd848a4 100644 --- a/Projects/Features/SongCreditFeature/Sources/View/CreditCollectionView/CreditCollectionViewCell.swift +++ b/Projects/Features/SongCreditFeature/Sources/View/CreditCollectionView/CreditCollectionViewCell.swift @@ -35,9 +35,16 @@ final class CreditCollectionViewCell: UICollectionViewCell { ) -> UICollectionViewLayoutAttributes { setNeedsLayout() layoutIfNeeded() + let collectionViewWidth = superview?.bounds.width ?? UIScreen.main.bounds.width + + let maxWidth = collectionViewWidth * 0.6 + let size = contentView.systemLayoutSizeFitting(layoutAttributes.size) var newFrame = layoutAttributes.frame - newFrame.size = size + newFrame.size = .init( + width: min(maxWidth, size.width), + height: size.height + ) layoutAttributes.frame = newFrame return layoutAttributes } diff --git a/Projects/Features/StorageFeature/Sources/Analytics/StorageAnalyticsLog.swift b/Projects/Features/StorageFeature/Sources/Analytics/StorageAnalyticsLog.swift new file mode 100644 index 000000000..800b0eed1 --- /dev/null +++ b/Projects/Features/StorageFeature/Sources/Analytics/StorageAnalyticsLog.swift @@ -0,0 +1,45 @@ +import LogManager + +enum StorageAnalyticsLog: AnalyticsLogType { + case clickStorageTabbarTab(tab: StorageTab) + case clickCreatePlaylistButton(location: CreatePlaylistLocation) + case clickMyPlaylistEditButton + case clickMyLikeListEditButton + case clickLoginButton(location: LoginLocation) + case clickFruitDrawEntryButton(location: FruitDrawEntryLocation) + case clickMyLikeListMusicButton(id: String) +} + +enum StorageTab: String, AnalyticsLogEnumParametable { + case myPlaylist = "my_playlist" + case myLikeList = "my_like_list" + + var description: String { + self.rawValue + } +} + +enum CreatePlaylistLocation: String, AnalyticsLogEnumParametable { + case myPlaylist = "my_playlist" + + var description: String { + self.rawValue + } +} + +enum FruitDrawEntryLocation: String, AnalyticsLogEnumParametable { + case myPlaylist = "my_playlist" + + var description: String { + self.rawValue + } +} + +enum LoginLocation: String, AnalyticsLogEnumParametable { + case myPlaylist = "my_playlist" + case myLikeList = "my_like_list" + + var description: String { + self.rawValue + } +} diff --git a/Projects/Features/StorageFeature/Sources/Components/LikeStorageComponent.swift b/Projects/Features/StorageFeature/Sources/Components/LikeStorageComponent.swift index 9e1ec79d8..11f9c53f5 100644 --- a/Projects/Features/StorageFeature/Sources/Components/LikeStorageComponent.swift +++ b/Projects/Features/StorageFeature/Sources/Components/LikeStorageComponent.swift @@ -14,7 +14,7 @@ public protocol LikeStorageDependency: Dependency { var editFavoriteSongsOrderUseCase: any EditFavoriteSongsOrderUseCase { get } var deleteFavoriteListUseCase: any DeleteFavoriteListUseCase { get } var logoutUseCase: any LogoutUseCase { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } var signInFactory: any SignInFactory { get } var songDetailPresenter: any SongDetailPresentable { get } } @@ -29,7 +29,7 @@ public final class LikeStorageComponent: Component { editFavoriteSongsOrderUseCase: dependency.editFavoriteSongsOrderUseCase ), containSongsFactory: dependency.containSongsFactory, - textPopUpFactory: dependency.textPopUpFactory, + textPopupFactory: dependency.textPopupFactory, signInFactory: dependency.signInFactory, songDetailPresenter: dependency.songDetailPresenter ) diff --git a/Projects/Features/StorageFeature/Sources/Components/ListStorageComponent.swift b/Projects/Features/StorageFeature/Sources/Components/ListStorageComponent.swift index 3fb2c4118..311867607 100644 --- a/Projects/Features/StorageFeature/Sources/Components/ListStorageComponent.swift +++ b/Projects/Features/StorageFeature/Sources/Components/ListStorageComponent.swift @@ -12,7 +12,7 @@ import UIKit import UserDomainInterface public protocol ListStorageDependency: Dependency { - var multiPurposePopUpFactory: any MultiPurposePopupFactory { get } + var multiPurposePopupFactory: any MultiPurposePopupFactory { get } var playlistDetailFactory: any PlaylistDetailFactory { get } var createPlaylistUseCase: any CreatePlaylistUseCase { get } var editPlayListOrderUseCase: any EditPlaylistOrderUseCase { get } @@ -21,7 +21,7 @@ public protocol ListStorageDependency: Dependency { var fetchPlaylistSongsUseCase: any FetchPlaylistSongsUseCase { get } var fetchPlaylistCreationPriceUseCase: any FetchPlaylistCreationPriceUseCase { get } var logoutUseCase: any LogoutUseCase { get } - var textPopUpFactory: any TextPopUpFactory { get } + var textPopupFactory: any TextPopupFactory { get } var signInFactory: any SignInFactory { get } var fruitDrawFactory: any FruitDrawFactory { get } } @@ -38,8 +38,8 @@ public final class ListStorageComponent: Component { fetchPlaylistSongsUseCase: dependency.fetchPlaylistSongsUseCase, fetchPlaylistCreationPriceUseCase: dependency.fetchPlaylistCreationPriceUseCase ), - multiPurposePopUpFactory: dependency.multiPurposePopUpFactory, - textPopUpFactory: dependency.textPopUpFactory, + multiPurposePopupFactory: dependency.multiPurposePopupFactory, + textPopupFactory: dependency.textPopupFactory, playlistDetailFactory: dependency.playlistDetailFactory, signInFactory: dependency.signInFactory, fruitDrawFactory: dependency.fruitDrawFactory diff --git a/Projects/Features/StorageFeature/Sources/Components/StorageComponent.swift b/Projects/Features/StorageFeature/Sources/Components/StorageComponent.swift index bff28aed8..5c85727f5 100644 --- a/Projects/Features/StorageFeature/Sources/Components/StorageComponent.swift +++ b/Projects/Features/StorageFeature/Sources/Components/StorageComponent.swift @@ -9,8 +9,8 @@ import UserDomainInterface public protocol StorageDependency: Dependency { var signInFactory: any SignInFactory { get } - var textPopUpFactory: any TextPopUpFactory { get } - var multiPurposePopUpFactory: any MultiPurposePopupFactory { get } + var textPopupFactory: any TextPopupFactory { get } + var multiPurposePopupFactory: any MultiPurposePopupFactory { get } var listStorageComponent: ListStorageComponent { get } var likeStorageComponent: LikeStorageComponent { get } } @@ -22,9 +22,9 @@ public final class StorageComponent: Component, StorageFactor storageCommonService: DefaultStorageCommonService.shared ), listStorageComponent: dependency.listStorageComponent, - multiPurposePopUpFactory: dependency.multiPurposePopUpFactory, + multiPurposePopupFactory: dependency.multiPurposePopupFactory, likeStorageComponent: dependency.likeStorageComponent, - textPopUpFactory: dependency.textPopUpFactory, + textPopupFactory: dependency.textPopupFactory, signInFactory: dependency.signInFactory ) } diff --git a/Projects/Features/StorageFeature/Sources/Reactors/LikeStorageReactor.swift b/Projects/Features/StorageFeature/Sources/Reactors/LikeStorageReactor.swift index 2cf44e1e5..4dfabd9f7 100644 --- a/Projects/Features/StorageFeature/Sources/Reactors/LikeStorageReactor.swift +++ b/Projects/Features/StorageFeature/Sources/Reactors/LikeStorageReactor.swift @@ -124,6 +124,8 @@ final class LikeStorageReactor: Reactor { return deleteSongs() case .loginButtonDidTap: + let log = CommonAnalyticsLog.clickLoginButton(entry: .storageLike) + LogManager.analytics(log) return .just(.showLoginAlert) case .presentAddToPlaylistPopup: @@ -136,6 +138,13 @@ final class LikeStorageReactor: Reactor { .skip(1) .withUnretained(self) .flatMap { owner, editingState -> Observable in + let log = if !editingState { + CommonAnalyticsLog.clickEditButton(location: .playlist) + } else { + CommonAnalyticsLog.clickEditCompleteButton(location: .playlist) + } + LogManager.analytics(log) + // ํŽธ์ง‘์ด ์ข…๋ฃŒ๋  ๋•Œ ์ฒ˜๋ฆฌ if editingState == false { let new = owner.currentState.dataSource.flatMap { $0.items }.map { $0.songID } @@ -291,18 +300,10 @@ extension LikeStorageReactor { } func addToCurrentPlaylist() -> Observable { - let limit = 50 - let appendingPlaylisItems = currentState.dataSource .flatMap { $0.items.filter { $0.isSelected == true } } .map { PlaylistItem(id: $0.songID, title: $0.title, artist: $0.artist) } - if appendingPlaylisItems.count > limit { - let overFlowQuantity = appendingPlaylisItems.count - limit - let overFlowMessage = LocalizationStrings.overFlowAddPlaylistWarning(overFlowQuantity) - return .just(.showToast(overFlowMessage)) - } - PlayState.shared.append(contentsOf: appendingPlaylisItems) return .just(.showToast(LocalizationStrings.addList)) } @@ -310,7 +311,10 @@ extension LikeStorageReactor { func playWithAddToCurrentPlaylist(song: FavoriteSongEntity) -> Observable { let appendingPlaylisItem = PlaylistItem(id: song.songID, title: song.title, artist: song.artist) PlayState.shared.append(item: appendingPlaylisItem) - WakmusicYoutubePlayer(id: song.songID).play() + WakmusicYoutubePlayer( + id: song.songID, + playPlatform: song.title.isContainShortsTagTitle ? .youtube : .automatic + ).play() return .empty() } diff --git a/Projects/Features/StorageFeature/Sources/Reactors/ListStorageReactor.swift b/Projects/Features/StorageFeature/Sources/Reactors/ListStorageReactor.swift index 655c50619..6c4564e88 100644 --- a/Projects/Features/StorageFeature/Sources/Reactors/ListStorageReactor.swift +++ b/Projects/Features/StorageFeature/Sources/Reactors/ListStorageReactor.swift @@ -39,7 +39,7 @@ final class ListStorageReactor: Reactor { case switchEditingState(Bool) case updateIsLoggedIn(Bool) case updateIsShowActivityIndicator(Bool) - case showLoginAlert + case showLoginAlert(CommonAnalyticsLog.LoginButtonEntry?) case showToast(String) case showCreatePricePopup(Int) case showCreateListPopup @@ -57,7 +57,7 @@ final class ListStorageReactor: Reactor { var backupDataSource: [MyPlayListSectionModel] var selectedItemCount: Int var isShowActivityIndicator: Bool - @Pulse var showLoginAlert: Void? + @Pulse var showLoginAlert: CommonAnalyticsLog.LoginButtonEntry? @Pulse var showToast: String? @Pulse var hideSongCart: Void? @Pulse var showCreatePricePopup: Int? @@ -130,14 +130,18 @@ final class ListStorageReactor: Reactor { return tapAll(isSelecting) case .loginButtonDidTap: - return .just(.showLoginAlert) + let log = CommonAnalyticsLog.clickLoginButton(entry: .myPlaylist) + LogManager.analytics(log) + return .just(.showLoginAlert(.myPlaylist)) case .addToCurrentPlaylistButtonDidTap: return addToCurrentPlaylist() case .createListButtonDidTap: + let log = CommonAnalyticsLog.clickLoginButton(entry: .addMusics) + LogManager.analytics(log) let isLoggedIn = currentState.isLoggedIn - return isLoggedIn ? mutateFetchPlaylistCreationPrice() : .just(.showLoginAlert) + return isLoggedIn ? mutateFetchPlaylistCreationPrice() : .just(.showLoginAlert(.addMusics)) case .confirmCreatePriceButtonDidTap: return .just(.showCreateListPopup) @@ -154,7 +158,7 @@ final class ListStorageReactor: Reactor { case .drawFruitButtonDidTap: let isLoggedIn = currentState.isLoggedIn - return isLoggedIn ? .just(.showDrawFruitPopup) : .just(.showLoginAlert) + return isLoggedIn ? .just(.showDrawFruitPopup) : .just(.showLoginAlert(.fruitDraw)) case .completedFruitDraw: return completedFruitDraw() @@ -166,6 +170,13 @@ final class ListStorageReactor: Reactor { .skip(1) .withUnretained(self) .flatMap { owner, editingState -> Observable in + let log = if !editingState { + CommonAnalyticsLog.clickEditButton(location: .playlist) + } else { + CommonAnalyticsLog.clickEditCompleteButton(location: .playlist) + } + LogManager.analytics(log) + // ํŽธ์ง‘์ด ์ข…๋ฃŒ๋  ๋•Œ ์ฒ˜๋ฆฌ if editingState == false { let new = owner.currentState.dataSource.flatMap { $0.items }.map { $0.key } @@ -233,8 +244,8 @@ final class ListStorageReactor: Reactor { newState.isEditing = flag case let .updateIsLoggedIn(isLoggedIn): newState.isLoggedIn = isLoggedIn - case .showLoginAlert: - newState.showLoginAlert = () + case let .showLoginAlert(entry): + newState.showLoginAlert = entry case let .updateIsShowActivityIndicator(isShow): newState.isShowActivityIndicator = isShow case let .showToast(message): @@ -340,12 +351,6 @@ extension ListStorageReactor { return .just(.showToast("ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค.")) } - if selectedSongCount > limit { - let overFlowQuantity = selectedSongCount - limit - let overFlowMessage = LocalizationStrings.overFlowAddPlaylistWarning(overFlowQuantity) - return .just(.showToast(overFlowMessage)) - } - let keys = selectedPlaylists.map { $0.key } let observables = keys.map { key in @@ -388,21 +393,19 @@ extension ListStorageReactor { if selectedPlaylist.songCount == 0 { return .just(.showToast("ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค.")) } - - if selectedPlaylist.songCount > limit { - let overFlowQuantity = selectedPlaylist.songCount - limit - let overFlowMessage = LocalizationStrings.overFlowAddPlaylistWarning(overFlowQuantity) - return .just(.showToast(overFlowMessage)) - } - return Observable.concat( .just(.updateIsShowActivityIndicator(true)), fetchPlaylistSongsUseCase.execute(key: selectedPlaylist.key) .asObservable() .do(onNext: { [weak self] appendingPlaylistItems in PlayState.shared.appendSongsToPlaylist(appendingPlaylistItems) - let firstItem = appendingPlaylistItems.first! - WakmusicYoutubePlayer(id: firstItem.id).play() + let ids = appendingPlaylistItems.map { $0.id } + .prefix(50) + if appendingPlaylistItems.allSatisfy({ $0.title.isContainShortsTagTitle }) { + WakmusicYoutubePlayer(ids: Array(ids), title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง", playPlatform: .youtube).play() + } else { + WakmusicYoutubePlayer(ids: Array(ids), title: "์™ํƒ€๋ฒ„์Šค ๋ฎค์ง").play() + } self?.storageCommonService.isEditingState.onNext(false) }) .flatMap { songs -> Observable in diff --git a/Projects/Features/StorageFeature/Sources/Reactors/StorageReactor.swift b/Projects/Features/StorageFeature/Sources/Reactors/StorageReactor.swift index 5b1d0b923..9dd6afd12 100644 --- a/Projects/Features/StorageFeature/Sources/Reactors/StorageReactor.swift +++ b/Projects/Features/StorageFeature/Sources/Reactors/StorageReactor.swift @@ -1,4 +1,5 @@ import Foundation +import LogManager import ReactorKit import UserDomainInterface import Utility @@ -15,14 +16,14 @@ final class StorageReactor: Reactor { case updateIsLoggedIn(Bool) case switchTabIndex(Int) case switchEditingState(Bool) - case showLoginAlert + case showLoginAlert(CommonAnalyticsLog.LoginButtonEntry) } struct State { var isLoggedIn: Bool var isEditing: Bool var tabIndex: Int - @Pulse var showLoginAlert: Void? + @Pulse var showLoginAlert: CommonAnalyticsLog.LoginButtonEntry? } let initialState: State @@ -51,7 +52,7 @@ final class StorageReactor: Reactor { storageCommonService.isEditingState.onNext(true) return switchEditingState(true) } else { - return .just(.showLoginAlert) + return .just(.showLoginAlert(.myPlaylist)) } case .saveButtonTap: storageCommonService.isEditingState.onNext(false) @@ -84,8 +85,8 @@ final class StorageReactor: Reactor { newState.tabIndex = index case let .switchEditingState(flag): newState.isEditing = flag - case .showLoginAlert: - newState.showLoginAlert = () + case let .showLoginAlert(entry): + newState.showLoginAlert = entry case let .updateIsLoggedIn(isLoggedIn): newState.isLoggedIn = isLoggedIn } diff --git a/Projects/Features/StorageFeature/Sources/ViewControllers/LikeStorageViewController.swift b/Projects/Features/StorageFeature/Sources/ViewControllers/LikeStorageViewController.swift index 5f2d02142..a65b5b347 100644 --- a/Projects/Features/StorageFeature/Sources/ViewControllers/LikeStorageViewController.swift +++ b/Projects/Features/StorageFeature/Sources/ViewControllers/LikeStorageViewController.swift @@ -21,7 +21,7 @@ final class LikeStorageViewController: BaseReactorViewController LikeStorageViewController { let viewController = LikeStorageViewController(reactor: reactor) viewController.containSongsFactory = containSongsFactory - viewController.textPopUpFactory = textPopUpFactory + viewController.textPopupFactory = textPopupFactory viewController.signInFactory = signInFactory viewController.songDetailPresenter = songDetailPresenter return viewController @@ -61,7 +66,7 @@ final class LikeStorageViewController: BaseReactorViewController final class ListStorageViewController: BaseReactorViewController, SongCartViewType, PlaylistDetailNavigator { - let listStorageView = ListStorageView() + private let createListButton = CreateListButtonView( + padding: .init( + top: 16, + left: 20, + bottom: 12, + right: 20 + ) + ) + private let listStorageView = ListStorageView() - var multiPurposePopUpFactory: MultiPurposePopupFactory - var textPopUpFactory: TextPopUpFactory + var multiPurposePopupFactory: MultiPurposePopupFactory + var textPopupFactory: TextPopupFactory var playlistDetailFactory: any PlaylistDetailFactory var signInFactory: SignInFactory var fruitDrawFactory: FruitDrawFactory @@ -32,14 +40,14 @@ final class ListStorageViewController: BaseReactorViewController CGFloat { + return 80 // height(52) + top inset(16) + bottom inset(12) + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return createListButton + } + public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return 60 } diff --git a/Projects/Features/StorageFeature/Sources/ViewControllers/StorageViewController.swift b/Projects/Features/StorageFeature/Sources/ViewControllers/StorageViewController.swift index 67ed98d32..fbe1d73f4 100644 --- a/Projects/Features/StorageFeature/Sources/ViewControllers/StorageViewController.swift +++ b/Projects/Features/StorageFeature/Sources/ViewControllers/StorageViewController.swift @@ -2,6 +2,7 @@ import BaseFeature import BaseFeatureInterface import DesignSystem import Localization +import LogManager import Pageboy import ReactorKit import RxSwift @@ -16,9 +17,9 @@ final class StorageViewController: TabmanViewController, View { private var bottomSheetView: BottomSheetView! private var listStorageComponent: ListStorageComponent! - private var multiPurposePopUpFactory: MultiPurposePopupFactory! + private var multiPurposePopupFactory: MultiPurposePopupFactory! private var likeStorageComponent: LikeStorageComponent! - private var textPopUpFactory: TextPopUpFactory! + private var textPopupFactory: TextPopupFactory! private var signInFactory: SignInFactory! private var viewControllers: [UIViewController]! @@ -58,18 +59,18 @@ final class StorageViewController: TabmanViewController, View { public static func viewController( reactor: Reactor, listStorageComponent: ListStorageComponent, - multiPurposePopUpFactory: MultiPurposePopupFactory, + multiPurposePopupFactory: MultiPurposePopupFactory, likeStorageComponent: LikeStorageComponent, - textPopUpFactory: TextPopUpFactory, + textPopupFactory: TextPopupFactory, signInFactory: SignInFactory ) -> StorageViewController { let viewController = StorageViewController(reactor: reactor) viewController.listStorageComponent = listStorageComponent - viewController.multiPurposePopUpFactory = multiPurposePopUpFactory + viewController.multiPurposePopupFactory = multiPurposePopupFactory viewController.likeStorageComponent = likeStorageComponent viewController.viewControllers = [listStorageComponent.makeView(), likeStorageComponent.makeView()] - viewController.textPopUpFactory = textPopUpFactory + viewController.textPopupFactory = textPopupFactory viewController.signInFactory = signInFactory return viewController } @@ -82,7 +83,28 @@ final class StorageViewController: TabmanViewController, View { animated: Bool ) { self.reactor?.action.onNext(.switchTab(index)) - NotificationCenter.default.post(name: .didChangeTabInStorage, object: index) + // ํƒญ ์ด๋™ ๊ฐ„ ํ”Œ๋กœํŒ… ๋ฒ„ํŠผ ์œ„์น˜ ์กฐ์ • + NotificationCenter.default.post( + name: .shouldMovePlaylistFloatingButton, + object: index == 0 ? + PlaylistFloatingButtonPosition.top : + PlaylistFloatingButtonPosition.default + ) + } + + /// ํƒญ๋งจ ํƒญ ํ„ฐ์น˜ ์ด๋ฒคํŠธ ๊ฐ์ง€ ํ•จ์ˆ˜ + override func bar(_ bar: any TMBar, didRequestScrollTo index: PageboyViewController.PageIndex) { + super.bar(bar, didRequestScrollTo: index) + + guard let viewController = viewControllers[safe: index] else { return } + switch viewController { + case is ListStorageViewController: + LogManager.analytics(StorageAnalyticsLog.clickStorageTabbarTab(tab: .myPlaylist)) + case is LikeStorageViewController: + LogManager.analytics(StorageAnalyticsLog.clickStorageTabbarTab(tab: .myLikeList)) + default: + break + } } } @@ -104,13 +126,16 @@ extension StorageViewController { reactor.pulse(\.$showLoginAlert) .compactMap { $0 } - .bind(with: self, onNext: { owner, _ in - guard let vc = owner.textPopUpFactory.makeView( + .bind(with: self, onNext: { owner, entry in + guard let vc = owner.textPopupFactory.makeView( text: LocalizationStrings.needLoginWarning, cancelButtonIsHidden: false, confirmButtonText: nil, cancelButtonText: nil, completion: { + let log = CommonAnalyticsLog.clickLoginButton(entry: entry) + LogManager.analytics(log) + let loginVC = owner.signInFactory.makeView() loginVC.modalPresentationStyle = .fullScreen owner.present(loginVC, animated: true) @@ -126,6 +151,14 @@ extension StorageViewController { func bindAction(reactor: Reactor) { storageView.rx.editButtonDidTap + .do(onNext: { + let tabIndex = reactor.currentState.tabIndex + switch tabIndex { + case 0: LogManager.analytics(StorageAnalyticsLog.clickMyPlaylistEditButton) + case 1: LogManager.analytics(StorageAnalyticsLog.clickMyLikeListEditButton) + default: break + } + }) .map { Reactor.Action.editButtonDidTap } .bind(to: reactor.action) .disposed(by: disposeBag) @@ -179,7 +212,7 @@ extension StorageViewController: PageboyViewControllerDataSource, TMBarDataSourc for pageboyViewController: Pageboy.PageboyViewController, at index: Pageboy.PageboyViewController.PageIndex ) -> UIViewController? { - viewControllers[index] + return viewControllers[safe: index] } public func defaultPage(for pageboyViewController: Pageboy.PageboyViewController) -> Pageboy.PageboyViewController diff --git a/Projects/Features/StorageFeature/Sources/Views/CreateListButton.swift b/Projects/Features/StorageFeature/Sources/Views/CreateListButton.swift index 98cfe6a1d..80c7114e8 100644 --- a/Projects/Features/StorageFeature/Sources/Views/CreateListButton.swift +++ b/Projects/Features/StorageFeature/Sources/Views/CreateListButton.swift @@ -4,7 +4,19 @@ import Then import UIKit import Utility -final class CreateListButton: UIButton { +final class CreateListButtonView: UIView { + private let baseView = UIView().then { + $0.layer.cornerRadius = 8 + $0.layer.borderColor = DesignSystemAsset.BlueGrayColor.blueGray200.color.withAlphaComponent(0.4).cgColor + $0.layer.borderWidth = 1 + $0.backgroundColor = .white.withAlphaComponent(0.4) + $0.clipsToBounds = true + } + + private let translucentView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)).then { + $0.layer.cornerRadius = 8 + } + private let image = UIImageView().then { $0.image = DesignSystemAsset.Storage.storageNewPlaylistAdd.image } @@ -17,25 +29,45 @@ final class CreateListButton: UIButton { kernValue: -0.5 ) - override init(frame: CGRect) { - super.init(frame: frame) + let button = UIButton() + + private let padding: UIEdgeInsets + + init(padding: UIEdgeInsets = .zero) { + self.padding = padding + super.init(frame: .zero) addView() setLayout() configureUI() } + @available(*, unavailable) required init?(coder: NSCoder) { - super.init(coder: coder) + fatalError("init(coder:) has not been implemented") } private func addView() { - self.addSubviews( + self.addSubview(baseView) + baseView.addSubviews( + translucentView, image, - title + title, + button ) } private func setLayout() { + baseView.snp.makeConstraints { + $0.top.equalToSuperview().inset(padding.top) + $0.leading.equalToSuperview().inset(padding.left) + $0.trailing.equalToSuperview().inset(padding.right) + $0.bottom.equalToSuperview().inset(padding.bottom) + } + + translucentView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + image.snp.makeConstraints { $0.width.height.equalTo(32) $0.centerY.equalToSuperview() @@ -45,14 +77,13 @@ final class CreateListButton: UIButton { title.snp.makeConstraints { $0.center.equalToSuperview() } + + button.snp.makeConstraints { + $0.edges.equalToSuperview() + } } private func configureUI() { - self.layer.cornerRadius = 8 - self.layer.borderWidth = 1 - self.setBackgroundColor(.white.withAlphaComponent(0.4), for: .normal) - self.setBackgroundColor(.lightGray, for: .selected) - self.layer.borderColor = DesignSystemAsset.BlueGrayColor.blueGray200.color.cgColor.copy(alpha: 0.4) - self.clipsToBounds = true + self.backgroundColor = .clear } } diff --git a/Projects/Features/StorageFeature/Sources/Views/LikeStorageTableViewCell.swift b/Projects/Features/StorageFeature/Sources/Views/LikeStorageTableViewCell.swift index efb2510d1..822f1a1ac 100644 --- a/Projects/Features/StorageFeature/Sources/Views/LikeStorageTableViewCell.swift +++ b/Projects/Features/StorageFeature/Sources/Views/LikeStorageTableViewCell.swift @@ -14,7 +14,6 @@ public protocol LikeStorageTableViewCellDelegate: AnyObject { public enum LikeStorageTableViewCellDelegateConstant { case cellTapped(indexPath: IndexPath) case playTapped(song: FavoriteSongEntity) - case thumbnailTapped(song: FavoriteSongEntity) } class LikeStorageTableViewCell: UITableViewCell { @@ -152,18 +151,10 @@ private extension LikeStorageTableViewCell { func setAction() { self.cellSelectButton.addTarget(self, action: #selector(cellSelectButtonAction), for: .touchUpInside) self.playButton.addTarget(self, action: #selector(playButtonAction), for: .touchUpInside) - - let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(albumImageDidTapAction)) - self.albumImageView.addGestureRecognizer(tapGestureRecognizer) } } private extension LikeStorageTableViewCell { - @objc func albumImageDidTapAction() { - guard let model else { return } - delegate?.buttonTapped(type: .thumbnailTapped(song: model)) - } - @objc func playButtonAction() { guard let model else { return } delegate?.buttonTapped(type: .playTapped(song: model)) diff --git a/Projects/Features/StorageFeature/Sources/Views/LikeStorageView.swift b/Projects/Features/StorageFeature/Sources/Views/LikeStorageView.swift index 32185ca90..e8c10dfd5 100644 --- a/Projects/Features/StorageFeature/Sources/Views/LikeStorageView.swift +++ b/Projects/Features/StorageFeature/Sources/Views/LikeStorageView.swift @@ -85,7 +85,7 @@ final class LikeStorageView: UIView { func configureUI() { backgroundColor = DesignSystemAsset.BlueGrayColor.blueGray100.color tableView.refreshControl = refreshControl - tableView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 0, left: 0, bottom: 56, right: 0) + tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 56, right: 0) loginWarningView.isHidden = true activityIndicator.isHidden = true activityIndicator.stopAnimating() diff --git a/Projects/Features/StorageFeature/Sources/Views/ListStorageTableViewCell.swift b/Projects/Features/StorageFeature/Sources/Views/ListStorageTableViewCell.swift index f2139e8ff..b24e9adf8 100644 --- a/Projects/Features/StorageFeature/Sources/Views/ListStorageTableViewCell.swift +++ b/Projects/Features/StorageFeature/Sources/Views/ListStorageTableViewCell.swift @@ -13,9 +13,9 @@ public protocol ListStorageTableViewCellDelegate: AnyObject { } public enum ListStorageTableViewCellDelegateConstant { - case cellTapped(indexPath: IndexPath) - case listTapped(indexPath: IndexPath) - case playTapped(indexPath: IndexPath) + case cellTapped((indexPath: IndexPath, key: String)) + case listTapped((indexPath: IndexPath, key: String)) + case playTapped((indexPath: IndexPath, key: String)) } class ListStorageTableViewCell: UITableViewCell { @@ -35,12 +35,6 @@ class ListStorageTableViewCell: UITableViewCell { $0.lineBreakMode = .byTruncatingTail } - private let countLabel = WMLabel( - text: "", - textColor: DesignSystemAsset.BlueGrayColor.blueGray300.color, - font: .t7(weight: .light), - kernValue: -0.5 - ) private let verticalStackView = UIStackView().then { $0.axis = .vertical } @@ -57,6 +51,20 @@ class ListStorageTableViewCell: UITableViewCell { ) } + private let lockImageView: UIImageView = UIImageView().then { + $0.image = DesignSystemAsset.Storage.storageClose.image + $0.isHidden = true + } + + private let countLabel = WMLabel( + text: "", + textColor: DesignSystemAsset.BlueGrayColor.blueGray300.color, + font: .t7(weight: .light), + kernValue: -0.5 + ) + + private let countContainerView: UIView = UIView() + private let cellSelectButton = UIButton() private let listSelectButton = UIButton() @@ -85,10 +93,8 @@ extension ListStorageTableViewCell { cellSelectButton, listSelectButton ) - verticalStackView.addArrangedSubviews( - nameLabel, - countLabel - ) + verticalStackView.addArrangedSubviews(nameLabel, countContainerView) + countContainerView.addSubviews(countLabel, lockImageView) } func setLayout() { @@ -128,6 +134,14 @@ extension ListStorageTableViewCell { countLabel.snp.makeConstraints { $0.height.equalTo(18) + $0.top.bottom.equalToSuperview() + $0.leading.equalToSuperview() + } + + lockImageView.snp.makeConstraints { + $0.leading.equalTo(countLabel.snp.trailing) + $0.width.height.equalTo(16) + $0.bottom.equalToSuperview().offset(-1) } } @@ -160,6 +174,7 @@ extension ListStorageTableViewCell { self.cellSelectButton.isHidden = !isEditing self.listSelectButton.isHidden = isEditing self.playButton.isHidden = isEditing + self.lockImageView.isHidden = !model.private self.playButton.snp.updateConstraints { $0.right.equalToSuperview().inset(isEditing ? -24 : 20) @@ -184,14 +199,14 @@ extension ListStorageTableViewCell { extension ListStorageTableViewCell { @objc func cellSelectButtonAction() { - delegate?.buttonTapped(type: .cellTapped(indexPath: passToModel.0)) + delegate?.buttonTapped(type: .cellTapped(passToModel)) } @objc func listSelectButtonAction() { - delegate?.buttonTapped(type: .listTapped(indexPath: passToModel.0)) + delegate?.buttonTapped(type: .listTapped(passToModel)) } @objc func playButtonAction() { - delegate?.buttonTapped(type: .playTapped(indexPath: passToModel.0)) + delegate?.buttonTapped(type: .playTapped(passToModel)) } } diff --git a/Projects/Features/StorageFeature/Sources/Views/ListStorageView.swift b/Projects/Features/StorageFeature/Sources/Views/ListStorageView.swift index 88ac97b17..5aae9c258 100644 --- a/Projects/Features/StorageFeature/Sources/Views/ListStorageView.swift +++ b/Projects/Features/StorageFeature/Sources/Views/ListStorageView.swift @@ -20,19 +20,17 @@ private protocol ListStorageStateProtocol { func updateIsEnabledRefreshControl(isEnabled: Bool) func updateIsHiddenLoginWarningView(isHidden: Bool) func updateIsHiddenEmptyWarningView(isHidden: Bool) - func resetParticeAnimation() + func startParticeAnimation() + func removeParticeAnimation() } private protocol ListStorageActionProtocol { var loginButtonDidTap: Observable { get } - var createListButtonDidTap: Observable { get } var refreshControlValueChanged: Observable { get } var drawFruitButtonDidTap: Observable { get } } final class ListStorageView: UIView { - let createListButton = CreateListButton(frame: .zero) - let tableView = UITableView().then { $0.backgroundColor = .clear $0.register(ListStorageTableViewCell.self, forCellReuseIdentifier: ListStorageTableViewCell.reuseIdentifer) @@ -69,9 +67,15 @@ final class ListStorageView: UIView { fatalError("init(coder:) has not been implemented") } + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = drawFruitButton.bounds + } +} + +private extension ListStorageView { func addView() { self.addSubviews( - createListButton, tableView, drawFruitButton, particleAnimationView, @@ -81,13 +85,8 @@ final class ListStorageView: UIView { } func setLayout() { - createListButton.snp.makeConstraints { - $0.height.equalTo(52) - $0.top.equalTo(safeAreaLayoutGuide).offset(68) - $0.horizontalEdges.equalToSuperview().inset(20) - } tableView.snp.makeConstraints { - $0.top.equalTo(createListButton.snp.bottom).offset(12) + $0.top.equalTo(safeAreaLayoutGuide).offset(68 - 16) $0.horizontalEdges.equalToSuperview() $0.bottom.equalTo(drawFruitButton.snp.top) } @@ -104,7 +103,7 @@ final class ListStorageView: UIView { loginWarningView.snp.makeConstraints { $0.width.equalTo(164) $0.height.equalTo(176) - $0.top.equalTo(createListButton.snp.bottom).offset(80) + $0.top.equalTo(tableView.snp.top).offset(56 + 80) $0.centerX.equalToSuperview() } activityIndicator.snp.makeConstraints { @@ -117,6 +116,7 @@ final class ListStorageView: UIView { backgroundColor = DesignSystemAsset.BlueGrayColor.blueGray100.color tableView.refreshControl = refreshControl tableView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 0, left: 0, bottom: 56, right: 0) + tableView.sectionHeaderTopPadding = 0 loginWarningView.isHidden = true activityIndicator.isHidden = true @@ -138,16 +138,15 @@ final class ListStorageView: UIView { gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) drawFruitButton.layer.addSublayer(gradientLayer) } - - override func layoutSubviews() { - super.layoutSubviews() - gradientLayer.frame = drawFruitButton.bounds - } } extension ListStorageView: ListStorageStateProtocol { - func resetParticeAnimation() { - particleAnimationView.resetAnimation() + func startParticeAnimation() { + particleAnimationView.startAnimation() + } + + func removeParticeAnimation() { + particleAnimationView.removeAnimation() } func updateIsHiddenEmptyWarningView(isHidden: Bool) { @@ -202,8 +201,4 @@ extension Reactive: ListStorageActionProtocol where Base: ListStorageView { var refreshControlValueChanged: Observable { base.refreshControl.rx.controlEvent(.valueChanged).map { () }.asObservable() } - - var createListButtonDidTap: Observable { - base.createListButton.rx.tap.asObservable() - } } diff --git a/Projects/Features/StorageFeature/Sources/Views/ParticleAnimationView.swift b/Projects/Features/StorageFeature/Sources/Views/ParticleAnimationView.swift index 87c93bea6..c406a7b67 100644 --- a/Projects/Features/StorageFeature/Sources/Views/ParticleAnimationView.swift +++ b/Projects/Features/StorageFeature/Sources/Views/ParticleAnimationView.swift @@ -152,11 +152,6 @@ private extension ParticleAnimationView { } extension ParticleAnimationView: ParticleAnimationStateProtocol { - @objc func resetAnimation() { - removeAnimation() - startAnimation() - } - @objc func removeAnimation() { self.subviews.forEach { $0.removeAllAnimations() } } diff --git a/Projects/Features/TeamFeature/Sources/ViewControllers/TeamInfoViewController.swift b/Projects/Features/TeamFeature/Sources/ViewControllers/TeamInfoViewController.swift index eeb0a1dcd..b24112176 100644 --- a/Projects/Features/TeamFeature/Sources/ViewControllers/TeamInfoViewController.swift +++ b/Projects/Features/TeamFeature/Sources/ViewControllers/TeamInfoViewController.swift @@ -151,6 +151,7 @@ private extension TeamInfoViewController { bar.layout.contentInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) bar.layout.contentMode = .fit bar.layout.transitionStyle = .progressive + bar.layout.interButtonSpacing = 0 // ๋ฒ„ํŠผ ๊ธ€์”จ ์ปค์Šคํ…€ bar.buttons.customize { button in diff --git a/Projects/Modules/FeatureThirdPartyLib/Project.swift b/Projects/Modules/FeatureThirdPartyLib/Project.swift index 7dc79725c..407cc787d 100644 --- a/Projects/Modules/FeatureThirdPartyLib/Project.swift +++ b/Projects/Modules/FeatureThirdPartyLib/Project.swift @@ -20,7 +20,6 @@ let project = Project.module( .SPM.RxDataSources, .SPM.RxKeyboard, .SPM.SwiftEntryKit, - .SPM.CryptoSwift, .SPM.NVActivityIndicatorView ]) ] diff --git a/Projects/Modules/LogManager/Sources/AnalyticsLogManager.swift b/Projects/Modules/LogManager/Sources/AnalyticsLogManager.swift index 8c63d1b68..264276b68 100644 --- a/Projects/Modules/LogManager/Sources/AnalyticsLogManager.swift +++ b/Projects/Modules/LogManager/Sources/AnalyticsLogManager.swift @@ -1,4 +1,6 @@ import FirebaseAnalytics +import FirebaseCrashlytics +import FirebaseCrashlyticsSwift import Foundation import OSLog import ThirdPartyLib @@ -18,7 +20,7 @@ private extension LogManager { function: String = #function, line: Int = #line ) { - #if DEBUG + #if DEBUG || QA let logger = Logger(subsystem: OSLog.subSystem, category: level.category) let fileName = file.components(separatedBy: "/").last ?? "unknown.swift" @@ -70,6 +72,7 @@ public extension LogManager { line: Int = #line ) { Analytics.setUserID(userID) + Crashlytics.crashlytics().setUserID(userID) LogManager.printDebug( "Set Analytics UserID : \(String(describing: userID))", @@ -79,6 +82,38 @@ public extension LogManager { ) } + static func setUserProperty( + property: AnalyticsUserProperty, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + Analytics.setUserProperty(property.value, forName: property.key) + + LogManager.printDebug( + "Set User Property : [ \(property.key) = \(String(describing: property.value)) ]", + file: file, + function: function, + line: line + ) + } + + static func clearUserProperty( + property: AnalyticsUserProperty, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + Analytics.setUserProperty(nil, forName: property.key) + + LogManager.printDebug( + "Set User Property : [ \(property.key) = nil ]", + file: file, + function: function, + line: line + ) + } + static func printDebug( _ message: Any, file: String = #file, @@ -101,6 +136,9 @@ public extension LogManager { Analytics.logEvent(log.name, parameters: log.params) #elseif DEBUG LogHistoryStorage.shared.appendHistory(log: log) + #elseif QA + Analytics.logEvent(log.name, parameters: log.params) + LogHistoryStorage.shared.appendHistory(log: log) #endif let message = """ \(log.name) logged @@ -123,4 +161,36 @@ public extension LogManager { line: line ) } + + static func sendError( + message: String, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + Crashlytics.crashlytics().log(message) + LogManager.log( + message, + level: .error, + file: file, + function: function, + line: line + ) + } + + static func sendError( + error: any Error, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + Crashlytics.crashlytics().record(error: error) + LogManager.log( + error, + level: .error, + file: file, + function: function, + line: line + ) + } } diff --git a/Projects/Modules/LogManager/Sources/AnalyticsUserProperty.swift b/Projects/Modules/LogManager/Sources/AnalyticsUserProperty.swift new file mode 100644 index 000000000..c4994fb33 --- /dev/null +++ b/Projects/Modules/LogManager/Sources/AnalyticsUserProperty.swift @@ -0,0 +1,73 @@ +import Foundation + +public protocol UserPropertyRepresentable { + var key: String { get } + var value: String? { get } +} + +public enum AnalyticsUserProperty { + case loginPlatform(platform: String) + case fruitTotal(count: Int) + case playlistSongTotal(count: Int) + case latestSearchKeyword(keyword: String) + case songPlayPlatform(platform: String) +} + +extension AnalyticsUserProperty: UserPropertyRepresentable { + public var key: String { + Mirror(reflecting: self) + .children + .first? + .label? + .toSnakeCase() ?? String(describing: self).toSnakeCase() + } + + public var value: String? { + switch self { + case let .loginPlatform(platform): return platform + case let .fruitTotal(count): return "\(count)" + case let .playlistSongTotal(count): return "\(count)" + case let .latestSearchKeyword(keyword): return keyword + case let .songPlayPlatform(platform): return platform + } + } +} + +private extension String { + func toSnakeCase() -> String { + var result = "" + var previousStringWasCapitalized = false + var previousStringWasNumber = false + + for (index, string) in self.enumerated() { + var mutableString = String(string) + + if !mutableString.isAlphabet { + if index != 0, + !previousStringWasNumber { + mutableString = "_" + mutableString + } + previousStringWasNumber = true + } else if mutableString == mutableString.uppercased() { + mutableString = mutableString.lowercased() + + // e.g. JSON์œผ๋กœ ์˜ค๋ฉด j_s_o_n ์ด๋Ÿฐ์‹์ด ์•„๋‹Œ json ์ด๋ ‡๊ฒŒ ๋ฐ”๋€Œ๋„๋ก + if index != 0, + !previousStringWasCapitalized { + mutableString = "_" + mutableString + } + previousStringWasCapitalized = true + } else { + previousStringWasCapitalized = false + previousStringWasNumber = false + } + result += mutableString + } + return result + } + + var isAlphabet: Bool { + let alphabetSet = CharacterSet.uppercaseLetters.union(.lowercaseLetters).union(.whitespacesAndNewlines) + return self.rangeOfCharacter(from: alphabetSet.inverted) == nil + } +} diff --git a/Projects/Modules/LogManager/Sources/CommonAnalyticsLog.swift b/Projects/Modules/LogManager/Sources/CommonAnalyticsLog.swift index 019b79995..11ae089f0 100644 --- a/Projects/Modules/LogManager/Sources/CommonAnalyticsLog.swift +++ b/Projects/Modules/LogManager/Sources/CommonAnalyticsLog.swift @@ -1,12 +1,26 @@ import Foundation +public protocol AnalyticsLogEnumParametable: RawRepresentable, CustomStringConvertible + where RawValue == String {} + +public extension AnalyticsLogEnumParametable { + var description: String { + self.rawValue + } +} + public enum CommonAnalyticsLog: AnalyticsLogType { case viewPage(pageName: PageName) - case clickPlaylistItem(location: PlaylistItemLocation) + case clickPlaylistItem(location: PlaylistItemLocation, key: String) + case clickPlayButton(location: PlayButtonLocation, type: PlayButtonType) + case clickAddMusicsButton(location: AddMusicLocation) + case clickEditButton(location: EditButtonLocation) + case clickEditCompleteButton(location: EditButtonLocation) + case clickLoginButton(entry: LoginButtonEntry) } public extension CommonAnalyticsLog { - enum PageName: String, CustomStringConvertible { + enum PageName: String, AnalyticsLogEnumParametable { case home case musicDetail = "music_detail" case musicLyrics = "music_lyrics" @@ -17,26 +31,85 @@ public extension CommonAnalyticsLog { case myPlaylistDetail = "my_playlist_detail" case chart case artist - case storage + case storagePlaylist = "storage_playlist" + case storageLike = "storage_like" case search case fruitDraw = "fruit_draw" // ์—ด๋งค ๋ฝ‘๊ธฐ case playlist - case mypage + case myPage = "my_page" case setting case login + case songCredit = "song_credit" + case creditSongList = "credit_song_list" + case noticePopup = "notice_popup" public var description: String { self.rawValue } } - enum PlaylistItemLocation: String, CustomStringConvertible { + enum PlaylistItemLocation: String, AnalyticsLogEnumParametable { case home case storage case search + case searchResult = "search_result" public var description: String { self.rawValue } } + + enum PlayButtonLocation: String, AnalyticsLogEnumParametable { + case home + case search + case artist + case playlist + case storagePlaylist = "storage_playlist" + case storageLike = "storage_like" + case playlistDetail = "playlist_detail" + case chart + case recentMusic = "recent_music" + case musicDetail = "music_detail" + case creditSongList = "credit_song_list" + } + + enum PlayButtonType: String, AnalyticsLogEnumParametable { + case single + case multiple + case all + case random + case range1to50 + case range50to100 + case playlist + } + + enum AddMusicLocation: String, AnalyticsLogEnumParametable { + case songDetail = "song_detail" + case search + case chart + case recentMusic = "recent_music" + case artist + case playlist + case playlistDetail = "playlist_detail" + case storageLike = "storage_like" + } + + enum EditButtonLocation: String, AnalyticsLogEnumParametable { + case playlistDetail = "playlist_detail" + case myPlaylist = "my_playlist" + case storageLike = "storage_like" + case playlist + } + + enum LoginButtonEntry: String, AnalyticsLogEnumParametable { + case myPlaylist = "my_playlist" + case storageLike = "storage_like" + case mypage + case fruitStorage = "fruit_storage" + case fruitDraw = "fruit_draw" + case songLike = "song_like" + case addMusics = "add_musics" + case playlistSubscribe = "playlist_subscribe" + case artistSubscribe = "artist_subscribe" + } } diff --git a/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryCell.swift b/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryCell.swift index 4cceaa4bd..f7ba1c80c 100644 --- a/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryCell.swift +++ b/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryCell.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || QA import UIKit final class LogHistoryCell: UITableViewCell { diff --git a/Projects/Modules/LogManager/Sources/LogHistory/LogHistorySectionItem.swift b/Projects/Modules/LogManager/Sources/LogHistory/LogHistorySectionItem.swift index 9c36ef5a7..616e7f704 100644 --- a/Projects/Modules/LogManager/Sources/LogHistory/LogHistorySectionItem.swift +++ b/Projects/Modules/LogManager/Sources/LogHistory/LogHistorySectionItem.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || QA import Foundation struct LogHistorySectionItem: Hashable, Equatable { diff --git a/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryStorage.swift b/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryStorage.swift index 4889264a4..0b3d8f41d 100644 --- a/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryStorage.swift +++ b/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryStorage.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || QA import Foundation final class LogHistoryStorage { diff --git a/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryViewController.swift b/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryViewController.swift index 8e1b6dddf..bde99e2ee 100644 --- a/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryViewController.swift +++ b/Projects/Modules/LogManager/Sources/LogHistory/LogHistoryViewController.swift @@ -1,4 +1,4 @@ -#if DEBUG +#if DEBUG || QA import UIKit public final class LogHistoryViewController: UIViewController { diff --git a/Projects/Modules/ThirdPartyLib/Project.swift b/Projects/Modules/ThirdPartyLib/Project.swift index b6ae06dad..f08d13b22 100644 --- a/Projects/Modules/ThirdPartyLib/Project.swift +++ b/Projects/Modules/ThirdPartyLib/Project.swift @@ -17,6 +17,7 @@ let project = Project.module( .SPM.Moya, .SPM.FirebaseAnalytics, .SPM.FirebaseCrashlytics, + .SPM.CryptoSwift, .SPM.FirebaseMessaging ] ) diff --git a/Projects/Modules/Utility/Sources/API/WMImageAPI.swift b/Projects/Modules/Utility/Sources/API/WMImageAPI.swift index 55531b8e0..fcb15fad8 100644 --- a/Projects/Modules/Utility/Sources/API/WMImageAPI.swift +++ b/Projects/Modules/Utility/Sources/API/WMImageAPI.swift @@ -1,83 +1,35 @@ -// -// WMImageAPI.swift -// Utility -// -// Created by KTH on 2023/02/10. -// Copyright ร‚ยฉ 2023 yongbeomkwak. All rights reserved. -// - import Foundation public enum WMImageAPI { - case fetchNewsThumbnail(time: String) - case fetchArtistWithRound(id: String, version: Int) - case fetchArtistWithSquare(id: String, version: Int) - case fetchPlayList(id: String, version: Int) - case fetchRecommendPlaylistWithRound(id: String, version: Int) - case fetchRecommendPlaylistWithSquare(id: String, version: Int) case fetchYoutubeThumbnail(id: String) case fetchYoutubeThumbnailHD(id: String) - case fetchNotice(id: String) } public extension WMImageAPI { var baseURLString: String { - return BASE_IMAGE_URL() - } - - var youtubeBaseURLString: String { return "https://i.ytimg.com" } var path: String { switch self { - case let .fetchNewsThumbnail(time): - return WMDOMAIN_IMAGE_NEWS() + "/\(time).png" - - case let .fetchArtistWithRound(id, version): - return WMDOMAIN_IMAGE_ARTIST_ROUND() + "/\(id).png?v=\(version)" - - case let .fetchArtistWithSquare(id, version): - return WMDOMAIN_IMAGE_ARTIST_SQUARE() + "/\(id).png?v=\(version)" - - case let .fetchPlayList(id, version): - return WMDOMAIN_IMAGE_PLAYLIST() + "/\(id).png?v=\(version)" - - case let .fetchRecommendPlaylistWithSquare(id, version): - return WMDOMAIN_IMAGE_RECOMMEND_PLAYLIST_SQUARE() + "/\(id).png?v=\(version)" - - case let .fetchRecommendPlaylistWithRound(id, version): - return WMDOMAIN_IMAGE_RECOMMEND_PLAYLIST_ROUND() + "/\(id).png?v=\(version)" - case let .fetchYoutubeThumbnail(id): return "vi/\(id)/mqdefault.jpg" case let .fetchYoutubeThumbnailHD(id): return "vi/\(id)/maxresdefault.jpg" - - case let .fetchNotice(id): - return WMDOMAIN_IMAGE_NOTICE() + "/\(id)" } } var toString: String { switch self { - case .fetchYoutubeThumbnail: - return youtubeBaseURLString + "/" + path - case .fetchYoutubeThumbnailHD: - return youtubeBaseURLString + "/" + path - default: + case .fetchYoutubeThumbnail, .fetchYoutubeThumbnailHD: return baseURLString + "/" + path } } var toURL: URL? { switch self { - case .fetchYoutubeThumbnail: - return URL(string: youtubeBaseURLString + "/" + path) - case .fetchYoutubeThumbnailHD: - return URL(string: youtubeBaseURLString + "/" + path) - default: + case .fetchYoutubeThumbnail, .fetchYoutubeThumbnailHD: return URL(string: baseURLString + "/" + path) } } diff --git a/Projects/Modules/Utility/Sources/DeepLink/DeepLinkGenerator.swift b/Projects/Modules/Utility/Sources/DeepLink/DeepLinkGenerator.swift index 53bee103c..e31a2a0d8 100644 --- a/Projects/Modules/Utility/Sources/DeepLink/DeepLinkGenerator.swift +++ b/Projects/Modules/Utility/Sources/DeepLink/DeepLinkGenerator.swift @@ -8,7 +8,6 @@ public final class WMDeepLinkGenerator: WMDeepLinkGeneratable { public init() {} public func generatePlaylistDeepLink(key: String) -> String { - #warning("๋‚˜์ค‘์— ๋”ฅ๋งํฌ ๋ณ€๊ฒฝํ•˜๊ธฐ") - return "https://\(WM_UNIVERSALLINK_TEST_DOMAIN())/playlist/\(key)" + return "https://\(WM_UNIVERSALLINK_DOMAIN())/playlist/\(key)" } } diff --git a/Projects/Modules/Utility/Sources/Enums/PlaylistFloatingButtonPosition.swift b/Projects/Modules/Utility/Sources/Enums/PlaylistFloatingButtonPosition.swift new file mode 100644 index 000000000..1c72b3c7e --- /dev/null +++ b/Projects/Modules/Utility/Sources/Enums/PlaylistFloatingButtonPosition.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum PlaylistFloatingButtonPosition { + case `default` + case top + + public var bottomOffset: CGFloat { + switch self { + case .default: + return -20 + case .top: + return -80 + } + } +} diff --git a/Projects/Modules/Utility/Sources/Enums/YoutubePlayType.swift b/Projects/Modules/Utility/Sources/Enums/YoutubePlayType.swift new file mode 100644 index 000000000..2743af89d --- /dev/null +++ b/Projects/Modules/Utility/Sources/Enums/YoutubePlayType.swift @@ -0,0 +1,13 @@ +import Foundation + +public enum YoutubePlayType: Codable { + case youtube + case youtubeMusic + + public var display: String { + switch self { + case .youtube: "YouTube" + case .youtubeMusic: "YouTube Music" + } + } +} diff --git a/Projects/Modules/Utility/Sources/Extensions/Extension+Int.swift b/Projects/Modules/Utility/Sources/Extensions/Extension+Int.swift index 8983eb6d8..9e0ca74c1 100644 --- a/Projects/Modules/Utility/Sources/Extensions/Extension+Int.swift +++ b/Projects/Modules/Utility/Sources/Extensions/Extension+Int.swift @@ -54,4 +54,15 @@ public extension Int { return formatter.string(from: NSNumber(value: millions))! + "์–ต" } } + + func formattedWithComma() -> String { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + + guard let result = numberFormatter.string(from: NSNumber(value: self)) else { + return "0" + } + + return result + } } diff --git a/Projects/Modules/Utility/Sources/Extensions/Extension+Notification.Name.swift b/Projects/Modules/Utility/Sources/Extensions/Extension+Notification.Name.swift index e106bfd7c..cee2cc82b 100644 --- a/Projects/Modules/Utility/Sources/Extensions/Extension+Notification.Name.swift +++ b/Projects/Modules/Utility/Sources/Extensions/Extension+Notification.Name.swift @@ -7,7 +7,7 @@ public extension Notification.Name { static let willRefreshUserInfo = Notification.Name("willRefreshUserInfo") // ์œ ์ € ์ •๋ณด ๊ฐฑ์‹  static let willStatusBarEnterDarkBackground = Notification.Name("willStatusBarEnterDarkBackground") static let willStatusBarEnterLightBackground = Notification.Name("willStatusBarEnterLightBackground") - static let willShowSongCart = Notification.Name("willShowSongCart") - static let willHideSongCart = Notification.Name("willHideSongCart") - static let didChangeTabInStorage = Notification.Name("didChangeTabInStorage") + static let shouldHidePlaylistFloatingButton = Notification.Name("shouldHidePlaylistFloatingButton") + static let shouldShowPlaylistFloatingButton = Notification.Name("shouldShowPlaylistFloatingButton") + static let shouldMovePlaylistFloatingButton = Notification.Name("shouldMovePlaylistFloatingButton") } diff --git a/Projects/Modules/Utility/Sources/Extensions/Extension+PreferenceManager.swift b/Projects/Modules/Utility/Sources/Extensions/Extension+PreferenceManager.swift index f90c7ed51..c319761e4 100644 --- a/Projects/Modules/Utility/Sources/Extensions/Extension+PreferenceManager.swift +++ b/Projects/Modules/Utility/Sources/Extensions/Extension+PreferenceManager.swift @@ -6,6 +6,7 @@ // Copyright ยฉ 2023 yongbeomkwak. All rights reserved. // +import FirebaseCrashlytics import Foundation import LogManager import RxSwift @@ -61,10 +62,15 @@ public extension PreferenceManager { itemCount: itemCount ) Utility.PreferenceManager.userInfo = userInfo + LogManager.setUserProperty(property: .fruitTotal(count: userInfo.itemCount)) + LogManager.setUserProperty(property: .loginPlatform(platform: userInfo.platform)) } static func clearUserInfo() { LogManager.setUserID(userID: nil) + Crashlytics.crashlytics().setUserID(nil) PreferenceManager.userInfo = nil + LogManager.clearUserProperty(property: .fruitTotal(count: -1)) + LogManager.clearUserProperty(property: .loginPlatform(platform: "")) } } diff --git a/Projects/Modules/Utility/Sources/Extensions/Extension+String.swift b/Projects/Modules/Utility/Sources/Extensions/Extension+String.swift index 8aee481dd..992034871 100644 --- a/Projects/Modules/Utility/Sources/Extensions/Extension+String.swift +++ b/Projects/Modules/Utility/Sources/Extensions/Extension+String.swift @@ -60,4 +60,8 @@ public extension String { formatter.dateFormat = format return formatter.date(from: self) ?? .init() } + + var isContainShortsTagTitle: Bool { + return self.lowercased().contains("#Shorts".lowercased()) + } } diff --git a/Projects/Modules/Utility/Sources/Extensions/Extension+UIViewController.swift b/Projects/Modules/Utility/Sources/Extensions/Extension+UIViewController.swift index ba4ac3cc0..2675f41f2 100644 --- a/Projects/Modules/Utility/Sources/Extensions/Extension+UIViewController.swift +++ b/Projects/Modules/Utility/Sources/Extensions/Extension+UIViewController.swift @@ -21,7 +21,7 @@ public extension UIViewController { return UINavigationController(rootViewController: self) } - #if DEBUG + #if DEBUG || QA private struct Preview: UIViewControllerRepresentable { let viewController: UIViewController @@ -54,16 +54,18 @@ public extension UIViewController { ) } - @_disfavoredOverload func showToast( text: String, font: UIFont = UIFont(name: "Pretendard-Light", size: 14) ?? .systemFont(ofSize: 14, weight: .light), - options: WMToastOptions = [.empty] + options: WMToastOptions = [.empty], + backgroundThema: EKAttributes.DisplayMode = .dark ) { var attributes = EKAttributes.bottomFloat attributes.displayDuration = 2 - attributes.entryBackground = .color(color: EKColor(rgb: 0x101828).with(alpha: 0.8)) + attributes.entryBackground = backgroundThema == .dark ? + .color(color: EKColor(rgb: 0x101828).with(alpha: 0.8)) : + .color(color: EKColor(rgb: 0xF2F4F7).with(alpha: 0.8)) attributes.roundCorners = .all(radius: 20) attributes.entranceAnimation = EKAttributes.Animation.init( translate: .init(duration: 0.3), @@ -76,45 +78,7 @@ public extension UIViewController { let style = EKProperty.LabelStyle( font: font, - color: EKColor(rgb: 0xFCFCFD), - alignment: .center - ) - let labelContent = EKProperty.LabelContent( - text: text, - style: style - ) - - let contentView = EKNoteMessageView(with: labelContent) - contentView.verticalOffset = 10 - SwiftEntryKit.display(entry: contentView, using: attributes) - } - - @available(*, deprecated, message: "ํ† ์ŠคํŠธ ์œ„์น˜ ์กฐ์ • ๋ฒ„์ „์œผ๋กœ ๊ฐœ์„ ") - func showToast( - text: String, - font: UIFont = UIFont(name: "Pretendard-Light", size: 14) ?? - .systemFont(ofSize: 14, weight: .light), - verticalOffset: CGFloat? = nil - ) { - var attributes = EKAttributes.bottomFloat - attributes.displayDuration = 2 - attributes.entryBackground = .color(color: EKColor(rgb: 0x101828).with(alpha: 0.8)) - attributes.roundCorners = .all(radius: 20) - attributes.entranceAnimation = EKAttributes.Animation.init( - translate: .init(duration: 0.3), - fade: .init(from: 0, to: 1, duration: 0.3) - ) - attributes.exitAnimation = EKAttributes.Animation.init( - fade: .init(from: 1, to: 0, duration: 0.3) - ) - - if let verticalOffset = verticalOffset { - attributes.positionConstraints.verticalOffset = verticalOffset - } - - let style = EKProperty.LabelStyle( - font: font, - color: EKColor(rgb: 0xFCFCFD), + color: backgroundThema == .dark ? EKColor(rgb: 0xFCFCFD) : EKColor(rgb: 0x191A1C), alignment: .center ) let labelContent = EKProperty.LabelContent( diff --git a/Projects/Modules/Utility/Sources/Manager/PreferenceManager.swift b/Projects/Modules/Utility/Sources/Manager/PreferenceManager.swift index eb8c069fd..c3f1df5e6 100644 --- a/Projects/Modules/Utility/Sources/Manager/PreferenceManager.swift +++ b/Projects/Modules/Utility/Sources/Manager/PreferenceManager.swift @@ -22,6 +22,7 @@ public final class PreferenceManager { case ignoredPopupIDs // ๋‹ค์‹œ๋ณด์ง€ ์•Š๋Š” ํŒ์—… IDs case readNoticeIDs // ์ด๋ฏธ ์ฝ์€ ๊ณต์ง€ IDs case pushNotificationAuthorizationStatus // ๊ธฐ๊ธฐ์•Œ๋ฆผ on/off ์ƒํƒœ + case songPlayPlatformType // ์œ ํŠœ๋ธŒ๋ฎค์ง์œผ๋กœ ์žฌ์ƒํ• ์ง€ ์—ฌ๋ถ€ } @UserDefaultWrapper(key: Constants.recentRecords.rawValue, defaultValue: nil) @@ -44,6 +45,9 @@ public final class PreferenceManager { @UserDefaultWrapper(key: Constants.pushNotificationAuthorizationStatus.rawValue, defaultValue: nil) public static var pushNotificationAuthorizationStatus: Bool? + + @UserDefaultWrapper(key: Constants.songPlayPlatformType.rawValue, defaultValue: YoutubePlayType.youtube) + public static var songPlayPlatformType: YoutubePlayType? } @propertyWrapper diff --git a/Projects/Modules/Utility/Sources/Player/WakmusicYoutubePlayer.swift b/Projects/Modules/Utility/Sources/Player/WakmusicYoutubePlayer.swift index 618b85a97..bbe42c8d2 100644 --- a/Projects/Modules/Utility/Sources/Player/WakmusicYoutubePlayer.swift +++ b/Projects/Modules/Utility/Sources/Player/WakmusicYoutubePlayer.swift @@ -1,65 +1,224 @@ import Foundation +import LinkPresentation import UIKit public struct WakmusicYoutubePlayer: WakmusicPlayer { + fileprivate enum OpenerPlatform { + case youtube + case youtubeMusic + } + + private enum VideoPlayType { + case videos(ids: [String]) + case playlist(listID: String) + } + + public enum PlayPlatform { + case youtube + case youtubeMusic + case automatic + } + private let youtubeURLGenerator: any YoutubeURLGeneratable - private let ids: [String] + private let youtubeVideoType: VideoPlayType + private let title: String? + private let openerPlatform: OpenerPlatform public init( id: String, + title: String? = nil, + playPlatform: PlayPlatform = .automatic, youtubeURLGenerator: any YoutubeURLGeneratable = YoutubeURLGenerator() ) { - self.ids = [id] + self.youtubeVideoType = .videos(ids: [id]) + self.title = title + self.openerPlatform = playPlatform.toOpenerPlatform self.youtubeURLGenerator = youtubeURLGenerator } public init( ids: [String], + title: String? = nil, + playPlatform: PlayPlatform = .automatic, + youtubeURLGenerator: any YoutubeURLGeneratable = YoutubeURLGenerator() + ) { + self.youtubeVideoType = .videos(ids: ids) + self.title = title + self.openerPlatform = playPlatform.toOpenerPlatform + self.youtubeURLGenerator = youtubeURLGenerator + } + + public init( + listID: String, + playPlatform: PlayPlatform = .automatic, youtubeURLGenerator: any YoutubeURLGeneratable = YoutubeURLGenerator() ) { - self.ids = ids + self.youtubeVideoType = .playlist(listID: listID) + self.title = nil + self.openerPlatform = playPlatform.toOpenerPlatform self.youtubeURLGenerator = youtubeURLGenerator } public func play() { - playYoutube(ids: ids) + switch youtubeVideoType { + case let .videos(ids): + playYoutube(ids: ids) + case let .playlist(listID): + playPlaylistYoutube(listID: listID) + } } } private extension WakmusicYoutubePlayer { func playYoutube(ids: [String]) { - if let appURL = urlForYoutubeApp(ids: ids) { - UIApplication.shared.open(appURL) - } else if let webURL = urlForYoutubeWeb(ids: ids) { - UIApplication.shared.open(webURL) + Task { @MainActor in + guard let url = await urlForYoutube(ids: ids) else { + return + } + if let title, !title.isEmpty, let titledURL = url.appendingTitleParam(title: title) { + await UIApplication.shared.open(titledURL) + } else { + await UIApplication.shared.open(url) + } + } + } + + func playPlaylistYoutube(listID: String) { + guard let url = urlForYoutubePlaylist(listID: listID) else { + return + } + UIApplication.shared.open(url) + } +} + +private extension WakmusicYoutubePlayer { + func urlForYoutube(ids: [String]) async -> URL? { + do { + switch openerPlatform { + case .youtube: + return if let appURL = urlForYoutubeApp(ids: ids) { + appURL + } else if let webURL = urlForYoutubeWeb(ids: ids) { + webURL + } else { + nil + } + + case .youtubeMusic: + return if let appURL = try await urlForYoutubeMusicApp(ids: ids) { + appURL + } else if let webURL = try await urlForYoutubeMusicWeb(ids: ids) { + webURL + } else { + nil + } + } + } catch { + print(error) + return nil } } func urlForYoutubeApp(ids: [String]) -> URL? { - if ids.count == 1, - let id = ids.first, - let youtubeAppURL = URL(string: youtubeURLGenerator.generateYoutubeVideoAppURL(id: id)), - UIApplication.shared.canOpenURL(youtubeAppURL) { - return youtubeAppURL - } else if - let youtubeAppURL = URL(string: youtubeURLGenerator.generateYoutubeVideoAppURL(ids: ids)), - UIApplication.shared.canOpenURL(youtubeAppURL) { - return youtubeAppURL + return openableURL( + youtubeURLGenerator.generateYoutubeVideoAppURL(ids: ids) + ) + } + + func urlForYoutubeWeb(ids: [String]) -> URL? { + return openableURL( + youtubeURLGenerator.generateYoutubeVideoWebURL(ids: ids) + ) + } + + func urlForYoutubeMusicApp(ids: [String]) async throws -> URL? { + if ids.count == 1, let id = ids.first { + return openableURL(youtubeURLGenerator.generateYoutubeMusicVideoAppURL(id: id)) + } else if let redirectedYoutubeURL = try await redirectedYoutubeURL( + youtubeURLGenerator + .generateYoutubeVideoWebURL(ids: ids) + ), + let components = URLComponents(url: redirectedYoutubeURL, resolvingAgainstBaseURL: false), + let listID = components.queryItems?.first(where: { $0.name == "list" })?.value { + return openableURL(youtubeURLGenerator.generateYoutubeMusicPlaylistAppURL(id: listID)) } return nil } - func urlForYoutubeWeb(ids: [String]) -> URL? { - if ids.count == 1, - let id = ids.first, - let youtubeWebURL = URL(string: youtubeURLGenerator.generateYoutubeVideoWebURL(id: id)), - UIApplication.shared.canOpenURL(youtubeWebURL) { - return youtubeWebURL - } else if - let youtubeWebURL = URL(string: youtubeURLGenerator.generateYoutubeVideoWebURL(ids: ids)), - UIApplication.shared.canOpenURL(youtubeWebURL) { - return youtubeWebURL + func urlForYoutubeMusicWeb(ids: [String]) async throws -> URL? { + if ids.count == 1, let id = ids.first { + return openableURL(youtubeURLGenerator.generateYoutubeMusicVideoWebURL(id: id)) + } else if let redirectedYoutubeURL = try await redirectedYoutubeURL( + youtubeURLGenerator + .generateYoutubeVideoWebURL(ids: ids) + ), + let components = URLComponents(url: redirectedYoutubeURL, resolvingAgainstBaseURL: false), + let listID = components.queryItems?.first(where: { $0.name == "list" })?.value { + return openableURL(youtubeURLGenerator.generateYoutubeMusicPlaylistWebURL(id: listID)) } return nil } } + +private extension WakmusicYoutubePlayer { + func urlForYoutubePlaylist(listID: String) -> URL? { + switch openerPlatform { + case .youtube: + let appURL = openableURL(youtubeURLGenerator.generateYoutubePlaylistAppURL(id: listID)) + let webURL = openableURL(youtubeURLGenerator.generateYoutubePlaylistWebURL(id: listID)) + return appURL ?? webURL + + case .youtubeMusic: + let appURL = openableURL(youtubeURLGenerator.generateYoutubeMusicPlaylistAppURL(id: listID)) + let webURL = openableURL(youtubeURLGenerator.generateYoutubeMusicPlaylistWebURL(id: listID)) + return appURL ?? webURL + } + } +} + +private extension WakmusicYoutubePlayer { + func openableURL(_ urlString: String) -> URL? { + guard let url = URL(string: urlString), UIApplication.shared.canOpenURL(url) else { return nil } + return url + } + + @MainActor + func redirectedYoutubeURL(_ urlString: String) async throws -> URL? { + guard let url = URL(string: urlString) else { return nil } + + let provider = LPMetadataProvider() + let metadata = try await provider.startFetchingMetadata(for: url) + guard let redirectedURL = metadata.url, + UIApplication.shared.canOpenURL(redirectedURL) + else { return nil } + + return redirectedURL + } +} + +private extension URL { + func appendingTitleParam(title: String) -> URL? { + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return nil } + components.queryItems?.append(URLQueryItem(name: "title", value: title)) + return components.url + } +} + +private extension WakmusicYoutubePlayer.PlayPlatform { + var toOpenerPlatform: WakmusicYoutubePlayer.OpenerPlatform { + switch self { + case .youtube: return .youtube + case .youtubeMusic: return .youtubeMusic + case .automatic: return PreferenceManager.songPlayPlatformType?.toOpnerPlatform ?? .youtube + } + } +} + +private extension YoutubePlayType { + var toOpnerPlatform: WakmusicYoutubePlayer.OpenerPlatform { + switch self { + case .youtube: return .youtube + case .youtubeMusic: return .youtubeMusic + } + } +} diff --git a/Projects/Modules/Utility/Sources/Utils/Secrets.swift b/Projects/Modules/Utility/Sources/Utils/Secrets.swift index 5f8c4115f..40433ff46 100644 --- a/Projects/Modules/Utility/Sources/Utils/Secrets.swift +++ b/Projects/Modules/Utility/Sources/Utils/Secrets.swift @@ -15,42 +15,9 @@ public func config(key: String) -> String { return secrets[key] as? String ?? "not found key" } -// MARK: - BASE_IMAGE_URL -public func BASE_IMAGE_URL() -> String { - return config(key: "BASE_IMAGE_URL") -} - -// MARK: - WMDomain > Image -public func WMDOMAIN_IMAGE_NEWS() -> String { - return config(key: "WMDOMAIN_IMAGE_NEWS") -} - -public func WMDOMAIN_IMAGE_ARTIST_ROUND() -> String { - return config(key: "WMDOMAIN_IMAGE_ARTIST_ROUND") -} - -public func WMDOMAIN_IMAGE_ARTIST_SQUARE() -> String { - return config(key: "WMDOMAIN_IMAGE_ARTIST_SQUARE") -} - -public func WMDOMAIN_IMAGE_PROFILE() -> String { - return config(key: "WMDOMAIN_IMAGE_PROFILE") -} - -public func WMDOMAIN_IMAGE_PLAYLIST() -> String { - return config(key: "WMDOMAIN_IMAGE_PLAYLIST") -} - -public func WMDOMAIN_IMAGE_RECOMMEND_PLAYLIST_SQUARE() -> String { - return config(key: "WMDOMAIN_IMAGE_RECOMMEND_PLAYLIST_SQUARE") -} - -public func WMDOMAIN_IMAGE_RECOMMEND_PLAYLIST_ROUND() -> String { - return config(key: "WMDOMAIN_IMAGE_RECOMMEND_PLAYLIST_ROUND") -} - -public func WMDOMAIN_IMAGE_NOTICE() -> String { - return config(key: "WMDOMAIN_IMAGE_NOTICE") +// MARK: - CDN Domain +public func CDN_DOMAIN_URL() -> String { + return config(key: "CDN_DOMAIN_URL") } // MARK: - NAVER @@ -89,9 +56,9 @@ public func WM_URI_SCHEME() -> String { } public func WM_UNIVERSALLINK_DOMAIN() -> String { - return config(key: "WM_UNIVERSALLINK_DOMAIN") -} - -public func WM_UNIVERSALLINK_TEST_DOMAIN() -> String { - return config(key: "WM_UNIVERSALLINK_TEST_DOMAIN") + #if DEBUG || QA + return config(key: "WM_UNIVERSALLINK_TEST_DOMAIN") + #else + return config(key: "WM_UNIVERSALLINK_DOMAIN") + #endif } diff --git a/Projects/Modules/Utility/Sources/Utils/Utils.swift b/Projects/Modules/Utility/Sources/Utils/Utils.swift index a85b16bc3..6c6e9890b 100644 --- a/Projects/Modules/Utility/Sources/Utils/Utils.swift +++ b/Projects/Modules/Utility/Sources/Utils/Utils.swift @@ -102,7 +102,7 @@ public func colorFromRGB(_ hexString: String, alpha: CGFloat = 1.0) -> UIColor { } public func DEBUG_LOG(_ msg: Any, file: String = #file, function: String = #function, line: Int = #line) { - #if DEBUG + #if DEBUG || QA let fileName = file.split(separator: "/").last ?? "" let funcName = function.split(separator: "(").first ?? "" print("[\(fileName)] \(funcName)(\(line)): \(msg)") diff --git a/Projects/Modules/Utility/Sources/Youtube/YoutubeURLGenerator.swift b/Projects/Modules/Utility/Sources/Youtube/YoutubeURLGenerator.swift index 5f558a398..ab0ab8927 100644 --- a/Projects/Modules/Utility/Sources/Youtube/YoutubeURLGenerator.swift +++ b/Projects/Modules/Utility/Sources/Youtube/YoutubeURLGenerator.swift @@ -7,11 +7,20 @@ public protocol YoutubeURLGeneratable { func generateYoutubeVideoWebURL(id: String) -> String func generateYoutubeVideoAppURL(ids: [String]) -> String func generateYoutubeVideoWebURL(ids: [String]) -> String + func generateYoutubePlaylistAppURL(id: String) -> String + func generateYoutubePlaylistWebURL(id: String) -> String + + func generateYoutubeMusicVideoAppURL(id: String) -> String + func generateYoutubeMusicVideoWebURL(id: String) -> String + func generateYoutubeMusicPlaylistAppURL(id: String) -> String + func generateYoutubeMusicPlaylistWebURL(id: String) -> String } public struct YoutubeURLGenerator: YoutubeURLGeneratable { public init() {} + // MARK: Youtube + public func generateThumbnailURL(id: String) -> String { "https://i.ytimg.com/vi/\(id)/mqdefault.jpg" } @@ -35,4 +44,30 @@ public struct YoutubeURLGenerator: YoutubeURLGeneratable { public func generateYoutubeVideoWebURL(ids: [String]) -> String { "https://youtube.com/watch_videos?video_ids=\(ids.joined(separator: ","))" } + + public func generateYoutubePlaylistAppURL(id: String) -> String { + "youtube://playlist?list=\(id)" + } + + public func generateYoutubePlaylistWebURL(id: String) -> String { + "https://youtube.com/playlist?list=\(id)" + } + + // MARK: Youtube Music + + public func generateYoutubeMusicVideoAppURL(id: String) -> String { + return "youtubemusic://watch?v=\(id)" + } + + public func generateYoutubeMusicVideoWebURL(id: String) -> String { + return "https://music.youtube.com/watch?v=\(id)" + } + + public func generateYoutubeMusicPlaylistAppURL(id: String) -> String { + return "youtubemusic://watch?list=\(id)" + } + + public func generateYoutubeMusicPlaylistWebURL(id: String) -> String { + return "https://music.youtube.com/watch?list=\(id)" + } } diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/note_shadow.imageset/Contents.json b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/note_shadow.imageset/Contents.json new file mode 100644 index 000000000..fbb584e96 --- /dev/null +++ b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/note_shadow.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "note_shadow@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "note_shadow@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/note_shadow.imageset/note_shadow@2x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/note_shadow.imageset/note_shadow@2x.png new file mode 100644 index 000000000..f5df9121b Binary files /dev/null and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/note_shadow.imageset/note_shadow@2x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/note_shadow.imageset/note_shadow@3x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/note_shadow.imageset/note_shadow@3x.png new file mode 100644 index 000000000..99a293bf9 Binary files /dev/null and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/note_shadow.imageset/note_shadow@3x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/Contents.json b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/Contents.json index c28990e9d..090304915 100644 --- a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/Contents.json +++ b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/Contents.json @@ -5,12 +5,12 @@ "scale" : "1x" }, { - "filename" : "Group 2607997@2x.png", + "filename" : "unidentified_note@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "Group 2607997@3x.png", + "filename" : "unidentified_note@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/Group 2607997@2x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/Group 2607997@2x.png deleted file mode 100644 index 8e40ac7fa..000000000 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/Group 2607997@2x.png and /dev/null differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/Group 2607997@3x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/Group 2607997@3x.png deleted file mode 100644 index 72c7a80c1..000000000 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/Group 2607997@3x.png and /dev/null differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/unidentified_note@2x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/unidentified_note@2x.png new file mode 100644 index 000000000..473fafdde Binary files /dev/null and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/unidentified_note@2x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/unidentified_note@3x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/unidentified_note@3x.png new file mode 100644 index 000000000..9658afddf Binary files /dev/null and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note.imageset/unidentified_note@3x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_1st.imageset/unidentified_note_1st@2x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_1st.imageset/unidentified_note_1st@2x.png index 4cae55dfd..3f6916c1f 100644 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_1st.imageset/unidentified_note_1st@2x.png and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_1st.imageset/unidentified_note_1st@2x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_1st.imageset/unidentified_note_1st@3x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_1st.imageset/unidentified_note_1st@3x.png index 0ef245e44..2edb8af65 100644 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_1st.imageset/unidentified_note_1st@3x.png and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_1st.imageset/unidentified_note_1st@3x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_2nd.imageset/unidentified_note_2nd@2x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_2nd.imageset/unidentified_note_2nd@2x.png index 95a123769..c3ae96d86 100644 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_2nd.imageset/unidentified_note_2nd@2x.png and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_2nd.imageset/unidentified_note_2nd@2x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_2nd.imageset/unidentified_note_2nd@3x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_2nd.imageset/unidentified_note_2nd@3x.png index b4fab465f..91acb3d75 100644 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_2nd.imageset/unidentified_note_2nd@3x.png and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_2nd.imageset/unidentified_note_2nd@3x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_3rd.imageset/unidentified_note_3rd@2x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_3rd.imageset/unidentified_note_3rd@2x.png index b0374e381..99a4e7dfc 100644 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_3rd.imageset/unidentified_note_3rd@2x.png and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_3rd.imageset/unidentified_note_3rd@2x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_3rd.imageset/unidentified_note_3rd@3x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_3rd.imageset/unidentified_note_3rd@3x.png index ea6a7078b..4938540a3 100644 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_3rd.imageset/unidentified_note_3rd@3x.png and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_3rd.imageset/unidentified_note_3rd@3x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_4th.imageset/unidentified_note_4th@2x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_4th.imageset/unidentified_note_4th@2x.png index 52a7d37ae..f125df846 100644 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_4th.imageset/unidentified_note_4th@2x.png and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_4th.imageset/unidentified_note_4th@2x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_4th.imageset/unidentified_note_4th@3x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_4th.imageset/unidentified_note_4th@3x.png index 345ed537d..7b881774c 100644 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_4th.imageset/unidentified_note_4th@3x.png and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_4th.imageset/unidentified_note_4th@3x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_5th.imageset/unidentified_note_5th@2x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_5th.imageset/unidentified_note_5th@2x.png index ce9400181..ff5320a5b 100644 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_5th.imageset/unidentified_note_5th@2x.png and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_5th.imageset/unidentified_note_5th@2x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_5th.imageset/unidentified_note_5th@3x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_5th.imageset/unidentified_note_5th@3x.png index 49025e81d..e3469d76a 100644 Binary files a/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_5th.imageset/unidentified_note_5th@3x.png and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/FruitDraw.xcassets/unidentified_note_5th.imageset/unidentified_note_5th@3x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut.imageset/Contents.json b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut.imageset/Contents.json new file mode 100644 index 000000000..91543bb11 --- /dev/null +++ b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "radio_button@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "radio_button@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut.imageset/radio_button@2x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut.imageset/radio_button@2x.png new file mode 100644 index 000000000..6d3efaa90 Binary files /dev/null and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut.imageset/radio_button@2x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut.imageset/radio_button@3x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut.imageset/radio_button@3x.png new file mode 100644 index 000000000..a6a026260 Binary files /dev/null and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut.imageset/radio_button@3x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut_color.imageset/Contents.json b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut_color.imageset/Contents.json new file mode 100644 index 000000000..a5cf0c1cc --- /dev/null +++ b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut_color.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "radio_button_fill@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "radio_button_fill@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut_color.imageset/radio_button_fill@2x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut_color.imageset/radio_button_fill@2x.png new file mode 100644 index 000000000..53408431b Binary files /dev/null and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut_color.imageset/radio_button_fill@2x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut_color.imageset/radio_button_fill@3x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut_color.imageset/radio_button_fill@3x.png new file mode 100644 index 000000000..680a99979 Binary files /dev/null and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/MyInfo.xcassets/donut_color.imageset/radio_button_fill@3x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/Storage.xcassets/storage_close.imageset/Contents.json b/Projects/UsertInterfaces/DesignSystem/Resources/Image/Storage.xcassets/storage_close.imageset/Contents.json new file mode 100644 index 000000000..0bf04a3c9 --- /dev/null +++ b/Projects/UsertInterfaces/DesignSystem/Resources/Image/Storage.xcassets/storage_close.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_16_close@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_16_close@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/Storage.xcassets/storage_close.imageset/ic_16_close@2x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/Storage.xcassets/storage_close.imageset/ic_16_close@2x.png new file mode 100644 index 000000000..9d6d046cd Binary files /dev/null and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/Storage.xcassets/storage_close.imageset/ic_16_close@2x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Resources/Image/Storage.xcassets/storage_close.imageset/ic_16_close@3x.png b/Projects/UsertInterfaces/DesignSystem/Resources/Image/Storage.xcassets/storage_close.imageset/ic_16_close@3x.png new file mode 100644 index 000000000..9f71e86af Binary files /dev/null and b/Projects/UsertInterfaces/DesignSystem/Resources/Image/Storage.xcassets/storage_close.imageset/ic_16_close@3x.png differ diff --git a/Projects/UsertInterfaces/DesignSystem/Sources/Extension+UILabel.swift b/Projects/UsertInterfaces/DesignSystem/Sources/Extension+UILabel.swift index 291570a01..5ff917659 100644 --- a/Projects/UsertInterfaces/DesignSystem/Sources/Extension+UILabel.swift +++ b/Projects/UsertInterfaces/DesignSystem/Sources/Extension+UILabel.swift @@ -63,6 +63,7 @@ public extension UILabel { /// - Parameter lineHeightMultiple: ์ค„ ๊ฐ„๊ฒฉ์˜ ๋ฐฐ์ˆ˜ (lineSpacing * lineHeightMultiple) func getTextWithAttributes( lineHeight: CGFloat? = nil, + lineBreakMode: NSLineBreakMode = .byTruncatingTail, kernValue: Double? = nil, lineSpacing: CGFloat? = nil, lineHeightMultiple: CGFloat? = nil, @@ -73,15 +74,22 @@ public extension UILabel { if let lineSpacing { paragraphStyle.lineSpacing = lineSpacing } if let lineHeightMultiple { paragraphStyle.lineHeightMultiple = lineHeightMultiple } - paragraphStyle.lineBreakMode = .byTruncatingTail + paragraphStyle.lineBreakMode = lineBreakMode paragraphStyle.alignment = alignment let baselineOffset: CGFloat + let offsetDivisor: CGFloat + + if #available(iOS 16.4, *) { // 16.4 ๋ถ€ํ„ฐ ์ž ์ˆ˜ํ•จ ํŒจ์น˜๋กœ ๊ณ ์ณ์กŒ๋‹ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค. + offsetDivisor = 2 + } else { + offsetDivisor = 4 + } if let lineHeight { paragraphStyle.maximumLineHeight = lineHeight paragraphStyle.minimumLineHeight = lineHeight - baselineOffset = (lineHeight - font.lineHeight) / 2 + baselineOffset = (lineHeight - font.lineHeight) / offsetDivisor } else { baselineOffset = 0 } @@ -97,25 +105,38 @@ public extension UILabel { func setTextWithAttributes( lineHeight: CGFloat? = nil, + lineBreakMode: NSLineBreakMode = .byTruncatingTail, kernValue: Double? = -0.5, lineSpacing: CGFloat? = nil, lineHeightMultiple: CGFloat? = nil, - alignment: NSTextAlignment = .left + alignment: NSTextAlignment = .left, + hangulWordPriority: Bool = false ) { let paragraphStyle = NSMutableParagraphStyle() if let lineSpacing { paragraphStyle.lineSpacing = lineSpacing } if let lineHeightMultiple { paragraphStyle.lineHeightMultiple = lineHeightMultiple } - paragraphStyle.lineBreakMode = .byTruncatingTail + paragraphStyle.lineBreakMode = lineBreakMode paragraphStyle.alignment = alignment + if hangulWordPriority { + paragraphStyle.lineBreakStrategy = .hangulWordPriority + } + let baselineOffset: CGFloat + let offsetDivisor: CGFloat + + if #available(iOS 16.4, *) { // 16.4 ๋ถ€ํ„ฐ ์ž ์ˆ˜ํ•จ ํŒจ์น˜๋กœ ๊ณ ์ณ์กŒ๋‹ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค. + offsetDivisor = 2 + } else { + offsetDivisor = 4 + } if let lineHeight { paragraphStyle.maximumLineHeight = lineHeight paragraphStyle.minimumLineHeight = lineHeight - baselineOffset = (lineHeight - font.lineHeight) / 2 + baselineOffset = (lineHeight - font.lineHeight) / offsetDivisor } else { baselineOffset = 0 } diff --git a/Projects/UsertInterfaces/DesignSystem/Sources/SingleActionButtonView.swift b/Projects/UsertInterfaces/DesignSystem/Sources/SingleActionButtonView.swift index 6ae2c1022..6e1aeed11 100644 --- a/Projects/UsertInterfaces/DesignSystem/Sources/SingleActionButtonView.swift +++ b/Projects/UsertInterfaces/DesignSystem/Sources/SingleActionButtonView.swift @@ -14,6 +14,11 @@ public final class SingleActionButtonView: UIView { $0.backgroundColor = .white.withAlphaComponent(0.4) } + private let translucentView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)).then { + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + private let imageView = UIImageView().then { $0.contentMode = .scaleAspectFit } @@ -34,7 +39,7 @@ public final class SingleActionButtonView: UIView { public init(frame: CGRect, topSpacing: CGFloat = 16) { self.topSpacing = topSpacing super.init(frame: frame) - backgroundColor = DesignSystemAsset.BlueGrayColor.gray100.color + backgroundColor = .clear addView() setLayout() addAction() @@ -66,6 +71,7 @@ private extension SingleActionButtonView { } func addView() { + addSubview(translucentView) addSubview(baseView) addSubview(imageView) addSubview(label) @@ -92,5 +98,9 @@ private extension SingleActionButtonView { label.snp.makeConstraints { $0.center.equalTo(button.snp.center) } + + translucentView.snp.makeConstraints { + $0.edges.equalTo(baseView) + } } } diff --git a/Projects/UsertInterfaces/DesignSystem/Sources/WMLabel.swift b/Projects/UsertInterfaces/DesignSystem/Sources/WMLabel.swift index 2e52d630e..167a7b8a3 100644 --- a/Projects/UsertInterfaces/DesignSystem/Sources/WMLabel.swift +++ b/Projects/UsertInterfaces/DesignSystem/Sources/WMLabel.swift @@ -1,5 +1,3 @@ - - import MarqueeLabel import UIKit @@ -63,4 +61,8 @@ public final class WMLabel: UILabel { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public func setFont(_ style: UIFont.WMFontSystem) { + self.font = UIFont.setFont(style) + } } diff --git a/Projects/UsertInterfaces/DesignSystem/Sources/WMNavigationBarView.swift b/Projects/UsertInterfaces/DesignSystem/Sources/WMNavigationBarView.swift index 49bd5f3dd..a455f6462 100644 --- a/Projects/UsertInterfaces/DesignSystem/Sources/WMNavigationBarView.swift +++ b/Projects/UsertInterfaces/DesignSystem/Sources/WMNavigationBarView.swift @@ -5,14 +5,14 @@ import UIKit public final class WMNavigationBarView: UIView { private let leftStackView = UIStackView().then { $0.axis = .horizontal - $0.alignment = .leading + $0.alignment = .center } public private(set) var titleView: UIView = UIView() private let rightStackView = UIStackView().then { $0.isUserInteractionEnabled = true $0.axis = .horizontal - $0.alignment = .trailing + $0.alignment = .center $0.distribution = .fillEqually $0.spacing = 10 } diff --git a/README.md b/README.md index 8ca914d87..125838027 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ ## iOS Developer & Designer -| iOS Hamp | ๊ตฌ๊ตฌ | ์ผ€์ด | ๊น€๋Œ€ํฌ | ์ƒดํ“จ(๋””์ž์ธ) | +| iOS Hamp | ๊ตฌ๊ตฌ | ์ผ€์ด | ๊น€๋Œ€ํฌ | baegteun | ์ƒดํ“จ(๋””์ž์ธ) | | --- | --- | --- | --- | -- | -| | | | | | -| [yongbeomkwak](https://github.com/yongbeomkwak) | [KangTaeHoon](https://github.com/KangTaeHoon) | [youn9k](https://github.com/youn9k) | [kimdaehee0824](https://github.com/kimdaehee0824) | [syampuuu](instagram.com/jjma._.y) | +| | | | | | | +| [yongbeomkwak](https://github.com/yongbeomkwak) | [KangTaeHoon](https://github.com/KangTaeHoon) | [youn9k](https://github.com/youn9k) | [kimdaehee0824](https://github.com/kimdaehee0824) | [baekteun](https://github.com/baekteun) | [syampuuu](instagram.com/jjma._.y) | ## iOS ๋ชจ๋“ˆ ๊ตฌ์กฐ๋„