diff --git a/alsongDalsong/ASEntity/ASEntity/Answer.swift b/alsongDalsong/ASEntity/ASEntity/Answer.swift index d161467d..365d2efb 100644 --- a/alsongDalsong/ASEntity/ASEntity/Answer.swift +++ b/alsongDalsong/ASEntity/ASEntity/Answer.swift @@ -26,3 +26,12 @@ extension Answer { music: Music.musicStub4, playlist: Playlist()) } + +extension Answer: CustomStringConvertible { + public var description: String { + return """ + player: \(player?.description ?? "nil") + music: \(music?.description ?? "nil") + """ + } +} diff --git a/alsongDalsong/ASEntity/ASEntity/GameState.swift b/alsongDalsong/ASEntity/ASEntity/GameState.swift index 34d386f9..ea8c8e9e 100644 --- a/alsongDalsong/ASEntity/ASEntity/GameState.swift +++ b/alsongDalsong/ASEntity/ASEntity/GameState.swift @@ -1,4 +1,4 @@ -public struct GameState { +public struct GameState: Equatable { public let mode: Mode? public let recordOrder: UInt8? public let status: Status? @@ -150,9 +150,17 @@ public enum GameViewType { case .submitMusic: (systemName: "music.note.list", color: "508DFD") case .humming: - (systemName: "microphone", color: "FD5050") + if #available(iOS 18.0, *) { + (systemName: "microphone", color: "FD5050") + } else { + (systemName: "mic", color: "FD5050") + } case .rehumming: - (systemName: "microphone", color: "FD5050") + if #available(iOS 18.0, *) { + (systemName: "microphone", color: "FD5050") + } else { + (systemName: "mic", color: "FD5050") + } case .submitAnswer: (systemName: "music.note.list", color: "508DFD") case .result: diff --git a/alsongDalsong/ASEntity/ASEntity/Music.swift b/alsongDalsong/ASEntity/ASEntity/Music.swift index d9070493..5797783b 100644 --- a/alsongDalsong/ASEntity/ASEntity/Music.swift +++ b/alsongDalsong/ASEntity/ASEntity/Music.swift @@ -38,3 +38,9 @@ extension Music { public static let musicStub3 = Music(title: "으아~", artist: "김흥국") public static let musicStub4 = Music(title: "이브, 프시케 그리고 푸른 수염의 아내", artist: "르세라핌") } + +extension Music: CustomStringConvertible { + public var description: String { + return "\(title ?? "Unknown") - \(artist ?? "Unknown")" + } +} diff --git a/alsongDalsong/ASEntity/ASEntity/Player.swift b/alsongDalsong/ASEntity/ASEntity/Player.swift index 4c8539c6..e9cd8844 100644 --- a/alsongDalsong/ASEntity/ASEntity/Player.swift +++ b/alsongDalsong/ASEntity/ASEntity/Player.swift @@ -28,3 +28,9 @@ extension Player { public static let playerStub3: Player = Player(id: "2", avatarUrl: nil, nickname: "Moral-life", score: nil, order: 2) public static let playerStub4: Player = Player(id: "3", avatarUrl: nil, nickname: "Sang₩", score: nil, order: 3) } + +extension Player: CustomStringConvertible { + public var description: String { + return "\(nickname ?? "Unknown")" + } +} diff --git a/alsongDalsong/ASEntity/ASEntity/Record.swift b/alsongDalsong/ASEntity/ASEntity/Record.swift index c6225f81..cdba02d1 100644 --- a/alsongDalsong/ASEntity/ASEntity/Record.swift +++ b/alsongDalsong/ASEntity/ASEntity/Record.swift @@ -30,3 +30,12 @@ extension Record { public static let recordStub4_2 = Record(player: Player.playerStub4, recordOrder: 1, fileUrl: stubm4aData) public static let recordStub4_3 = Record(player: Player.playerStub4, recordOrder: 2, fileUrl: stubm4aData) } + +extension Record: CustomStringConvertible { + public var description: String { + return """ + player: \(player?.description ?? "nil") + recordOrder: \(String(describing: recordOrder)) + """ + } +} diff --git a/alsongDalsong/ASEntity/ASEntity/Room.swift b/alsongDalsong/ASEntity/ASEntity/Room.swift index c977815c..05f7a407 100644 --- a/alsongDalsong/ASEntity/ASEntity/Room.swift +++ b/alsongDalsong/ASEntity/ASEntity/Room.swift @@ -42,3 +42,22 @@ public struct Room: Codable { self.submits = submits } } + +extension Room: CustomStringConvertible { + public var description: String { + return """ + number: \(number ?? "nil") + host: \(host?.description ?? "nil") + players: \(players?.description ?? "nil") + mode: \(mode?.title ?? "nil") + round: \(round ?? 0) + status: \(status?.description ?? "nil") + recordOrder: \(recordOrder ?? 0) + records: \(records?.description ?? "nil") + answers: \(answers?.description ?? "nil") + dueTime: \(dueTime?.description ?? "nil") + selectedRecords: \(selectedRecords?.description ?? "nil") + submits: \(submits?.description ?? "nil") + """ + } +} diff --git a/alsongDalsong/ASEntity/ASEntity/Status.swift b/alsongDalsong/ASEntity/ASEntity/Status.swift index 5354948b..1574782e 100644 --- a/alsongDalsong/ASEntity/ASEntity/Status.swift +++ b/alsongDalsong/ASEntity/ASEntity/Status.swift @@ -7,3 +7,20 @@ public enum Status: String, Codable { case hint case result } + +extension Status: CustomStringConvertible { + public var description: String { + switch self { + case .humming: + return "humming" + case .rehumming: + return "rehumming" + case .waiting: + return "waiting" + case .hint: + return "hint" + case .result: + return "result" + } + } +} diff --git a/alsongDalsong/ASMusicKit/ASMusicKit/ASMusicAPI.swift b/alsongDalsong/ASMusicKit/ASMusicKit/ASMusicAPI.swift index ab64d0ed..c2be1790 100644 --- a/alsongDalsong/ASMusicKit/ASMusicKit/ASMusicAPI.swift +++ b/alsongDalsong/ASMusicKit/ASMusicKit/ASMusicAPI.swift @@ -4,12 +4,12 @@ import MusicKit public struct ASMusicAPI { public init() {} + /// MusicKit을 통해 Apple Music의 노래를 검색합니다. /// - Parameters: /// - text: 검색 요청을 보낼 검색어 /// - maxCount: 검색해서 찾아올 음악의 갯수 기본값 설정은 25 /// - Returns: Music의 배열 - @MainActor public func search(for text: String, _ maxCount: Int = 10, _ offset: Int = 0) async throws -> [Music] { let status = await MusicAuthorization.request() switch status { @@ -61,11 +61,45 @@ public struct ASMusicAPI { throw ASMusicError.notAuthorized } } + + public func randomSong(from playlistId: String) async throws -> ASEntity.Music { + let status = await MusicAuthorization.request() + switch status { + case .authorized: + do { + let request = MusicCatalogResourceRequest(matching: \.id, equalTo: MusicItemID(rawValue: playlistId)) + let playlistResponse = try await request.response() + let playlist = playlistResponse.items.first! + + let playlistWithTrack = try await playlist.with([.tracks]) + guard let tracks = playlistWithTrack.tracks else { + throw ASMusicError.playListHasNoSongs + } + + if let song = tracks.randomElement() { + return ASEntity.Music( + id: song.id.rawValue, + title: song.title, + artist: song.artistName, + artworkUrl: song.artwork?.url(width: 300, height: 300), + previewUrl: song.previewAssets?.first?.url, + artworkBackgroundColor: song.artwork?.backgroundColor?.toHex() + ) + } + } catch { + throw ASMusicError.playListHasNoSongs + } + default: + throw ASMusicError.notAuthorized + } + return ASEntity.Music(id: "nil", title: nil, artist: nil, artworkUrl: nil, previewUrl: nil, artworkBackgroundColor: nil) + } } public enum ASMusicError: Error, LocalizedError { case notAuthorized case searchError + case playListHasNoSongs public var errorDescription: String? { switch self { @@ -73,6 +107,8 @@ public enum ASMusicError: Error, LocalizedError { "애플 뮤직에 접근하는 권한이 없습니다." case .searchError: "노래 검색 중 오류가 발생했습니다." + case .playListHasNoSongs: + "플레이리스트에 노래가 없습니다." } } } diff --git a/alsongDalsong/ASNetworkKit/ASNetworkKit/Firebase/ASFirebaseDatabase.swift b/alsongDalsong/ASNetworkKit/ASNetworkKit/Firebase/ASFirebaseDatabase.swift index 0158b3bc..ad4d7937 100644 --- a/alsongDalsong/ASNetworkKit/ASNetworkKit/Firebase/ASFirebaseDatabase.swift +++ b/alsongDalsong/ASNetworkKit/ASNetworkKit/Firebase/ASFirebaseDatabase.swift @@ -1,11 +1,12 @@ import ASEntity +import ASLogKit import Combine @preconcurrency internal import FirebaseFirestore public final class ASFirebaseDatabase: ASFirebaseDatabaseProtocol { private let firestoreRef = Firestore.firestore() private var roomListeners: ListenerRegistration? - private var roomPublisher = PassthroughSubject() + private var roomPublisher = CurrentValueSubject(nil) public func addRoomListener(roomNumber: String) -> AnyPublisher { let roomRef = firestoreRef.collection("rooms").document(roomNumber) @@ -18,8 +19,14 @@ public final class ASFirebaseDatabase: ASFirebaseDatabaseProtocol { return self.roomPublisher.send(completion: .failure(ASNetworkErrors.FirebaseListenerError)) } + if document.metadata.isFromCache { + Logger.debug("로컬 캐시에서 데이터를 가져온 경우") + return + } + do { let room = try document.data(as: Room.self) + Logger.debug("방 정보를 가져왔습니다.\n\(room)") return self.roomPublisher.send(room) } catch { return self.roomPublisher.send(completion: .failure(ASNetworkErrors.FirebaseListenerError)) @@ -27,10 +34,13 @@ public final class ASFirebaseDatabase: ASFirebaseDatabaseProtocol { } roomListeners = listener - return roomPublisher.eraseToAnyPublisher() + return roomPublisher + .compactMap { $0 } + .eraseToAnyPublisher() } public func removeRoomListener() { + roomPublisher.send(nil) roomListeners?.remove() } } diff --git a/alsongDalsong/ASNetworkKit/ASNetworkKit/FirebaseEndpoint.swift b/alsongDalsong/ASNetworkKit/ASNetworkKit/FirebaseEndpoint.swift index 05e7c199..abe35869 100644 --- a/alsongDalsong/ASNetworkKit/ASNetworkKit/FirebaseEndpoint.swift +++ b/alsongDalsong/ASNetworkKit/ASNetworkKit/FirebaseEndpoint.swift @@ -14,7 +14,7 @@ public struct FirebaseEndpoint: Endpoint, Equatable { public init(path: Path, method: HTTPMethod) { self.path = path self.method = method - headers = [:] + self.headers = [:] } // TODO: - firebase api/cloud func에 맞는 path 넣기 @@ -29,7 +29,7 @@ public struct FirebaseEndpoint: Endpoint, Equatable { case submitMusic case submitAnswer case resetGame - + public var description: String { switch self { case .auth: @@ -69,4 +69,15 @@ public extension FirebaseEndpoint { Self(path: .auth, method: .get) .update(\.queryItems, with: [.init(name: "listAvatarUrls", value: "true")]) } + + func withCommonQueryItems(roomNumber: String?, userID: String?) -> Self { + var items = [URLQueryItem]() + if let userID { + items.append(URLQueryItem(name: "userId", value: userID)) + } + if let roomNumber { + items.append(URLQueryItem(name: "roomNumber", value: roomNumber)) + } + return self.update(\.queryItems, with: items) + } } diff --git a/alsongDalsong/ASRepository/ASRepository/Protocols/MainRepositoryProtocol.swift b/alsongDalsong/ASRepository/ASRepository/Protocols/MainRepositoryProtocol.swift index 6467d5fa..e15c8564 100644 --- a/alsongDalsong/ASRepository/ASRepository/Protocols/MainRepositoryProtocol.swift +++ b/alsongDalsong/ASRepository/ASRepository/Protocols/MainRepositoryProtocol.swift @@ -4,22 +4,22 @@ import Foundation public protocol MainRepositoryProtocol { var myId: String? { get } - var number: CurrentValueSubject { get } - var host: CurrentValueSubject { get } - var players: CurrentValueSubject<[Player]?, Never> { get } - var mode: CurrentValueSubject { get } - var round: CurrentValueSubject { get } - var status: CurrentValueSubject { get } - var recordOrder: CurrentValueSubject { get } - var records: CurrentValueSubject<[ASEntity.Record]?, Never> { get } - var answers: CurrentValueSubject<[Answer]?, Never> { get } - var dueTime: CurrentValueSubject { get } - var selectedRecords: CurrentValueSubject<[UInt8]?, Never> { get } - var submits: CurrentValueSubject<[Answer]?, Never> { get } + var room: CurrentValueSubject { get } func connectRoom(roomNumber: String) func disconnectRoom() - + + func createRoom(nickname: String, avatar: URL) async throws -> String + func joinRoom(nickname: String, avatar: URL, roomNumber: String) async throws -> Bool + func leaveRoom() async throws -> Bool + func startGame() async throws -> Bool + func changeMode(mode: Mode) async throws -> Bool + func changeRecordOrder() async throws -> Bool + func resetGame() async throws -> Bool + func submitAnswer(answer: Music) async throws -> Bool + func getAvatarUrls() async throws -> [URL] + func getResource(url: URL) async throws -> Data + + func submitMusic(answer: ASEntity.Music) async throws -> Bool func postRecording(_ record: Data) async throws -> Bool - func postResetGame() async throws -> Bool } diff --git a/alsongDalsong/ASRepository/ASRepository/Protocols/RepositoryProtocols.swift b/alsongDalsong/ASRepository/ASRepository/Protocols/RepositoryProtocols.swift index 8cdae54b..ed33c049 100644 --- a/alsongDalsong/ASRepository/ASRepository/Protocols/RepositoryProtocols.swift +++ b/alsongDalsong/ASRepository/ASRepository/Protocols/RepositoryProtocols.swift @@ -55,14 +55,14 @@ public protocol RoomActionRepositoryProtocol: Sendable { func createRoom(nickname: String, avatar: URL) async throws -> String func joinRoom(nickname: String, avatar: URL, roomNumber: String) async throws -> Bool func leaveRoom() async throws -> Bool - func startGame(roomNumber: String) async throws -> Bool - func changeMode(roomNumber: String, mode: Mode) async throws -> Bool - func changeRecordOrder(roomNumber: String) async throws -> Bool + func startGame() async throws -> Bool + func changeMode(mode: Mode) async throws -> Bool + func changeRecordOrder() async throws -> Bool func resetGame() async throws -> Bool } public protocol MusicRepositoryProtocol { - func getMusicData(url: URL) async -> Data? + func getMusicData(url: URL) async throws -> Data? } public protocol GameStateRepositoryProtocol { @@ -71,5 +71,5 @@ public protocol GameStateRepositoryProtocol { public protocol HummingResultRepositoryProtocol { func getResult() -> AnyPublisher<[(answer: Answer, records: [ASEntity.Record], submit: Answer, recordOrder: UInt8)], Never> - func getRecordData(url: URL) -> Future + func getRecordData(url: URL) async throws -> Data } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/AnswersRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/AnswersRepository.swift index 8259529b..ccc61d92 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/AnswersRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/AnswersRepository.swift @@ -1,31 +1,29 @@ -import ASDecoder -import ASEncoder import ASEntity -import ASNetworkKit +import ASRepositoryProtocol import Combine import Foundation -import ASRepositoryProtocol public final class AnswersRepository: AnswersRepositoryProtocol { private var mainRepository: MainRepositoryProtocol - private var networkManager: ASNetworkManagerProtocol - public init(mainRepository: MainRepositoryProtocol, networkManager: ASNetworkManagerProtocol) { + + public init(mainRepository: MainRepositoryProtocol) { self.mainRepository = mainRepository - self.networkManager = networkManager } public func getAnswers() -> AnyPublisher<[Answer], Never> { - mainRepository.answers + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.answers } + .removeDuplicates() .eraseToAnyPublisher() } public func getAnswersCount() -> AnyPublisher { - mainRepository.answers + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } - .map { $0.count } + .compactMap { $0?.answers } + .map(\.count) + .removeDuplicates() .eraseToAnyPublisher() } @@ -34,9 +32,10 @@ public final class AnswersRepository: AnswersRepositoryProtocol { return Just(nil).eraseToAnyPublisher() } - return mainRepository.answers + return mainRepository.room .receive(on: DispatchQueue.main) - .compactMap(\.self) + .compactMap { $0?.answers } + .removeDuplicates() .flatMap { answers in Just(answers.first { $0.player?.id == myId }) .eraseToAnyPublisher() @@ -45,14 +44,6 @@ public final class AnswersRepository: AnswersRepositoryProtocol { } public func submitMusic(answer: ASEntity.Music) async throws -> Bool { - let queryItems = [URLQueryItem(name: "userId", value: ASFirebaseAuth.myID), - URLQueryItem(name: "roomNumber", value: mainRepository.number.value)] - let endPoint = FirebaseEndpoint(path: .submitMusic, method: .post) - .update(\.queryItems, with: queryItems) - - let body = try ASEncoder.encode(answer) - let response = try await networkManager.sendRequest(to: endPoint, type: .json, body: body, option: .none) - let responseDict = try ASDecoder.decode([String: String].self, from: response) - return !responseDict.isEmpty + try await mainRepository.submitMusic(answer: answer) } } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/AvatarRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/AvatarRepository.swift index fcaf211d..7dc796bd 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/AvatarRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/AvatarRepository.swift @@ -1,24 +1,19 @@ -import ASNetworkKit +import ASRepositoryProtocol import Combine import Foundation -import ASRepositoryProtocol public final class AvatarRepository: AvatarRepositoryProtocol { - // TODO: - Container로 주입 - private let storageManager: ASFirebaseStorageProtocol - private let networkManager: ASNetworkManagerProtocol + private let mainRepository: MainRepositoryProtocol - public init ( - storageManager: ASFirebaseStorageProtocol, - networkManager: ASNetworkManagerProtocol + public init( + mainRepository: MainRepositoryProtocol ) { - self.storageManager = storageManager - self.networkManager = networkManager + self.mainRepository = mainRepository } public func getAvatarUrls() async throws -> [URL] { do { - let urls = try await self.storageManager.getAvatarUrls() + let urls = try await self.mainRepository.getAvatarUrls() return urls } catch { throw error @@ -27,9 +22,7 @@ public final class AvatarRepository: AvatarRepositoryProtocol { public func getAvatarData(url: URL) async -> Data? { do { - guard let endpoint = ResourceEndpoint(url: url) else { return nil } - let data = try await self.networkManager.sendRequest(to: endpoint, type: .none, body: nil, option: .both) - return data + return try await mainRepository.getResource(url: url) } catch { return nil } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/GameStateRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/GameStateRepository.swift index 586287a4..a6928518 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/GameStateRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/GameStateRepository.swift @@ -1,7 +1,7 @@ import ASEntity +import ASRepositoryProtocol import Combine import Foundation -import ASRepositoryProtocol public final class GameStateRepository: GameStateRepositoryProtocol { private var mainRepository: MainRepositoryProtocol @@ -11,12 +11,13 @@ public final class GameStateRepository: GameStateRepositoryProtocol { } public func getGameState() -> AnyPublisher { - Publishers.CombineLatest4(mainRepository.mode, mainRepository.recordOrder, mainRepository.status, mainRepository.round) + mainRepository.room .receive(on: DispatchQueue.main) - .map { mode, recordOrder, status, round in - guard let mode, let round, let players = self.mainRepository.players.value else { return nil } - return ASEntity.GameState(mode: mode, recordOrder: recordOrder, status: status, round: round, players: players) + .compactMap { room in + guard let mode = room?.mode, let players = room?.players else { return nil } + return ASEntity.GameState(mode: mode, recordOrder: room?.recordOrder, status: room?.status, round: room?.round, players: players) } + .removeDuplicates() .eraseToAnyPublisher() } } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/GameStatusRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/GameStatusRepository.swift index 9c555c94..6ebdc247 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/GameStatusRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/GameStatusRepository.swift @@ -1,7 +1,7 @@ -import Foundation -import Combine import ASEntity import ASRepositoryProtocol +import Combine +import Foundation public final class GameStatusRepository: GameStatusRepositoryProtocol { private var mainRepository: MainRepositoryProtocol @@ -11,29 +11,34 @@ public final class GameStatusRepository: GameStatusRepositoryProtocol { } public func getStatus() -> AnyPublisher { - mainRepository.status + mainRepository.room .receive(on: DispatchQueue.main) + .compactMap { $0?.status } + .removeDuplicates() .eraseToAnyPublisher() } public func getRound() -> AnyPublisher { - mainRepository.round + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.round } + .removeDuplicates() .eraseToAnyPublisher() } public func getRecordOrder() -> AnyPublisher { - mainRepository.recordOrder + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.recordOrder } + .removeDuplicates() .eraseToAnyPublisher() } public func getDueTime() -> AnyPublisher { - mainRepository.dueTime + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.dueTime } + .removeDuplicates() .eraseToAnyPublisher() } } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/HummingResultRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/HummingResultRepository.swift index fd5bf857..c2d7891b 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/HummingResultRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/HummingResultRepository.swift @@ -1,37 +1,34 @@ -import Foundation -import Combine import ASEntity -import ASNetworkKit import ASRepositoryProtocol +import Combine +import Foundation public final class HummingResultRepository: HummingResultRepositoryProtocol { private var mainRepository: MainRepositoryProtocol - private let storageManager: ASFirebaseStorageProtocol - private let networkManager: ASNetworkManagerProtocol - public init ( - storageManager: ASFirebaseStorageProtocol, - networkManager: ASNetworkManagerProtocol, + public init( mainRepository: MainRepositoryProtocol ) { - self.storageManager = storageManager - self.networkManager = networkManager self.mainRepository = mainRepository } public func getResult() -> AnyPublisher<[(answer: Answer, records: [ASEntity.Record], submit: Answer, recordOrder: UInt8)], Never> { - Publishers.Zip4(mainRepository.answers, mainRepository.records, mainRepository.submits, mainRepository.recordOrder) - .compactMap { answers, records, submits, recordOrder in - answers?.map { answer in + mainRepository.room + .compactMap { room in + guard let room else { return [] } + return room.answers?.map { answer in let relatedRecords: [ASEntity.Record] = self.getRelatedRecords(for: answer, - from: records, - count: answers?.count ?? 0) - let relatedSubmit: Answer = self.getRelatedSubmit(for: answer, from: submits) + from: room.records, + count: room.answers?.count ?? 0) + let relatedSubmit: Answer = self.getRelatedSubmit(for: answer, from: room.submits) - return (answer: answer, records: relatedRecords, submit: relatedSubmit, recordOrder: recordOrder ?? 0) + return (answer: answer, records: relatedRecords, submit: relatedSubmit, recordOrder: room.recordOrder ?? 0) } } .receive(on: DispatchQueue.main) + .removeDuplicates(by: { lhs, rhs in + self.areResultsEqual(lhs: lhs, rhs: rhs) + }) .eraseToAnyPublisher() } @@ -42,7 +39,7 @@ public final class HummingResultRepository: HummingResultRepositoryProtocol { let tempCheck: Int = (((answer.player?.order ?? 0) + i) % count) if let filteredRecord = records?.first(where: { record in (tempCheck == record.player?.order) && - (record.recordOrder ?? 0 == i) + (record.recordOrder ?? 0 == i) }) { filteredRecords.append(filteredRecord) } @@ -59,36 +56,37 @@ public final class HummingResultRepository: HummingResultRepositoryProtocol { targetOrder == submit.player?.order }) - //TODO: nil 값에 대한 처리 필요 + // TODO: nil 값에 대한 처리 필요 return submit ?? Answer.answerStub1 } - public func getRecordData(url: URL) -> Future { - Future { promise in - Task { - do { - guard let endpoint = ResourceEndpoint(url: url) else { return promise(.failure(ASNetworkErrors.urlError)) } - let data = try await self.networkManager.sendRequest(to: endpoint, type: .json, body: nil, option: .both) - promise(.success(data)) - } catch { - promise(.failure(error)) - } + public func getRecordData(url: URL) async throws -> Data { + try await mainRepository.getResource(url: url) + } +} + +extension HummingResultRepository { + private func areResultsEqual( + lhs: [(answer: Answer, records: [ASEntity.Record], submit: Answer, recordOrder: UInt8)], + rhs: [(answer: Answer, records: [ASEntity.Record], submit: Answer, recordOrder: UInt8)] + ) -> Bool { + guard lhs.count == rhs.count else { return false } + for (index, lhsItem) in lhs.enumerated() { + let rhsItem = rhs[index] + if lhsItem.answer != rhsItem.answer || + lhsItem.records != rhsItem.records || + lhsItem.submit != rhsItem.submit || + lhsItem.recordOrder != rhsItem.recordOrder + { + return false } } + return true } } public final class LocalHummingResultRepository: HummingResultRepositoryProtocol { - private let storageManager: ASFirebaseStorageProtocol - private let networkManager: ASNetworkManagerProtocol - - public init ( - storageManager: ASFirebaseStorageProtocol, - networkManager: ASNetworkManagerProtocol - ) { - self.storageManager = storageManager - self.networkManager = networkManager - } + public init() {} public func getResult() -> AnyPublisher<[(answer: Answer, records: [ASEntity.Record], submit: Answer, recordOrder: UInt8)], Never> { let tempAnswers = [Answer.answerStub1, Answer.answerStub2, Answer.answerStub3, Answer.answerStub4] @@ -117,7 +115,7 @@ public final class LocalHummingResultRepository: HummingResultRepositoryProtocol let tempCheck: Int = (((answer.player?.order ?? 0) + i) % count) if let filteredRecord = records?.first(where: { record in (tempCheck == record.player?.order) && - (record.recordOrder ?? 0 == i) + (record.recordOrder ?? 0 == i) }) { filteredRecords.append(filteredRecord) } @@ -136,17 +134,7 @@ public final class LocalHummingResultRepository: HummingResultRepositoryProtocol return submit ?? Answer.answerStub1 } - public func getRecordData(url: URL) -> Future { - Future { promise in - Task { - do { - guard let endpoint = ResourceEndpoint(url: url) else { return promise(.failure(ASNetworkErrors.urlError)) } - let data = try await self.networkManager.sendRequest(to: endpoint, type: .json, body: nil, option: .both) - promise(.success(data)) - } catch { - promise(.failure(error)) - } - } - } + public func getRecordData(url: URL) async throws -> Data { + Data() } } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/MainRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/MainRepository.swift index 4bf32fde..b74acf07 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/MainRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/MainRepository.swift @@ -3,36 +3,33 @@ import ASEncoder import ASEntity import ASLogKit import ASNetworkKit +import ASRepositoryProtocol import Combine import Foundation -import ASRepositoryProtocol public final class MainRepository: MainRepositoryProtocol { public var myId: String? { ASFirebaseAuth.myID } - public var number = CurrentValueSubject(nil) - public var host = CurrentValueSubject(nil) - public var players = CurrentValueSubject<[Player]?, Never>(nil) - public var mode = CurrentValueSubject(nil) - public var round = CurrentValueSubject(nil) - public var status = CurrentValueSubject(nil) - public var recordOrder = CurrentValueSubject(nil) - public var answers = CurrentValueSubject<[ASEntity.Answer]?, Never>(nil) - public var dueTime = CurrentValueSubject(nil) - public var submits = CurrentValueSubject<[ASEntity.Answer]?, Never>(nil) - public var records = CurrentValueSubject<[ASEntity.Record]?, Never>(nil) - public var selectedRecords = CurrentValueSubject<[UInt8]?, Never>(nil) + public var room = CurrentValueSubject(nil) private let databaseManager: ASFirebaseDatabaseProtocol + private let authManager: ASFirebaseAuthProtocol + private let storageManager: ASFirebaseStorageProtocol private let networkManager: ASNetworkManagerProtocol private var cancellables: Set = [] - public init(databaseManager: ASFirebaseDatabaseProtocol, networkManager: ASNetworkManagerProtocol) { + public init(databaseManager: ASFirebaseDatabaseProtocol, + authManager: ASFirebaseAuthProtocol, + storageManager: ASFirebaseStorageProtocol, + networkManager: ASNetworkManagerProtocol) + { self.databaseManager = databaseManager + self.authManager = authManager + self.storageManager = storageManager self.networkManager = networkManager } public func connectRoom(roomNumber: String) { - databaseManager.addRoomListener(roomNumber: roomNumber) + self.databaseManager.addRoomListener(roomNumber: roomNumber) .receive(on: DispatchQueue.main) .sink { completion in switch completion { @@ -44,80 +41,158 @@ public final class MainRepository: MainRepositoryProtocol { } } receiveValue: { [weak self] room in guard let self else { return } - update(\.number, with: room.number) - update(\.host, with: room.host) - update(\.players, with: room.players) - update(\.mode, with: room.mode) - update(\.round, with: room.round) - update(\.status, with: room.status) - update(\.recordOrder, with: room.recordOrder) - update(\.answers, with: room.answers) - update(\.dueTime, with: room.dueTime) - update(\.submits, with: room.submits) - update(\.records, with: room.records) - update(\.selectedRecords, with: room.selectedRecords) + self.room.send(room) } - .store(in: &cancellables) + .store(in: &self.cancellables) } public func disconnectRoom() { - update(\.number, with: nil) - update(\.host, with: nil) - update(\.players, with: nil) - update(\.mode, with: nil) - update(\.round, with: nil) - update(\.status, with: nil) - update(\.recordOrder, with: nil) - update(\.answers, with: nil) - update(\.dueTime, with: nil) - update(\.submits, with: nil) - update(\.records, with: nil) - update(\.selectedRecords, with: nil) - databaseManager.removeRoomListener() + self.room.send(nil) + self.databaseManager.removeRoomListener() } - - private func update( - _ keyPath: ReferenceWritableKeyPath>, - with newValue: Value? - ) { - let subject = self[keyPath: keyPath] - if subject.value != newValue { - subject.send(newValue) + + public func createRoom(nickname: String, avatar: URL) async throws -> String { + try await self.authManager.signIn(nickname: nickname, avatarURL: avatar) + let response: [String: String]? = try await self.sendRequest( + endpointPath: .createRoom, + requestBody: ["hostID": myId] + ) + guard let roomNumber = response?["number"] as? String else { + throw ASNetworkErrors.responseError } + return roomNumber } - - public func postRecording(_ record: Data) async throws -> Bool { - let queryItems = [URLQueryItem(name: "userId", value: ASFirebaseAuth.myID), - URLQueryItem(name: "roomNumber", value: number.value)] - let endPoint = FirebaseEndpoint(path: .uploadRecording, method: .post) + + public func joinRoom(nickname: String, avatar: URL, roomNumber: String) async throws -> Bool { + let response: [String: String] = try await self.sendRequest( + endpointPath: .joinRoom, + requestBody: ["roomNumber": roomNumber, "userId": myId] + ) + guard let roomNumberResponse = response["number"] else { + throw ASNetworkErrors.responseError + } + return roomNumberResponse == roomNumber + } + + public func leaveRoom() async throws -> Bool { + self.disconnectRoom() + try await self.authManager.signOut() + return true + } + + public func startGame() async throws -> Bool { + let response: [String: Bool] = try await self.sendRequest( + endpointPath: .gameStart, + requestBody: ["roomNumber": self.room.value?.number, "userId": self.myId] + ) + guard let response = response["success"] else { + throw ASNetworkErrors.responseError + } + return response + } + + public func changeMode(mode: ASEntity.Mode) async throws -> Bool { + let response: [String: Bool] = try await self.sendRequest( + endpointPath: .changeMode, + requestBody: ["roomNumber": self.room.value?.number, "userId": self.myId, "mode": mode.rawValue] + ) + guard let isSuccess = response["success"] else { + throw ASNetworkErrors.responseError + } + return isSuccess + } + + public func changeRecordOrder() async throws -> Bool { + let response: [String: Bool] = try await self.sendRequest( + endpointPath: .changeRecordOrder, + requestBody: ["roomNumber": self.room.value?.number, "userId": self.myId] + ) + guard let isSuccess = response["success"] else { + throw ASNetworkErrors.responseError + } + return isSuccess + } + + public func resetGame() async throws -> Bool { + let queryItems = [URLQueryItem(name: "userId", value: myId), + URLQueryItem(name: "roomNumber", value: self.room.value?.number)] + let endPoint = FirebaseEndpoint(path: .resetGame, method: .post) .update(\.queryItems, with: queryItems) let response = try await networkManager.sendRequest( to: endPoint, - type: .multipart, - body: record, + type: .none, + body: nil, option: .none ) + let responseDict = try ASDecoder.decode([String: Bool].self, from: response) guard let success = responseDict["success"] else { return false } return success } + + public func submitMusic(answer: ASEntity.Music) async throws -> Bool { + let queryItems = [URLQueryItem(name: "userId", value: myId), + URLQueryItem(name: "roomNumber", value: self.room.value?.number)] + let endPoint = FirebaseEndpoint(path: .submitMusic, method: .post) + .update(\.queryItems, with: queryItems) + + let body = try ASEncoder.encode(answer) + let response = try await networkManager.sendRequest(to: endPoint, type: .json, body: body, option: .none) + let responseDict = try ASDecoder.decode([String: String].self, from: response) + return !responseDict.isEmpty + } - public func postResetGame() async throws -> Bool { - let queryItems = [URLQueryItem(name: "userId", value: ASFirebaseAuth.myID), - URLQueryItem(name: "roomNumber", value: number.value)] - let endPoint = FirebaseEndpoint(path: .resetGame, method: .post) + public func getAvatarUrls() async throws -> [URL] { + try await self.storageManager.getAvatarUrls() + } + + public func getResource(url: URL) async throws -> Data { + do { + guard let endpoint = ResourceEndpoint(url: url) else { throw ASNetworkErrors.urlError } + let data = try await self.networkManager.sendRequest(to: endpoint, type: .none, body: nil, option: .both) + return data + } catch { + throw error + } + } + + public func submitAnswer(answer: ASEntity.Music) async throws -> Bool { + let queryItems = [URLQueryItem(name: "userId", value: myId), + URLQueryItem(name: "roomNumber", value: self.room.value?.number)] + let endPoint = FirebaseEndpoint(path: .submitAnswer, method: .post) + .update(\.queryItems, with: queryItems) + .update(\.headers, with: ["Content-Type": "application/json"]) + + let body = try ASEncoder.encode(answer) + let response = try await networkManager.sendRequest(to: endPoint, type: .json, body: body, option: .none) + let responseDict = try ASDecoder.decode([String: String].self, from: response) + return !responseDict.isEmpty + } + + public func postRecording(_ record: Data) async throws -> Bool { + let queryItems = [URLQueryItem(name: "userId", value: myId), + URLQueryItem(name: "roomNumber", value: self.room.value?.number)] + let endPoint = FirebaseEndpoint(path: .uploadRecording, method: .post) .update(\.queryItems, with: queryItems) let response = try await networkManager.sendRequest( to: endPoint, - type: .none, - body: nil, + type: .multipart, + body: record, option: .none ) - let responseDict = try ASDecoder.decode([String: Bool].self, from: response) guard let success = responseDict["success"] else { return false } return success } + + private func sendRequest(endpointPath: FirebaseEndpoint.Path, requestBody: [String: Any]) async throws -> T { + let endpoint = FirebaseEndpoint(path: endpointPath, method: .post) + Logger.debug("Request to \(endpoint)") + let body = try JSONSerialization.data(withJSONObject: requestBody, options: []) + let data = try await networkManager.sendRequest(to: endpoint, type: .json, body: body, option: .none) + let response = try JSONDecoder().decode(T.self, from: data) + return response + } } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/MusicRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/MusicRepository.swift index 7592a0ca..d4f32a72 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/MusicRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/MusicRepository.swift @@ -1,28 +1,17 @@ -import ASNetworkKit +import ASRepositoryProtocol import Combine import Foundation -import ASRepositoryProtocol public final class MusicRepository: MusicRepositoryProtocol { - // TODO: - Container로 주입 - private let firebaseManager: ASFirebaseStorageProtocol - private let networkManager: ASNetworkManagerProtocol + private let mainRepository: MainRepositoryProtocol public init( - firebaseManager: ASFirebaseStorageProtocol, - networkManager: ASNetworkManagerProtocol + mainRepository: MainRepositoryProtocol ) { - self.firebaseManager = firebaseManager - self.networkManager = networkManager + self.mainRepository = mainRepository } - public func getMusicData(url: URL) async -> Data? { - do { - guard let endpoint = ResourceEndpoint(url: url) else { return nil } - let data = try await self.networkManager.sendRequest(to: endpoint, type: .json, body: nil, option: .both) - return data - } catch { - return nil - } + public func getMusicData(url: URL) async throws -> Data? { + try await mainRepository.getResource(url: url) } } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/PlayersRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/PlayersRepository.swift index 3e4e1038..b669b866 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/PlayersRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/PlayersRepository.swift @@ -1,46 +1,40 @@ -import Foundation -import ASNetworkKit -import Combine import ASEntity import ASRepositoryProtocol +import Combine +import Foundation public final class PlayersRepository: PlayersRepositoryProtocol { private var mainRepository: MainRepositoryProtocol - private var firebaseAuthManager: ASFirebaseAuthProtocol - public init(mainRepository: MainRepositoryProtocol, - firebaseAuthManager: ASFirebaseAuthProtocol) { + public init(mainRepository: MainRepositoryProtocol) { self.mainRepository = mainRepository - self.firebaseAuthManager = firebaseAuthManager } public func getPlayers() -> AnyPublisher<[Player], Never> { - mainRepository.players + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.players } + .removeDuplicates() .eraseToAnyPublisher() } public func getPlayersCount() -> AnyPublisher { - mainRepository.players - .receive(on: DispatchQueue.main) - .compactMap { $0 } - .map { $0.count } + getPlayers() + .map(\.count) .eraseToAnyPublisher() } public func getHost() -> AnyPublisher { - mainRepository.host + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.host } + .removeDuplicates() .eraseToAnyPublisher() } public func isHost() -> AnyPublisher { - self.getHost() - .receive(on: DispatchQueue.main) - .map { $0.id == ASFirebaseAuth.myID } + getHost() + .map { $0.id == self.mainRepository.myId } .eraseToAnyPublisher() } } - diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/RecordsRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/RecordsRepository.swift index 01179f9d..b015c838 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/RecordsRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/RecordsRepository.swift @@ -1,8 +1,8 @@ import ASEntity -import Combine -import Foundation import ASLogKit import ASRepositoryProtocol +import Combine +import Foundation public final class RecordsRepository: RecordsRepositoryProtocol { private var mainRepository: MainRepositoryProtocol @@ -12,30 +12,30 @@ public final class RecordsRepository: RecordsRepositoryProtocol { } public func getRecords() -> AnyPublisher<[ASEntity.Record], Never> { - mainRepository.records + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.records } + .removeDuplicates() .eraseToAnyPublisher() } public func getRecordsCount(on recordOrder: UInt8) -> AnyPublisher { - return mainRepository.records + getRecords() .compactMap { $0 } .map { records in - return records.filter { Int($0.recordOrder ?? 0) == recordOrder } - } - .map { - return $0.count + records.filter { Int($0.recordOrder ?? 0) == recordOrder } } + .map(\.count) .eraseToAnyPublisher() } public func getHumming(on recordOrder: UInt8) -> AnyPublisher { - let recordsPublisher = mainRepository.records - let playersPublisher = mainRepository.players - - return recordsPublisher - .combineLatest(playersPublisher) + mainRepository.room + .receive(on: DispatchQueue.main) + .compactMap { room in + guard let room else { return nil } + return (room.records, room.players) + } .map { [weak self] records, players -> ASEntity.Record? in self?.findRecord( records: records, @@ -43,16 +43,17 @@ public final class RecordsRepository: RecordsRepositoryProtocol { recordOrder: recordOrder ) } + .removeDuplicates() .eraseToAnyPublisher() } public func uploadRecording(_ record: Data) async throws -> Bool { - return try await mainRepository.postRecording(record) + try await mainRepository.postRecording(record) } - + private func findRecord(records: [ASEntity.Record]?, - players: [Player]?, - recordOrder: UInt8) -> ASEntity.Record? + players: [Player]?, + recordOrder: UInt8) -> ASEntity.Record? { guard let records, let players, !players.isEmpty, @@ -74,10 +75,10 @@ public final class RecordsRepository: RecordsRepositoryProtocol { } private func findTargetOrder(for myOrder: Int, in playersCount: Int) -> Int { - return (myOrder - 1 + playersCount) % playersCount + (myOrder - 1 + playersCount) % playersCount } private func findHummings(for recordOrder: UInt8, in records: [ASEntity.Record]) -> [ASEntity.Record] { - return records.filter { $0.recordOrder == (recordOrder - 1) } + records.filter { $0.recordOrder == (recordOrder - 1) } } } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/RoomActionRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/RoomActionRepository.swift index 49a33abe..d089cc04 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/RoomActionRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/RoomActionRepository.swift @@ -1,96 +1,42 @@ import ASEntity -import ASNetworkKit +import ASRepositoryProtocol import Combine import Foundation -import ASRepositoryProtocol public final class RoomActionRepository: RoomActionRepositoryProtocol { private let mainRepository: MainRepositoryProtocol - private let authManager: ASFirebaseAuthProtocol - private let networkManager: ASNetworkManagerProtocol public init( - mainRepository: MainRepositoryProtocol, - authManager: ASFirebaseAuthProtocol, - networkManager: ASNetworkManagerProtocol + mainRepository: MainRepositoryProtocol ) { self.mainRepository = mainRepository - self.authManager = authManager - self.networkManager = networkManager } public func createRoom(nickname: String, avatar: URL) async throws -> String { - try await self.authManager.signIn(nickname: nickname, avatarURL: avatar) - let response: [String: String]? = try await self.sendRequest( - endpointPath: .createRoom, - requestBody: ["hostID": ASFirebaseAuth.myID] - ) - guard let roomNumber = response?["number"] as? String else { - throw ASNetworkErrors.responseError - } - return roomNumber + try await mainRepository.createRoom(nickname: nickname, avatar: avatar) } public func joinRoom(nickname: String, avatar: URL, roomNumber: String) async throws -> Bool { - let player = try await self.authManager.signIn(nickname: nickname, avatarURL: avatar) - let response: [String: String]? = try await self.sendRequest( - endpointPath: .joinRoom, - requestBody: ["roomNumber": roomNumber, "userId": ASFirebaseAuth.myID] - ) - guard let roomNumberResponse = response?["number"] as? String else { - throw ASNetworkErrors.responseError - } - return roomNumberResponse == roomNumber + try await mainRepository.joinRoom(nickname: nickname, avatar: avatar, roomNumber: roomNumber) } public func leaveRoom() async throws -> Bool { - self.mainRepository.disconnectRoom() - try await self.authManager.signOut() - return true + try await mainRepository.leaveRoom() } - public func startGame(roomNumber: String) async throws -> Bool { - let response: [String: Bool]? = try await self.sendRequest( - endpointPath: .gameStart, - requestBody: ["roomNumber": roomNumber, "userId": ASFirebaseAuth.myID] - ) - guard let response = response?["success"] as? Bool else { - throw ASNetworkErrors.responseError - } - return response + public func startGame() async throws -> Bool { + try await mainRepository.startGame() } - public func changeMode(roomNumber: String, mode: Mode) async throws -> Bool { - let response: [String: Bool] = try await self.sendRequest( - endpointPath: .changeMode, - requestBody: ["roomNumber": roomNumber, "userId": ASFirebaseAuth.myID, "mode": mode.rawValue] - ) - guard let isSuccess = response["success"] as? Bool else { - throw ASNetworkErrors.responseError - } - return isSuccess + public func changeMode(mode: Mode) async throws -> Bool { + try await mainRepository.changeMode(mode: mode) } - public func changeRecordOrder(roomNumber: String) async throws -> Bool { - let response: [String: Bool] = try await self.sendRequest( - endpointPath: .changeRecordOrder, - requestBody: ["roomNumber": roomNumber, "userId": ASFirebaseAuth.myID] - ) - guard let isSuccess = response["success"] as? Bool else { - throw ASNetworkErrors.responseError - } - return isSuccess + public func changeRecordOrder() async throws -> Bool { + try await mainRepository.changeRecordOrder() } public func resetGame() async throws -> Bool { - return try await mainRepository.postResetGame() - } - - private func sendRequest(endpointPath: FirebaseEndpoint.Path, requestBody: [String: Any]) async throws -> T { - let endpoint = FirebaseEndpoint(path: endpointPath, method: .post) - let body = try JSONSerialization.data(withJSONObject: requestBody, options: []) - let data = try await networkManager.sendRequest(to: endpoint, type: .json, body: body, option: .none) - let response = try JSONDecoder().decode(T.self, from: data) - return response + try await mainRepository.resetGame() } } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/RoomInfoRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/RoomInfoRepository.swift index eebd887f..b01d9a99 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/RoomInfoRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/RoomInfoRepository.swift @@ -1,7 +1,7 @@ -import Foundation -import Combine import ASEntity import ASRepositoryProtocol +import Combine +import Foundation public final class RoomInfoRepository: RoomInfoRepositoryProtocol { private var mainRepository: MainRepositoryProtocol @@ -11,23 +11,26 @@ public final class RoomInfoRepository: RoomInfoRepositoryProtocol { } public func getRoomNumber() -> AnyPublisher { - mainRepository.number + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.number } + .removeDuplicates() .eraseToAnyPublisher() } public func getMode() -> AnyPublisher { - mainRepository.mode + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.mode } + .removeDuplicates() .eraseToAnyPublisher() } public func getRecordOrder() -> AnyPublisher { - mainRepository.recordOrder + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.recordOrder } + .removeDuplicates() .eraseToAnyPublisher() } } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/SelectedRecordsRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/SelectedRecordsRepository.swift index 5adf1dc1..261ecb12 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/SelectedRecordsRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/SelectedRecordsRepository.swift @@ -1,20 +1,20 @@ -import Foundation -import Combine import ASEntity import ASRepositoryProtocol +import Combine +import Foundation public final class SelectedRecordsRepository: SelectedRecordsRepositoryProtocol { private var mainRepository: MainRepositoryProtocol - + public init(mainRepository: MainRepositoryProtocol) { self.mainRepository = mainRepository } - + public func getSelectedRecords() -> AnyPublisher<[UInt8], Never> { - mainRepository.selectedRecords + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.selectedRecords } + .removeDuplicates() .eraseToAnyPublisher() } } - diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/SubmitsRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/SubmitsRepository.swift index 3b1cd8f4..e79b34cc 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/SubmitsRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/SubmitsRepository.swift @@ -1,45 +1,30 @@ -import ASDecoder -import ASEncoder import ASEntity -import ASNetworkKit +import ASRepositoryProtocol import Combine import Foundation -import ASRepositoryProtocol public final class SubmitsRepository: SubmitsRepositoryProtocol { private var mainRepository: MainRepositoryProtocol - private var networkManager: ASNetworkManagerProtocol - public init(mainRepository: MainRepositoryProtocol, networkManager: ASNetworkManagerProtocol) { + public init(mainRepository: MainRepositoryProtocol) { self.mainRepository = mainRepository - self.networkManager = networkManager } public func getSubmits() -> AnyPublisher<[Answer], Never> { - mainRepository.answers + mainRepository.room .receive(on: DispatchQueue.main) - .compactMap { $0 } + .compactMap { $0?.submits } + .removeDuplicates() .eraseToAnyPublisher() } - + public func getSubmitsCount() -> AnyPublisher { - mainRepository.submits - .receive(on: DispatchQueue.main) - .compactMap { $0 } - .map { $0.count } + getSubmits() + .map(\.count) .eraseToAnyPublisher() } - - public func submitAnswer(answer: Music) async throws -> Bool { - let queryItems = [URLQueryItem(name: "userId", value: ASFirebaseAuth.myID), - URLQueryItem(name: "roomNumber", value: mainRepository.number.value)] - let endPoint = FirebaseEndpoint(path: .submitAnswer, method: .post) - .update(\.queryItems, with: queryItems) - .update(\.headers, with: ["Content-Type": "application/json"]) - let body = try ASEncoder.encode(answer) - let response = try await networkManager.sendRequest(to: endPoint, type: .json, body: body, option: .none) - let responseDict = try ASDecoder.decode([String: String].self, from: response) - return !responseDict.isEmpty + public func submitAnswer(answer: Music) async throws -> Bool { + try await mainRepository.submitAnswer(answer: answer) } } diff --git a/alsongDalsong/ASRepository/ASRepository/RepsotioryAssembly.swift b/alsongDalsong/ASRepository/ASRepository/RepsotioryAssembly.swift index 8436cf5a..d2c7ce9d 100644 --- a/alsongDalsong/ASRepository/ASRepository/RepsotioryAssembly.swift +++ b/alsongDalsong/ASRepository/ASRepository/RepsotioryAssembly.swift @@ -8,28 +8,28 @@ public struct RepsotioryAssembly: Assembly { public func assemble(container: Registerable) { container.registerSingleton(MainRepositoryProtocol.self) { r in let databaseManager = r.resolve(ASFirebaseDatabaseProtocol.self) + let authManager = r.resolve(ASFirebaseAuthProtocol.self) let networkManager = r.resolve(ASNetworkManagerProtocol.self) + let storageManager = r.resolve(ASFirebaseStorageProtocol.self) return MainRepository( databaseManager: databaseManager, + authManager: authManager, + storageManager: storageManager, networkManager: networkManager ) } container.register(AnswersRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) - let networkManager = r.resolve(ASNetworkManagerProtocol.self) return AnswersRepository( - mainRepository: mainRepository, - networkManager: networkManager + mainRepository: mainRepository ) } container.register(AvatarRepositoryProtocol.self) { r in - let storageManager = r.resolve(ASFirebaseStorageProtocol.self) - let networkManager = r.resolve(ASNetworkManagerProtocol.self) + let mainRepository = r.resolve(MainRepositoryProtocol.self) return AvatarRepository( - storageManager: storageManager, - networkManager: networkManager + mainRepository: mainRepository ) } @@ -41,20 +41,15 @@ public struct RepsotioryAssembly: Assembly { } container.register(MusicRepositoryProtocol.self) { r in - let firebaseManager = r.resolve(ASFirebaseStorageProtocol.self) - let networkManager = r.resolve(ASNetworkManagerProtocol.self) - return MusicRepository( - firebaseManager: firebaseManager, - networkManager: networkManager + let mainRepository = r.resolve(MainRepositoryProtocol.self) + return MusicRepository(mainRepository: mainRepository ) } container.register(PlayersRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) - let firebaseAuthManager = r.resolve(ASFirebaseAuthProtocol.self) return PlayersRepository( - mainRepository: mainRepository, - firebaseAuthManager: firebaseAuthManager + mainRepository: mainRepository ) } @@ -67,12 +62,8 @@ public struct RepsotioryAssembly: Assembly { container.register(RoomActionRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) - let authManager = r.resolve(ASFirebaseAuthProtocol.self) - let networkManager = r.resolve(ASNetworkManagerProtocol.self) return RoomActionRepository( - mainRepository: mainRepository, - authManager: authManager, - networkManager: networkManager + mainRepository: mainRepository ) } @@ -92,10 +83,8 @@ public struct RepsotioryAssembly: Assembly { container.register(SubmitsRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) - let networkManager = r.resolve(ASNetworkManagerProtocol.self) return SubmitsRepository( - mainRepository: mainRepository, - networkManager: networkManager + mainRepository: mainRepository ) } @@ -106,11 +95,7 @@ public struct RepsotioryAssembly: Assembly { container.register(HummingResultRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) - let storageManager = r.resolve(ASFirebaseStorageProtocol.self) - let networkManager = r.resolve(ASNetworkManagerProtocol.self) return HummingResultRepository( - storageManager: storageManager, - networkManager: networkManager, mainRepository: mainRepository ) } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/CornerImageView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/GuideIconView.swift similarity index 96% rename from alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/CornerImageView.swift rename to alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/GuideIconView.swift index 89bb1c32..03254ae8 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/CornerImageView.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/GuideIconView.swift @@ -17,8 +17,7 @@ final class GuideIconView: UIView { } private func setupImageView(image: UIImage?) { - imageView.image = image - imageView.tintColor = .white + imageView.image = image?.withTintColor(.white, renderingMode: .alwaysOriginal) imageView.translatesAutoresizingMaskIntoConstraints = false addSubview(imageView) diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/UIViewController+Keyboard.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/UIViewController+Keyboard.swift deleted file mode 100644 index 5d4a3952..00000000 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Extensions/UIViewController+Keyboard.swift +++ /dev/null @@ -1,39 +0,0 @@ -import UIKit - -extension UIViewController { - func hideKeyboard() { - view.addGestureRecognizer( - UITapGestureRecognizer( - target: self, - action: #selector( - UIViewController.dismissKeyboard - ) - ) - ) - } - - @objc func dismissKeyboard() { - view.endEditing(true) - } - - @objc func keyboardWillShow(_ notification: NSNotification) { - if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { - let keyboardRectangle = keyboardFrame.cgRectValue - let keyboardHeight = keyboardRectangle.height - - UIView.animate(withDuration: 0.3) { - self.view.transform = CGAffineTransform(translationX: 0, y: -keyboardHeight) - } - } - } - - @objc func keyboardWillHide(_ notification: NSNotification) { - UIView.animate(withDuration: 0.3) { - self.view.transform = .identity - } - } - - @objc func appDidEnterBackground(_ notification: NSNotification) { - view.endEditing(true) - } -} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewController.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewController.swift index f3287e6a..eec61475 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewController.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewController.swift @@ -38,7 +38,8 @@ final class LobbyViewController: UIViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) if viewmodel.isLeaveRoom { - viewmodel.leaveRoom() + viewmodel.cancelSubscriptions() + cancellables.removeAll() } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewModel.swift index faabcb8a..18d50e35 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewModel.swift @@ -1,8 +1,8 @@ import ASEntity +import ASLogKit import ASRepositoryProtocol import Combine import Foundation -import ASLogKit final class LobbyViewModel: ObservableObject, @unchecked Sendable { private var playersRepository: PlayersRepositoryProtocol @@ -89,14 +89,13 @@ final class LobbyViewModel: ObservableObject, @unchecked Sendable { .receive(on: DispatchQueue.main) .sink { [weak self] isHost, playerCount in self?.canBeginGame = isHost && playerCount > 1 - Logger.debug("현재 canBeginGame: \(self?.canBeginGame)") } .store(in: &cancellables) } func gameStart() async throws { do { - _ = try await roomActionRepository.startGame(roomNumber: roomNumber) + _ = try await roomActionRepository.startGame() } catch { throw error } @@ -116,11 +115,15 @@ final class LobbyViewModel: ObservableObject, @unchecked Sendable { Task { do { if isHost { - _ = try await self.roomActionRepository.changeMode(roomNumber: roomNumber, mode: mode) + _ = try await self.roomActionRepository.changeMode(mode: mode) } } catch { Logger.error(error.localizedDescription) } } } + + public func cancelSubscriptions() { + cancellables.removeAll() + } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanelViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanelViewModel.swift index c4bee06b..06839732 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanelViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanelViewModel.swift @@ -88,14 +88,14 @@ final class MusicPanelViewModel: @unchecked Sendable { private func getPreviewData() { guard let previewUrl = music?.previewUrl else { return } Task { @MainActor in - preview = await musicRepository?.getMusicData(url: previewUrl) + preview = try await musicRepository?.getMusicData(url: previewUrl) } } private func getArtworkData() { guard let artworkUrl = music?.artworkUrl else { return } Task { @MainActor in - artwork = await musicRepository?.getMusicData(url: artworkUrl) + artwork = try await musicRepository?.getMusicData(url: artworkUrl) } } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewController.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewController.swift index 4ac01865..f3ad4e31 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewController.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewController.swift @@ -14,6 +14,7 @@ final class OnboardingViewController: UIViewController { private var inviteCode: String private var gameNavigationController: GameNavigationController? private var cancellables = Set() + var shouldMoveKeyboard: Bool = true init(viewmodel: OnboardingViewModel, inviteCode: String) { viewModel = viewmodel @@ -45,6 +46,7 @@ final class OnboardingViewController: UIViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + view.endEditing(true) NotificationCenter.default.removeObserver(self) } @@ -227,13 +229,18 @@ extension OnboardingViewController { extension OnboardingViewController { private func showRoomNumerInputAlert() { + shouldMoveKeyboard = false let alert = InputAlertController( titleText: .joinRoom, textFieldPlaceholder: .roomNumber, isUppercased: true ) { [weak self] roomNumber in self?.setNicknameAndJoinRoom(with: roomNumber) + self?.shouldMoveKeyboard = true + } secondaryButtonAction: { + self.shouldMoveKeyboard = true } + presentAlert(alert) } @@ -279,4 +286,41 @@ private extension OnboardingViewController { object: nil ) } + + func hideKeyboard() { + view.addGestureRecognizer( + UITapGestureRecognizer( + target: self, + action: #selector( + OnboardingViewController.dismissKeyboard + ) + ) + ) + } + + @objc func dismissKeyboard() { + view.endEditing(true) + } + + @objc func keyboardWillShow(_ notification: NSNotification) { + guard shouldMoveKeyboard else { return } + if let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue { + let keyboardRectangle = keyboardFrame.cgRectValue + let keyboardHeight = keyboardRectangle.height + + UIView.animate(withDuration: 0.3) { + self.view.transform = CGAffineTransform(translationX: 0, y: -keyboardHeight) + } + } + } + + @objc func keyboardWillHide(_ notification: NSNotification) { + UIView.animate(withDuration: 0.3) { + self.view.transform = .identity + } + } + + @objc func appDidEnterBackground(_ notification: NSNotification) { + view.endEditing(true) + } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewController.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewController.swift index 186cc476..f2ea2bac 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewController.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewController.swift @@ -32,6 +32,7 @@ class HummingResultViewController: UIViewController { override func viewDidDisappear(_ animated: Bool) { viewModel?.cancelSubscriptions() + cancellables.removeAll() } private func setResultTableView() { diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel.swift index d76488c6..f2c599d2 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel.swift @@ -111,7 +111,7 @@ final class HummingResultViewModel: @unchecked Sendable { currentRecords.append(recordsResult.removeFirst()) guard let fileUrl = currentRecords.last?.fileUrl else { continue } do { - let data = try await fetchRecordData(url: fileUrl) + let data = try await getRecordData(url: fileUrl) await AudioHelper.shared.startPlaying(data) await waitForPlaybackToFinish() } catch { @@ -123,7 +123,7 @@ final class HummingResultViewModel: @unchecked Sendable { private func startPlayingCurrentMusic() async -> Void { guard let fileUrl = currentResult?.music?.previewUrl else { return } - let data = await musicRepository.getMusicData(url: fileUrl) + let data = try? await musicRepository.getMusicData(url: fileUrl) await AudioHelper.shared.startPlaying(data, option: .partial(time: 10)) await waitForPlaybackToFinish() } @@ -150,29 +150,12 @@ final class HummingResultViewModel: @unchecked Sendable { currentsubmit = nil } - private func getRecordData(url: URL?) -> AnyPublisher { - if let url { - hummingResultRepository.getRecordData(url: url) - .eraseToAnyPublisher() - } else { - Just(nil) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - } - - private func fetchRecordData(url: URL) async throws -> Data { - try await withCheckedThrowingContinuation { continuation in - getRecordData(url: url) - .compactMap { $0 } - .sink(receiveCompletion: { completion in - if case .failure(let error) = completion { - continuation.resume(throwing: error) - } - }, receiveValue: { data in - continuation.resume(returning: data) - }) - .store(in: &cancellables) + private func getRecordData(url: URL?) async throws -> Data? { + guard let url else { return nil } + do { + return try await hummingResultRepository.getRecordData(url: url) + } catch { + return nil } } @@ -183,7 +166,7 @@ final class HummingResultViewModel: @unchecked Sendable { func changeRecordOrder() async throws { do { - try await roomActionRepository.changeRecordOrder(roomNumber: roomNumber) + try await roomActionRepository.changeRecordOrder() } catch { Logger.error(error.localizedDescription) throw error @@ -201,7 +184,11 @@ final class HummingResultViewModel: @unchecked Sendable { func getArtworkData(url: URL?) async -> Data? { guard let url else { return nil } - return await musicRepository.getMusicData(url: url) + do { + return try await musicRepository.getMusicData(url: url) + } catch { + return nil + } } public func cancelSubscriptions() { diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicView.swift index cbd83621..c1b05b2c 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicView.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicView.swift @@ -14,14 +14,16 @@ struct SelectMusicView: View { .scaleEffect(1.1) Spacer() Button { - viewModel.isPlaying.toggle() + if viewModel.selectedMusic != nil { + viewModel.isPlaying.toggle() + } } label: { if #available(iOS 17.0, *) { - Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + Image(systemName: viewModel.isPlaying ? "stop.fill" : "play.fill") .font(.largeTitle) .contentTransition(.symbolEffect(.replace.offUp)) } else { - Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + Image(systemName: viewModel.isPlaying ? "stop.fill" : "play.fill") .font(.largeTitle) } } @@ -49,13 +51,14 @@ struct SelectMusicView: View { Spacer() } } else { - if viewModel.searchList.isEmpty { + if viewModel.isSearching { VStack { Spacer() ProgressView() .scaleEffect(2.0) Spacer() } + .scrollDismissesKeyboard(.immediately) } else { List(viewModel.searchList) { music in Button { @@ -68,6 +71,7 @@ struct SelectMusicView: View { } } .listStyle(.plain) + .scrollDismissesKeyboard(.immediately) } } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewController.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewController.swift index 095f23ea..4e9607ab 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewController.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewController.swift @@ -82,10 +82,22 @@ class SelectMusicViewController: UIViewController { }, for: .touchUpInside) progressBar.setCompletionHandler { [weak self] in + guard let selectedMusic = self?.viewModel.selectedMusic else { + self?.showSubmitRandomMusicLoading() + return + } self?.showSubmitMusicLoading() } } + private func pickRandomMusic() async throws { + do { + try await viewModel.randomMusic() + } catch { + throw error + } + } + private func submitMusic() async throws { do { viewModel.stopMusic() @@ -113,6 +125,19 @@ extension SelectMusicViewController { presentAlert(alert) } + private func showSubmitRandomMusicLoading() { + let alert = LoadingAlertController( + progressText: .submitMusic, + loadAction: { [weak self] in + try await self?.pickRandomMusic() + try await self?.submitMusic() + }, + errorCompletion: { [weak self] error in + self?.showFailSubmitMusic(error) + }) + presentAlert(alert) + } + private func showFailSubmitMusic(_ error: Error) { let alert = SingleButtonAlertController(titleText: .error(error)) presentAlert(alert) diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewModel.swift index 9406c83e..07d15cb8 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewModel.swift @@ -7,6 +7,7 @@ import Foundation final class SelectMusicViewModel: ObservableObject, @unchecked Sendable { @Published public private(set) var answers: [Answer] = [] @Published public private(set) var searchList: [Music] = [] + @Published public private(set) var isSearching: Bool = false @Published public private(set) var dueTime: Date? @Published public private(set) var selectedMusic: Music? @Published public private(set) var submissionStatus: (submits: String, total: String) = ("0", "0") @@ -50,7 +51,7 @@ final class SelectMusicViewModel: ObservableObject, @unchecked Sendable { } .store(in: &cancellables) } - + private func bindGameStatus() { gameStatusRepository.getDueTime() .receive(on: DispatchQueue.main) @@ -88,27 +89,45 @@ final class SelectMusicViewModel: ObservableObject, @unchecked Sendable { public func downloadArtwork(url: URL?) async -> Data? { guard let url else { return nil } - return await musicRepository.getMusicData(url: url) + do { + return try await musicRepository.getMusicData(url: url) + } + catch { + return nil + } } public func handleSelectedSong(with music: Music) { selectedMusic = music beginPlaying() } + public func submitMusic() async throws { - guard let selectedMusic else { return } - do { - _ = try await answersRepository.submitMusic(answer: selectedMusic) - } catch { - throw error + if let selectedMusic { + do { + _ = try await answersRepository.submitMusic(answer: selectedMusic) + } catch { + throw error + } } } public func searchMusic(text: String) async throws { do { if text.isEmpty { return } + await updateIsSearching(with: true) let searchList = try await musicAPI.search(for: text) await updateSearchList(with: searchList) + await updateIsSearching(with: false) + } catch { + throw error + } + } + + public func randomMusic() async throws { + do { + let selectedMusic = try await musicAPI.randomSong(from: "pl.u-aZb00o7uPlzMZzr") + await updateSelectedMusic(with: selectedMusic) } catch { throw error } @@ -116,7 +135,7 @@ final class SelectMusicViewModel: ObservableObject, @unchecked Sendable { public func downloadMusic(url: URL) { Task { - guard let musicData = await musicRepository.getMusicData(url: url) else { + guard let musicData = try await musicRepository.getMusicData(url: url) else { return } await updateMusicData(with: musicData) @@ -128,7 +147,6 @@ final class SelectMusicViewModel: ObservableObject, @unchecked Sendable { downloadMusic(url: url) } - @MainActor public func resetSearchList() { searchList = [] } @@ -143,6 +161,16 @@ final class SelectMusicViewModel: ObservableObject, @unchecked Sendable { self.searchList = searchList } + @MainActor + private func updateIsSearching(with isSearching: Bool) { + self.isSearching = isSearching + } + + @MainActor + private func updateSelectedMusic(with music: Music) { + selectedMusic = music + } + public func cancelSubscriptions() { cancellables.removeAll() } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SelectAnswerView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SelectAnswerView.swift index 0a5d628b..d1d84b70 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SelectAnswerView.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SelectAnswerView.swift @@ -5,6 +5,8 @@ struct SelectAnswerView: View { @ObservedObject var viewModel: SubmitAnswerViewModel @State var searchTerm = "" @Environment(\.dismiss) private var dismiss + @FocusState private var isFocused: Bool + private let debouncer = Debouncer(delay: 0.5) var body: some View { @@ -17,14 +19,16 @@ struct SelectAnswerView: View { .scaleEffect(1.1) Spacer() Button { - viewModel.isPlaying.toggle() + if viewModel.selectedMusic != nil { + viewModel.isPlaying.toggle() + } } label: { if #available(iOS 17.0, *) { - Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + Image(systemName: viewModel.isPlaying ? "stop.fill" : "play.fill") .font(.largeTitle) .contentTransition(.symbolEffect(.replace.offUp)) } else { - Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") + Image(systemName: viewModel.isPlaying ? "stop.fill" : "play.fill") .font(.largeTitle) } } @@ -32,7 +36,10 @@ struct SelectAnswerView: View { .frame(width: 60) } .padding(16) - + .onTapGesture { + isFocused = false + } + ASSearchBar(text: $searchTerm, placeHolder: "노래를 선택하세요") .onChange(of: searchTerm) { newValue in debouncer.debounce { @@ -42,17 +49,29 @@ struct SelectAnswerView: View { } } } - List(viewModel.searchList) { music in - Button { - viewModel.handleSelectedMusic(with: music) - } label: { - ASMusicItemCell(music: music, fetchArtwork: { url in - await viewModel.downloadArtwork(url: url) - }) - .tint(.black) + .focused($isFocused) + if viewModel.isSearching { + VStack { + Spacer() + ProgressView() + .scaleEffect(2.0) + Spacer() + } + } else { + List(viewModel.searchList) { music in + Button { + viewModel.handleSelectedMusic(with: music) + } label: { + ASMusicItemCell(music: music, fetchArtwork: { url in + await viewModel.downloadArtwork(url: url) + }) + .tint(.black) + } } + .listStyle(.plain) + .edgesIgnoringSafeArea(.bottom) + .scrollDismissesKeyboard(.immediately) } - .listStyle(.plain) } .background(.asLightGray) .toolbar { @@ -66,7 +85,3 @@ struct SelectAnswerView: View { } } } - -#Preview { -// SelectMusicView(v) -} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewController.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewController.swift index 69243fe6..62077ec1 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewController.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewController.swift @@ -8,6 +8,7 @@ final class SubmitAnswerViewController: UIViewController { private var selectAnswerButton = ASButton() private var submitButton = ASButton() private var submissionStatus = SubmissionStatusView() + private var selectedAnswerView: UIHostingController? private var buttonStack = UIStackView() private let viewModel: SubmitAnswerViewModel private var cancellables: Set = [] @@ -28,9 +29,11 @@ final class SubmitAnswerViewController: UIViewController { setAction() bindToComponents() } - + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) viewModel.cancelSubscriptions() + cancellables.removeAll() } private func bindToComponents() { @@ -39,6 +42,7 @@ final class SubmitAnswerViewController: UIViewController { musicPanel.bind(to: viewModel.$music) selectedMusicPanel.bind(to: viewModel.$selectedMusic) submitButton.bind(to: viewModel.$musicData) + } private func setupUI() { @@ -70,7 +74,7 @@ final class SubmitAnswerViewController: UIViewController { progressBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), progressBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), progressBar.heightAnchor.constraint(equalToConstant: 16), - + musicPanel.topAnchor.constraint(equalTo: progressBar.bottomAnchor, constant: 32), musicPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), musicPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), @@ -79,7 +83,7 @@ final class SubmitAnswerViewController: UIViewController { selectedMusicPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), selectedMusicPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), selectedMusicPanel.heightAnchor.constraint(equalToConstant: 100), - + submissionStatus.topAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -16), submissionStatus.trailingAnchor.constraint(equalTo: buttonStack.trailingAnchor, constant: 16), @@ -105,15 +109,16 @@ final class SubmitAnswerViewController: UIViewController { private func setAction() { selectAnswerButton.addAction(UIAction { [weak self] _ in guard let self else { return } - let selectAnswerView = UIHostingController(rootView: SelectAnswerView(viewModel: viewModel)) - if let sheet = selectAnswerView.sheetPresentationController { + selectedAnswerView = UIHostingController(rootView: SelectAnswerView(viewModel: viewModel)) + if let sheet = selectedAnswerView?.sheetPresentationController { sheet.detents = [ .medium(), - .large() + .large(), ] sheet.prefersGrabberVisible = true } viewModel.stopMusic() + guard let selectAnswerView = selectedAnswerView else { return } present(selectAnswerView, animated: true) }, for: .touchUpInside) @@ -137,8 +142,9 @@ extension SubmitAnswerViewController { let alert = LoadingAlertController( progressText: .submitMusic, loadAction: { [weak self] in - try await self?.submitAnswer() - }) { [weak self] error in + try await self?.submitAnswer() + } + ) { [weak self] error in self?.showFailSubmitMusic(error) } presentAlert(alert) diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewModel.swift index 32c0a361..4dae9b4d 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewModel.swift @@ -7,6 +7,7 @@ import Foundation final class SubmitAnswerViewModel: ObservableObject, @unchecked Sendable { @Published public private(set) var searchList: [Music] = [] @Published public private(set) var selectedMusic: Music? + @Published public private(set) var isSearching: Bool = false @Published public private(set) var dueTime: Date? @Published public private(set) var recordOrder: UInt8? @Published public private(set) var status: Status? @@ -104,12 +105,16 @@ final class SubmitAnswerViewModel: ObservableObject, @unchecked Sendable { public func downloadArtwork(url: URL?) async -> Data? { guard let url else { return nil } - return await musicRepository.getMusicData(url: url) + do { + return try await musicRepository.getMusicData(url: url) + } catch { + return nil + } } public func downloadMusic(url: URL) { Task { - guard let musicData = await musicRepository.getMusicData(url: url) else { + guard let musicData = try await musicRepository.getMusicData(url: url) else { return } await updateMusicData(with: musicData) @@ -119,8 +124,10 @@ final class SubmitAnswerViewModel: ObservableObject, @unchecked Sendable { public func searchMusic(text: String) async throws { do { if text.isEmpty { return } + await updateIsSearching(with: true) let searchList = try await musicAPI.search(for: text) await updateSearchList(with: searchList) + await updateIsSearching(with: false) } catch { throw error } @@ -164,7 +171,6 @@ final class SubmitAnswerViewModel: ObservableObject, @unchecked Sendable { isRecording = false } - @MainActor public func resetSearchList() { searchList = [] } @@ -179,6 +185,11 @@ final class SubmitAnswerViewModel: ObservableObject, @unchecked Sendable { self.searchList = searchList } + @MainActor + private func updateIsSearching(with isSearching: Bool) { + self.isSearching = isSearching + } + public func cancelSubscriptions() { cancellables.removeAll() } diff --git a/firebase/functions/api/JoinRoom.js b/firebase/functions/api/JoinRoom.js index f0e28391..2d70357f 100644 --- a/firebase/functions/api/JoinRoom.js +++ b/firebase/functions/api/JoinRoom.js @@ -35,6 +35,7 @@ module.exports.joinRoom = onRequest({ region: 'asia-southeast1' }, async (req, r if (inGame) { return res.status(452).json({ error: 'Game has already started in this room' }); + } if (playerExists) { return res.status(400).json({ error: 'User already in the room' });